Files
ng-eventually/packages/polyfill/test/reload-own-document.test.ts
T
Sylvain Duchesne 0c21adebf2 feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree
readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
2026-09-21 14:08:05 +02:00

448 lines
16 KiB
TypeScript

/**
* reload-own-document.test.ts — the creator, after a page RELOAD, meets its own document.
*
* ── The report this reproduces ────────────────────────────────────────────
* *"Create an object, refresh the browser, and the creator is denied access to the object
* it just created."*
*
* A page reload is not a subtle event: every module-level cache the library holds is gone
* (the caps registry among them), and the durable wallet is exactly as the first session
* left it. What survives is what was WRITTEN — the scope index's Main branch (`contains`),
* its Store branch (`readCap`, the emulated `AddRepo`), and the document's own triples.
*
* So the question this suite asks is: after `ensureIdentity()` has connected the same
* identity over the same wallet, what does the creator get when it goes back to the
* document it made? And it asks it three ways, because three different defects hide behind
* the word "denied":
*
* - the document is ABSENT from the listing;
* - it is listed, and reading it returns NOTHING;
* - reading it is REFUSED with an error.
*
* …and for all three scopes, because a document in a public store serves its key to
* whoever asks (`emulated-verifier/public-store.ts`) while a protected or private one
* does not — so the scope is exactly the variable that decides.
*
* ── How the reload is simulated ───────────────────────────────────────────
* By dropping every piece of the library's module state ({@link reload}) while the fake
* broker keeps its quads. Nothing is hand-planted: the second session starts from an
* empty cap registry and an unresolved shim, and has to find its way back through the
* pointer, the doc-shim and the account record exactly as a fresh page does.
*/
import { test, expect, describe, mock, afterAll, beforeEach } from "bun:test";
import { configure, docs, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
setCurrentUser,
resetCaps,
resetConfig,
resetStoreRegistry,
getCaps,
} from "../src/shared-wallet/bootstrap";
import { listMyEntityDocs, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { connectedUser } from "../src/emulated-verifier/connect";
import { storeOfType } from "./wallet-fake";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
const SESSION: RegistrySession = { sessionId: "sid-reload", privateStoreId: "PRIV-RELOAD" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** The application's own predicate — the note's title, in the document it created. */
const TITLE = "urn:test:title";
/**
* The titles `doc` carries, read the way an APPLICATION reads one document since
* `1.0.0-dev.8`: the published anchored `docs.sparqlQuery`, naming the document. It
* replaces `readUnion([doc])`, which was the same anchored query with a batch around it.
*/
async function titlesIn(doc: Nuri): Promise<string[]> {
const res = await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"readDoc",
);
const rows = (res as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
return rows.filter((r) => r["p"]?.value === TITLE).map((r) => r["o"]!.value);
}
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
// --- the fake broker -------------------------------------------------------
interface Quad {
g: string;
s: string;
p: string;
o: string;
}
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
interface Token {
kind: "term" | "sep";
value: string;
}
/**
* Tokenize a triple body into IRIs, literals, `a`, `;` and `.`.
*
* A tokenizer rather than one regex per known query shape: the reload path issues
* writes from six different modules, and a per-shape fake would answer only the shapes
* whoever wrote it happened to think of — which is how a fake ends up green on a state
* the library never reaches.
*/
function tokenize(body: string): Token[] {
const re = /<([^>]*)>|"((?:[^"\\]|\\.)*)"|(;)|(\.)|\ba\b/g;
const out: Token[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
if (m[1] !== undefined) out.push({ kind: "term", value: m[1] });
else if (m[2] !== undefined) out.push({ kind: "term", value: unescapeLiteral(m[2]) });
else if (m[3] !== undefined) out.push({ kind: "sep", value: ";" });
else if (m[4] !== undefined) out.push({ kind: "sep", value: "." });
else out.push({ kind: "term", value: RDF_TYPE });
}
return out;
}
/** `<s> p o ; p o . <s2> p o` → the triples it carries. */
function parseTriples(body: string): Array<{ s: string; p: string; o: string }> {
const toks = tokenize(body);
const out: Array<{ s: string; p: string; o: string }> = [];
let subject: string | null = null;
let i = 0;
while (i < toks.length) {
const t = toks[i]!;
if (t.kind === "sep") {
if (t.value === ".") subject = null;
i += 1;
continue;
}
if (subject === null) {
subject = t.value;
i += 1;
continue;
}
const p = toks[i];
const o = toks[i + 1];
if (!p || !o || p.kind === "sep" || o.kind === "sep") break;
out.push({ s: subject, p: p.value, o: o.value });
i += 2;
}
return out;
}
/**
* A quad-store fake `ng`. It holds the wallet; the library holds nothing across a
* {@link reload}, which is the whole point.
*
* No `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the unit-fake path
* (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly. That is a
* limit of the fake broker, not a library state — the defect under test is about keys,
* not about bringing a repo into the session.
*/
function makeWallet(quads: Quad[] = []) {
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const del = query.match(/DELETE\s+WHERE\s*\{([\s\S]*)\}/i);
if (del && anchor !== undefined) {
// `<s> <p> ?o` clears one register slot; `<s> ?p ?o` retires a whole DEPOSIT
// (`emulated-verifier/inbox-consume.removeDeposit`) — a deposit carries
// `a`/`payload`/`ts` and optionally `from`, so naming predicates would leave some.
const boundPredicate = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/);
const wholeSubject = del[1]!.match(/<([^>]+)>\s+\?\w+\s+\?\w+/);
const matches = boundPredicate
? (q: Quad) => q.s === boundPredicate[1] && q.p === boundPredicate[2]
: wholeSubject
? (q: Quad) => q.s === wholeSubject[1]
: null;
if (matches) {
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && matches(q)) quads.splice(i, 1);
}
}
return undefined;
}
const wrapped = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const g = wrapped ? wrapped[1]! : anchor;
if (g === undefined) return undefined;
const body = wrapped
? wrapped[2]!
: query.replace(/^[\s\S]*?INSERT\s+DATA\s*\{/i, "").replace(/\}\s*$/, "");
for (const t of parseTriples(body)) quads.push({ g, ...t });
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
const wrapped = query.match(/GRAPH\s+<([^>]+)>/);
const g = wrapped ? wrapped[1]! : anchor;
const inGraph = quads.filter((q) => q.g === g);
// The whole-document read (`read-model.readDoc`).
if (/SELECT\s+\?s\s+\?p\s+\?o/.test(query)) {
return {
results: {
bindings: inGraph.map((q) => ({
s: { value: q.s },
p: { value: q.p },
o: { value: q.o },
})),
},
};
}
// The account record — several predicates on one subject.
if (query.includes(`<${SHIM}:docPublic>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<[^>]*:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of inGraph) {
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id !== undefined)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// An inbox's deposits — several predicates on one subject, `from` optional.
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of inGraph) {
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// Everything else the library reads is one bound subject, one bound predicate,
// one variable: the pointer, the store index, the Store/User/Header branches,
// the inbox index and its owner.
const one = query.match(/<([^>]+)>\s+<([^>]+)>\s+\?(\w+)/);
if (one) {
const bindings = inGraph
.filter((q) => q.s === one[1] && q.p === one[2])
.map((q) => ({ [one[3]!]: { value: q.o } }));
return { results: { bindings } };
}
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
// --- the page --------------------------------------------------------------
function boot(quads: Quad[]): ReturnType<typeof makeWallet> {
const ng = makeWallet(quads);
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
return ng;
}
/**
* A page RELOAD: everything the library holds in module scope goes, the wallet stays.
*
* Each call here drops one module's state, and together they are all of it — the config
* and the captured session, the registry caches (accounts, the resolved doc-shim, the
* inbox index), the opened repos, the outer-overlay memo, the caps, and who is connected.
* A fresh page has none of them, so neither does the session that follows.
*/
function reload(quads: Quad[]): ReturnType<typeof makeWallet> {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetPublicStoreFetches();
resetCaps();
return boot(quads);
}
/** Sign in and let the connection work finish — what `ensureIdentity()` awaits. */
async function signIn(id: string): Promise<void> {
setCurrentUser(id);
await connectedUser();
}
/** The first visit: sign in, create the note, write its title into it. */
async function firstVisit(quads: Quad[], scope: StoreType): Promise<Nuri> {
boot(quads);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "the note" }`,
note,
"writeEntity",
);
return note;
}
beforeEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetPublicStoreFetches();
resetCaps();
});
afterAll(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetPublicStoreFetches();
resetCaps();
});
// --- the reproduction ------------------------------------------------------
describe("after a reload, the creator comes back to its own document", () => {
// Shape 1 — is it even there? The listing is the path the applicative journey takes,
// and it is also the path that REFILES the caps, so it is asked first and alone.
for (const scope of ALL_SCOPES) {
test(`[${scope}] it is still LISTED among my documents`, async () => {
const quads: Quad[] = [];
const note = await firstVisit(quads, scope);
reload(quads);
await signIn("alice");
expect(await listMyEntityDocs("alice", scope)).toContain(note);
});
}
// Shape 2 — listed, but does reading it answer? The anchored `docs.sparqlQuery` is what
// an application reads one document with, and it REFUSES a document whose cap the reader
// does not hold. It did not always: until `1.0.0-dev.8` the published read for this was
// `readUnion`, which DROPPED such a document — a failure arriving as an absence, which
// is the family this project has been bitten by, and half the reason that call went.
for (const scope of ALL_SCOPES) {
test(`[${scope}] reading it by reference answers its content`, async () => {
const quads: Quad[] = [];
const note = await firstVisit(quads, scope);
reload(quads);
await signIn("alice");
// The application kept the reference (a route, a deep link) and reads it directly,
// without listing its store first.
expect(await titlesIn(note)).toEqual(["the note"]);
});
}
// Shape 3 — is it REFUSED? The guarded passage point, which is what an application
// reaches through `docs.*` and what every read of the library goes through.
for (const scope of ALL_SCOPES) {
test(`[${scope}] reading it through the guarded surface is not refused`, async () => {
const quads: Quad[] = [];
const note = await firstVisit(quads, scope);
reload(quads);
await signIn("alice");
await docs.sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
note,
"readDoc",
);
});
}
// …and the same question for a WRITE: editing the note one made is the other half of
// "denied access to the object it just created".
for (const scope of ALL_SCOPES) {
test(`[${scope}] writing to it again is not refused`, async () => {
const quads: Quad[] = [];
const note = await firstVisit(quads, scope);
reload(quads);
await signIn("alice");
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "edited" }`,
note,
"writeEntity",
);
});
}
// What the connection itself restored, stated as a fact rather than inferred from the
// symptom: does the reconnected session HOLD the key of the document it created?
for (const scope of ALL_SCOPES) {
test(`[${scope}] the reconnected session holds its key`, async () => {
const quads: Quad[] = [];
const note = await firstVisit(quads, scope);
reload(quads);
await signIn("alice");
expect(getCaps().capFor(note)).toBeDefined();
});
}
});