43aadbeb45
98 annotations posées à côté des déclarations, et un test qui les exige sur la surface publiée. Elles portent trois choses : le niveau qui répond, la référence amont, et la catégorie parmi les cinq. La cinquième est celle qui manquait : declared-not-wired, quand la cible DÉFINIT la forme et ne la câble pas. Neuf symboles en relèvent, dont readLinks — que j'avais classé « notre invention » en raisonnant depuis l'absence, alors que c'est le meilleur alignement disponible. Les références citent un SYMBOLE, jamais une ligne : trois citations du document avaient déjà pourri. Cinq corrections au passage, toutes vérifiées à la source — un chemin ORM qui n'existe pas, deux plages de lignes fausses, et surtout docs.* et subscribeDoc étiquetés PASSTHROUGH alors qu'ils sont alignés : nos noms, plus un argument jamais transmis. La sémantique survit à la migration, les sites d'appel non, et la nuance disparaissait sous une étiquette trop flatteuse. Le test échoue à l'annotation retirée, à la catégorie mal orthographiée, et à une invention qui prétendrait citer une référence — vérifié en cassant les trois. Il a aussi attrapé un défaut en lui-même : le gabarit de format placé dans index.ts se faisait analyser comme une annotation. La classification couvre l'interne qui prétend ressembler à la cible — tout emulated-verifier — et exclut ce qui ne le prétend pas. La faute d'origine portait sur une fonction non exportée ; n'être pas publié n'a protégé personne. Quatre symboles ont résisté et sont annotés avec leur catégorie dominante, la seconde nommée dans la note plutôt que lissée.
247 lines
13 KiB
TypeScript
247 lines
13 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 "../shared-wallet/bootstrap";
|
|
import { mustNotAttempt } from "../emulated-verifier/reach";
|
|
import { ensureReposOpen } from "../emulated-verifier/open-repo";
|
|
import { assertNuri } from "./sparql";
|
|
import { toNuri } from "../model/nuri";
|
|
import { isMachinerySubject } from "../emulated-verifier/machinery";
|
|
import type { Nuri, NuriLike } from "../model/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). */
|
|
// @provenance UnionSubject kind=invention level=none ref=none — no upstream type groups triples per (document, subject); a polyfill property bag, to be mapped into app types at the boundary
|
|
export interface UnionSubject {
|
|
/**
|
|
* The subject IRI (`?s`), exactly as the document carries it.
|
|
*
|
|
* Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY
|
|
* IRI. An application that writes its entity under the document's own NURI gets a
|
|
* NURI back here, but that is its convention, not this type's promise — a document
|
|
* may hold several subjects under IRIs of the consumer's choosing, and each comes
|
|
* back as written.
|
|
*
|
|
* The need that once made this `Nuri` is real and is met by {@link graph}: a
|
|
* consumer must be able to pass back what it just read without a cast —
|
|
* `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary
|
|
* would re-open exactly the confusion the template literal types exist to close.
|
|
* `graph` is the document reference, so that is the field to carry around.
|
|
*/
|
|
subject: string;
|
|
/**
|
|
* The document this subject was read from — the reference the caller passed to
|
|
* {@link readUnion}, unchanged. This is the anchor, it identifies the document
|
|
* stably, and it is what goes back into any call of this surface.
|
|
*/
|
|
graph: Nuri;
|
|
/** 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 | number> {
|
|
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 | number,
|
|
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.
|
|
*
|
|
* A document holding SEVERAL subjects yields several entries — one per distinct
|
|
* subject, carrying that subject as written, all sharing the document as their
|
|
* `graph`. Properties of different subjects are never merged. Placing one business
|
|
* entity per document stays the recommended practice (a key is per repo, so
|
|
* isolation needs a repo per entity), but it is a recommendation about writing: the
|
|
* read reports what is there.
|
|
*
|
|
* 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`.
|
|
*/
|
|
// @provenance readUnion kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:sparql_query — one anchored query per document — upstream's own anchoring (`resolve_target_for_sparql`), composed client-side
|
|
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
|
|
const sid = await sessionId();
|
|
// Drop the empties BEFORE validating, not after: this call has always tolerated a
|
|
// list with holes in it — a scope index can carry a blank entry, and a caller
|
|
// building a list from optional values should not have to compact it. Validating
|
|
// first turned that tolerance into a throw, which took down a whole reconnect run.
|
|
// Empty is absence, and absence is not a malformed reference.
|
|
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
|
|
if (unique.length === 0) return [];
|
|
|
|
// 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.
|
|
//
|
|
// Called on the WHOLE set, before the boundary is consulted, because opening is also
|
|
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
|
|
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
|
|
// still refuses to open what this user may not touch — it asks, it does not enter.
|
|
await ensureReposOpen(unique);
|
|
|
|
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
|
// hold before 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));
|
|
|
|
// 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. The read is
|
|
// anchored per document, so the unit of possession is the document: the cap key is
|
|
// the doc NURI, whatever subjects that document turns out to carry.
|
|
const caps = getCaps();
|
|
|
|
// Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is
|
|
// the identity upstream gives an object too: the ORM carries `@id` and `@graph` as
|
|
// two distinct read-only properties, and fabricates an `@id` when the writer leaves
|
|
// it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per
|
|
// graph is therefore the PROVIDED case, and `@id` is what tells them apart within a
|
|
// `@graph`. Two documents carrying the same subject IRI stay two entries: they are
|
|
// two objects, distinguished by their graph.
|
|
//
|
|
// Placing one business entity per document remains the recommended practice — a key
|
|
// is per repo, so isolating an entity requires a repo of its own. That is a
|
|
// recommendation about WRITING, and the read does not get to enforce it by making
|
|
// the other arrangement invisible.
|
|
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` — hence `graph` is the caller's
|
|
// reference to it. The subject comes back exactly as the document carries it.
|
|
for (const row of rows) {
|
|
// The polyfill's own compartments live as reserved SUBJECTS inside the very
|
|
// documents the consumer reads (the Header branch carrying a document's inbox
|
|
// address is the first). They are machinery, not this entity's properties —
|
|
// drop them here, once, for every compartment present and future.
|
|
const s = row.s?.value;
|
|
if (isMachinerySubject(s)) continue;
|
|
const p = row.p?.value;
|
|
const o = row.o?.value;
|
|
if (s === undefined || !p || o === undefined) continue;
|
|
// NUL cannot appear in an IRI, so the pair never collides with either half.
|
|
const key = `${doc}\u0000${s}`;
|
|
let entry = bySubject.get(key);
|
|
if (!entry) {
|
|
entry = { subject: s, graph: doc, props: {} };
|
|
bySubject.set(key, entry);
|
|
}
|
|
(entry.props[p] ??= []).push(o);
|
|
}
|
|
}
|
|
return [...bySubject.values()];
|
|
}
|