0c21adebf2
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.
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
/**
|
|
* read-document.test.ts - the internal single-document read (`src/surface/read-document.ts`).
|
|
*
|
|
* This file was `read-model.test.ts` until `1.0.0-dev.8`, when the published `readUnion`
|
|
* went. Three of its cases tested the BATCH POLICY rather than the read, and a batch no
|
|
* longer exists, so each was re-pointed at the property that replaced it rather than
|
|
* dropped:
|
|
*
|
|
* - "a doc that fails to read is SKIPPED, not aborting the batch" -> a read that fails
|
|
* THROWS. With no other document to carry on for, swallowing a failure would leave
|
|
* "it failed" and "it is empty" as the same answer, which is the family of defect
|
|
* this package has closed repeatedly.
|
|
* - "returns [] for an empty doc SET (no query)" -> a document that holds nothing is
|
|
* read, answers, and returns `[]`. That is the surviving distinction: an empty ANSWER.
|
|
* - "the same subject IRI in two documents stays two entries, told apart by `graph`" ->
|
|
* two reads, each reporting its own document. Nothing can merge across documents any
|
|
* more, because nothing shares a call; `graph` went with the batch, since the caller
|
|
* names exactly one document and already holds its reference.
|
|
*
|
|
* One BEHAVIOUR CHANGE is pinned below rather than left to be discovered: a document the
|
|
* holder has no capability for used to be dropped silently from the batch, and now makes
|
|
* the read THROW - the refusal belongs to `docs.sparqlQuery`, which is the published door
|
|
* this read goes through and which has always refused out loud.
|
|
*/
|
|
|
|
import { getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { test, expect, mock, afterAll } from "bun:test";
|
|
import { readDocumentSubjects } from "../src/surface/read-document";
|
|
import type { Nuri } from "../src/model/types";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps } from "../src/shared-wallet/bootstrap";
|
|
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
|
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
|
|
|
// The cap registry is process-wide, so each inject() starts from an empty one:
|
|
// once ANY cap exists the possession gate is in force for every reader, and a
|
|
// suite that never declares caps must not inherit another suite's.
|
|
afterAll(() => {
|
|
resetCaps();
|
|
resetOpenedRepos();
|
|
resetPublicStoreFetches();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
|
|
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
|
|
// NO anchorless union scan: a document is read by its own anchor. Each entity subject
|
|
// IRI IS its own document NURI (writeEntity convention), so the fixture keys triples by
|
|
// the doc NURI and returns them for the matching anchor.
|
|
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
return {
|
|
doc_create: mock(async () => "did:ng:o:new"),
|
|
sparql_update: mock(async () => undefined),
|
|
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
|
|
// Every read is ANCHORED to one doc NURI — never anchorless.
|
|
if (anchor === undefined) {
|
|
throw new Error("read-document must NEVER run an anchorless (union) query");
|
|
}
|
|
const doc = anchor as string;
|
|
const triples = triplesByDoc[doc];
|
|
if (!triples) return { results: { bindings: [] } };
|
|
const bindings = triples.map(([p, o]) => ({
|
|
s: { value: doc },
|
|
p: { value: p },
|
|
o: { value: o },
|
|
}));
|
|
return { results: { bindings } };
|
|
}),
|
|
};
|
|
}
|
|
|
|
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
const ng = fakeNgWith(triplesByDoc);
|
|
resetCaps();
|
|
resetOpenedRepos();
|
|
resetPublicStoreFetches();
|
|
setCurrentUser(null);
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
|
|
normalizeId: (u: string) => u,
|
|
});
|
|
return ng;
|
|
}
|
|
|
|
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
const FP = "http://festipod.org/";
|
|
|
|
test("reads the document with its OWN anchored query (never anchorless)", async () => {
|
|
const ng = inject({
|
|
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
|
|
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
|
|
});
|
|
const a = await readDocumentSubjects("did:ng:o:a");
|
|
const b = await readDocumentSubjects("did:ng:o:b");
|
|
|
|
// One anchored query per read = 2 sparql_query calls, each anchored (c[3] set).
|
|
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
|
|
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
|
|
expect(anchored.length).toBe(2);
|
|
// The anchors are exactly the documents that were named.
|
|
expect(anchored.map((c: unknown[]) => c[3])).toEqual(["did:ng:o:a", "did:ng:o:b"]);
|
|
|
|
expect(a.map((s) => s.subject)).toEqual(["did:ng:o:a"]);
|
|
expect(a[0]!.props[`${FP}title`]).toEqual(["A"]);
|
|
expect(b[0]!.props[`${FP}title`]).toEqual(["B"]);
|
|
});
|
|
|
|
test("groups predicates per subject", async () => {
|
|
inject({
|
|
"did:ng:o:p": [
|
|
[TYPE, `${FP}Participation`],
|
|
[`${FP}event`, "did:ng:o:e"],
|
|
[`${FP}user`, "urn:festipod:user:x"],
|
|
],
|
|
});
|
|
const s = (await readDocumentSubjects("did:ng:o:p"))[0]!;
|
|
expect(s.subject).toBe("did:ng:o:p");
|
|
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
|
|
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
|
|
});
|
|
|
|
test("a document that holds nothing is READ, answers, and returns []", async () => {
|
|
// The surviving half of the old "empty doc set" case, and the one that matters: an
|
|
// empty ANSWER is not a failure, and the query really was issued to obtain it. A caller
|
|
// deciding "absent versus unreadable" depends on exactly that difference.
|
|
const ng = inject({});
|
|
expect(await readDocumentSubjects("did:ng:o:empty")).toEqual([]);
|
|
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a read that FAILS throws, rather than reading as an empty document", async () => {
|
|
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
|
|
const orig = ng.sparql_query;
|
|
// Make the anchored read throw for the bad doc only.
|
|
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
|
|
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
|
|
return orig(sid, query, base, anchor);
|
|
}) as any;
|
|
resetCaps();
|
|
resetOpenedRepos();
|
|
resetPublicStoreFetches();
|
|
setCurrentUser(null);
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
|
|
normalizeId: (u: string) => u,
|
|
});
|
|
|
|
// The batch used to swallow this one and carry on with the good document. There is no
|
|
// "carry on" left, so the failure reaches the caller as a failure — which is the ONLY
|
|
// thing that keeps it distinguishable from a document that is genuinely empty.
|
|
await expect(readDocumentSubjects("did:ng:o:bad")).rejects.toThrow(/RepoNotFound/);
|
|
// The neighbour is unaffected: nothing is shared between two reads.
|
|
expect((await readDocumentSubjects("did:ng:o:ok")).map((s) => s.subject)).toEqual([
|
|
"did:ng:o:ok",
|
|
]);
|
|
});
|
|
|
|
// The possession gate: once ANY cap exists, a document whose cap is not in what the
|
|
// current holder holds is REFUSED — however well its NURI resolves. Before the first cap
|
|
// the gate is inert (no regression).
|
|
test("a document whose cap the holder does not hold is refused, out loud", async () => {
|
|
inject({
|
|
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
|
|
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
|
|
});
|
|
const mine = "did:ng:o:mine" as Nuri;
|
|
const theirs = "did:ng:o:theirs" as Nuri;
|
|
|
|
// Inert: no cap issued yet → everything flows through.
|
|
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
|
|
expect((await readDocumentSubjects(theirs)).map((s) => s.subject)).toEqual([theirs]);
|
|
|
|
// One cap issued → possession is now the rule for every document. BEHAVIOUR CHANGE:
|
|
// the batch dropped the unheld document silently and answered with the rest; a single
|
|
// read has no rest, so `docs.sparqlQuery` refuses it the way it always has — loudly.
|
|
setCurrentUser("alice");
|
|
getCaps().mint(mine);
|
|
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
|
|
await expect(readDocumentSubjects(theirs)).rejects.toThrow(/does not hold this document/i);
|
|
|
|
// …and for every holder: bob holds nothing, so bob reads nothing.
|
|
setCurrentUser("bob");
|
|
await expect(readDocumentSubjects(mine)).rejects.toThrow(/does not hold this document/i);
|
|
});
|
|
|
|
test("a malformed reference is refused, loudly", async () => {
|
|
// A non-reference is a caller mistake worth a loud error, never an empty read: swallowing
|
|
// it is how a mistake becomes an absence nobody can explain. The list tolerance this
|
|
// used to sit beside went with the batch — there is no list to carry a hole any more.
|
|
inject({});
|
|
await expect(readDocumentSubjects("not-a-nuri" as Nuri)).rejects.toThrow(
|
|
/not a NextGraph reference/i,
|
|
);
|
|
});
|
|
|
|
// ── several subjects inside one document ───────────────────────────────────
|
|
//
|
|
// The fixture above pins every row's subject to the doc NURI, which is what an
|
|
// application writing one entity per document produces — the recommended placement,
|
|
// and the only case it can exercise. A document may nevertheless carry SEVERAL
|
|
// subjects, and upstream that is the provided case, not an accident: the ORM carries
|
|
// `@id` and `@graph` as two distinct read-only properties and fabricates an `@id`
|
|
// when the writer leaves it empty, precisely so objects sharing a `@graph` stay
|
|
// distinguishable. This fixture lets the subject vary so that case can be tested.
|
|
function injectTriples(triplesByDoc: Record<string, Array<[string, string, string]>>) {
|
|
const ng = {
|
|
doc_create: mock(async () => "did:ng:o:new"),
|
|
sparql_update: mock(async () => undefined),
|
|
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
|
|
if (anchor === undefined) {
|
|
throw new Error("read-document must NEVER run an anchorless (union) query");
|
|
}
|
|
const triples = triplesByDoc[anchor as string];
|
|
if (!triples) return { results: { bindings: [] } };
|
|
return {
|
|
results: {
|
|
bindings: triples.map(([s, p, o]) => ({
|
|
s: { value: s },
|
|
p: { value: p },
|
|
o: { value: o },
|
|
})),
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
resetCaps();
|
|
resetOpenedRepos();
|
|
resetPublicStoreFetches();
|
|
setCurrentUser(null);
|
|
configure({ ng, useShape: () => undefined });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
|
|
normalizeId: (u: string) => u,
|
|
});
|
|
return ng;
|
|
}
|
|
|
|
test("two subjects in ONE document come back as two entries, unmixed", async () => {
|
|
// The document holds two entities under two IRIs of the consumer's choosing.
|
|
injectTriples({
|
|
"did:ng:o:d": [
|
|
["urn:app:alice", TYPE, `${FP}Person`],
|
|
["urn:app:alice", `${FP}title`, "A"],
|
|
["urn:app:bob", TYPE, `${FP}Person`],
|
|
["urn:app:bob", `${FP}title`, "B"],
|
|
],
|
|
});
|
|
|
|
const subjects = await readDocumentSubjects("did:ng:o:d");
|
|
|
|
// Under the old per-document fold this was ONE entry subject'd `did:ng:o:d`,
|
|
// whose `${FP}title` carried BOTH ["A","B"] — two entities conflated into one bag,
|
|
// and both re-labelled with the document's own NURI.
|
|
expect(subjects.length).toBe(2);
|
|
expect(subjects.map((s) => s.subject).sort()).toEqual(["urn:app:alice", "urn:app:bob"]);
|
|
|
|
const alice = subjects.find((s) => s.subject === "urn:app:alice")!;
|
|
const bob = subjects.find((s) => s.subject === "urn:app:bob")!;
|
|
expect(alice.props[`${FP}title`]).toEqual(["A"]);
|
|
expect(bob.props[`${FP}title`]).toEqual(["B"]);
|
|
});
|
|
|
|
test("a subject written under an IRI of its own is NOT re-labelled with the doc NURI", async () => {
|
|
injectTriples({
|
|
"did:ng:o:d": [["urn:app:thing", `${FP}title`, "T"]],
|
|
});
|
|
const [only] = await readDocumentSubjects("did:ng:o:d");
|
|
// The old fold reported `did:ng:o:d` here, a subject the document never carried.
|
|
expect(only!.subject).toBe("urn:app:thing");
|
|
});
|
|
|
|
test("the SAME subject IRI in two documents is never merged - two reads, two answers", async () => {
|
|
injectTriples({
|
|
"did:ng:o:a": [["urn:app:shared", `${FP}title`, "in-a"]],
|
|
"did:ng:o:b": [["urn:app:shared", `${FP}title`, "in-b"]],
|
|
});
|
|
const a = await readDocumentSubjects("did:ng:o:a");
|
|
const b = await readDocumentSubjects("did:ng:o:b");
|
|
// Two documents carrying one subject IRI are two objects. The batch had to say so with
|
|
// a `graph` field on every entry; a read that names ONE document says it structurally,
|
|
// because nothing of one read's answer can reach the other's.
|
|
expect(a.map((s) => s.subject)).toEqual(["urn:app:shared"]);
|
|
expect(b.map((s) => s.subject)).toEqual(["urn:app:shared"]);
|
|
expect(a[0]!.props[`${FP}title`]).toEqual(["in-a"]);
|
|
expect(b[0]!.props[`${FP}title`]).toEqual(["in-b"]);
|
|
});
|
|
|
|
test("machinery is dropped per subject, and the real subjects beside it survive", async () => {
|
|
injectTriples({
|
|
"did:ng:o:d": [
|
|
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
|
|
["urn:app:entity", `${FP}title`, "kept"],
|
|
],
|
|
});
|
|
const subjects = await readDocumentSubjects("did:ng:o:d");
|
|
expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]);
|
|
expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]);
|
|
});
|
|
|
|
test("a document holding ONLY machinery yields no entry at all", async () => {
|
|
injectTriples({
|
|
"did:ng:o:d": [
|
|
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
|
|
],
|
|
});
|
|
expect(await readDocumentSubjects("did:ng:o:d")).toEqual([]);
|
|
});
|