ae9c32e271
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
188 lines
9.2 KiB
TypeScript
188 lines
9.2 KiB
TypeScript
/**
|
|
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
|
|
* of documents, each with its own anchored `sparql_query`, and return the triples
|
|
* grouped per subject. This is the mechanism documented in docs/read-model.md.
|
|
*
|
|
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
|
|
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
|
|
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
|
|
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
|
|
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
|
|
* doc, independent of how many other graphs the local store holds.
|
|
*
|
|
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
|
|
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
|
|
* store. On a shared / bloated wallet that accumulates across runs, that is
|
|
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
|
|
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
|
|
*
|
|
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
|
|
* body iterates the named graphs regardless of the default graph, so an anchor does
|
|
* not bound such a body. The per-doc read therefore uses a default-graph body (no
|
|
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
|
|
*
|
|
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
|
|
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
|
|
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
|
|
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
|
|
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
|
|
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
|
|
* union query, so reactivity is assembled by re-querying on a change signal.
|
|
*
|
|
* ── Generic by construction ───────────────────────────────────────────────
|
|
* No application domain here: the consumer passes the doc NURIs to read (from
|
|
* the discovery index for public events, or its own scope docs for my-entities)
|
|
* and interprets the returned per-subject property bags. All NextGraph I/O routes
|
|
* through the T01.a `docs` primitives (the real injected `ng`), so this module
|
|
* imports no `@ng-org` package.
|
|
*
|
|
* At the real multi-store migration the per-doc anchored read is unchanged (native
|
|
* SPARQL, anchored to one repo); only bringing a repo into the session (open by cap)
|
|
* changes — the anchored query already resolves a same-session repo directly.
|
|
*/
|
|
|
|
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
|
import { getCaps, getStoreRegistryDeps } from "./polyfill";
|
|
import { mustNotAttempt } from "./reach";
|
|
import { ensureReposOpen } from "./open-repo";
|
|
import { assertNuri } from "./sparql";
|
|
import type { Nuri } from "./types";
|
|
|
|
// Keep the primitives referenced so tree-shaking never drops the import used by
|
|
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
|
|
// here but the module intentionally depends only on the docs primitive surface.
|
|
void docCreate;
|
|
void sparqlUpdate;
|
|
|
|
/** One subject read from a doc, with its properties (predicate → values). */
|
|
export interface UnionSubject {
|
|
/** The subject IRI (`?s`) — in the polyfill, the doc's own NURI. */
|
|
subject: string;
|
|
/** The graph (doc NURI) the subject was read from. */
|
|
graph: string;
|
|
/** predicate IRI → the list of object values (literals or IRIs) for it. */
|
|
props: Record<string, string[]>;
|
|
}
|
|
|
|
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
|
function bindings(
|
|
result: unknown,
|
|
): Array<Record<string, { value: string } | undefined>> {
|
|
if (!result) return [];
|
|
if (Array.isArray(result))
|
|
return result as Array<Record<string, { value: string }>>;
|
|
const anyRes = result as {
|
|
results?: { bindings?: Array<Record<string, { value: string }>> };
|
|
};
|
|
return anyRes.results?.bindings ?? [];
|
|
}
|
|
|
|
async function sessionId(): Promise<string> {
|
|
return (await getStoreRegistryDeps().getSession()).sessionId;
|
|
}
|
|
|
|
/**
|
|
* Read one doc with an anchored default-graph query, tolerant per-doc.
|
|
*
|
|
* The anchor (`doc` NURI) restricts the query to that repo's graph as the default
|
|
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
|
|
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
|
|
* This is O(1) in the doc's own size and independent of the rest of the (possibly
|
|
* bloated / shared) session store — it never iterates other graphs.
|
|
*
|
|
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
|
|
* `self.repos` until something opens it, and an anchored query against an unopened
|
|
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
|
|
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
|
|
* anchored query resolves a same-session repo directly. A genuinely-absent repo
|
|
* still yields `[]` (in isolation, never aborting the others). Returns the doc's
|
|
* rows, or `[]` on failure.
|
|
*
|
|
* At the real multi-store migration this becomes a real sync: opening a per-user
|
|
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
|
|
*/
|
|
async function readDoc(
|
|
sid: string,
|
|
doc: Nuri,
|
|
): Promise<Array<Record<string, { value: string } | undefined>>> {
|
|
try {
|
|
const nuri = assertNuri(doc);
|
|
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
|
|
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
|
|
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
|
|
const res = await sparqlQuery(
|
|
sid,
|
|
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
|
undefined,
|
|
nuri,
|
|
"readDoc",
|
|
);
|
|
return bindings(res);
|
|
} catch (error) {
|
|
console.error("[read-model] read failed for", doc, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read a BOUNDED, by-need set of docs — each with its OWN anchored query — and
|
|
* return the triples grouped per subject. `docs` are the NURIs to read (the
|
|
* consumer resolves them by need — index for public, own scope docs for mine).
|
|
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
|
|
* batch.
|
|
*
|
|
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
|
|
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
|
|
* read with an anchored default-graph query, O(1) per doc, independent of wallet
|
|
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
|
|
*/
|
|
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
|
|
const sid = await sessionId();
|
|
const unique = [...new Set(docs.filter(Boolean))];
|
|
if (unique.length === 0) return [];
|
|
|
|
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
|
// hold BEFORE opening or reading anything: upstream you cannot address a repo you
|
|
// have no cap for, so asking about one is not "a read that will be refused", it is
|
|
// a read that has no meaning. (The passage points enforce rule 1 regardless — see
|
|
// reach.ts — so a lapse here is caught, not exploited.)
|
|
const reachable = unique.filter((d) => !mustNotAttempt(d));
|
|
|
|
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
|
// target repos are not yet in `self.repos`, so an anchored read would return 0
|
|
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
|
|
// initial-state push before the anchored reads. No-op once opened / when the
|
|
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
|
|
await ensureReposOpen(reachable);
|
|
|
|
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
|
|
const perDoc = await Promise.all(
|
|
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
|
);
|
|
|
|
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
|
|
// already excluded these, so this loop should never drop anything. In this
|
|
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
|
|
const caps = getCaps();
|
|
|
|
const bySubject = new Map<string, UnionSubject>();
|
|
for (const { doc, rows } of perDoc) {
|
|
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
|
|
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
|
|
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
|
|
// is stable regardless of the repo_graph_name overlay suffix the store carries.
|
|
for (const row of rows) {
|
|
const p = row.p?.value;
|
|
const o = row.o?.value;
|
|
if (!p || o === undefined) continue;
|
|
let entry = bySubject.get(doc);
|
|
if (!entry) {
|
|
entry = { subject: doc, graph: doc, props: {} };
|
|
bySubject.set(doc, entry);
|
|
}
|
|
(entry.props[p] ??= []).push(o);
|
|
}
|
|
}
|
|
return [...bySubject.values()];
|
|
}
|