Files
ng-eventually/packages/polyfill/test/listing-surfaces-failure.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

180 lines
8.2 KiB
TypeScript

/**
* `listMyEntityDocs` answers a VERIFIED listing, or it surfaces — never a half-truth.
*
* The call stands on two reads of the same store document, one behind the other: the Main
* branch says WHICH documents are in there, the Store branch hands back the KEY of each
* (the emulated `AddRepo { read_cap }`). Both have to answer for the array it returns to
* mean what a caller reads into it — *these are your documents, here they are*.
*
* Neither failure is visible from the outside, and that is the whole reason this file
* exists. A listing whose keys never arrived is the SAME `Nuri[]` as one whose keys did:
* the caller learns nothing at the call, and finds out at the next read, which comes back
* empty with the cause long gone. An empty listing over a store that did not answer is
* worse still — it says "you created nothing", and an application renders that.
*
* That is the shape this library shipped twice already: `resolveAccount` answering "no such
* account" for a read that FAILED (`8c8ade7`), and `connectedUser` resolving over a restore
* that never ran (`f6d1734`, which reached a consuming application). The listing was the
* last member of the family, tolerated on the argument that this caller already holds the
* array by the time the key read fails — which is exactly what makes returning it a lie
* rather than a shortcut.
*
* ── The fault is the broker's, and it is one a real one produces ──────────
* The two reads are two round-trips. Between them the connection can die — that is all
* that is simulated here: the broker answers normally, then stops answering, and every
* later query rejects the way the wasm binding rejects a `RepoNotFound`/transport error.
* Nothing reaches into the library to make one of its functions throw artificially, and
* nothing plants a state the wallet could not be in: the documents below were CREATED
* through the surface, over the same wallet, in the session before.
*/
import { test, expect, describe, mock, beforeEach } from "bun:test";
import { configure, storeRegistry, docs } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read — "which documents are in this store". */
const LISTING_READ = `<${SHIM}:contains>`;
/** The Store-branch read — "and what is the key of each". */
const KEY_READ = `<${SHIM}:readCap>`;
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);
}
/**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
*
* A dropped connection is not selective, so neither is this: the switch decides *when* the
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up.
*/
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
const wallet = makeWallet(quads);
let lost = false;
const ng = {
doc_create: wallet.doc_create,
sparql_update: wallet.sparql_update,
sparql_query: mock(async (...a: unknown[]) => {
if (lost || lostAt(a[1] as string)) {
lost = true;
throw new Error("BrokerError: connection lost");
}
return wallet.sparql_query(...a);
}),
};
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
}
/**
* The session before: alice signs in and creates one document, over a broker that works.
*
* Returns the reference she keeps, and the quads the wallet now holds — the durable half
* that outlives the page.
*/
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <urn:test:note> <${TITLE}> "the note" }`,
note,
);
return { quads, note };
}
/**
* The page comes back over the same wallet and alice signs in — and only THEN does the link
* die, on `read`. Signing in succeeds, so the failure lands inside the one call under test
* rather than upstream of it, which is what makes the assertion about `listMyEntityDocs`.
*/
async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Promise<void> {
let armed = false;
forgetEverything();
bootPage(quads, (query) => armed && query.includes(read));
await signIn("alice");
armed = true;
}
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
beforeEach(() => {
forgetEverything();
});
describe("listMyEntityDocs answers a verified listing or surfaces", () => {
// The NORMAL case first, and it is not a formality: a call that threw unconditionally
// would pass both failure branches below. This is what the two of them are a departure
// from — the listing answers, and every document it names opens.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a broker that answers gives a listing whose documents open`, async () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
expect(await listMyEntityDocs("alice", scope)).toContain(note);
// Read the way an application reads one document now that `readUnion` is gone: the
// published anchored query, naming the document it was just handed.
expect(await titlesIn(note)).toEqual(["the note"]);
});
}
// The tolerance this file was written to remove: the listing is IN HAND when the key read
// fails, so returning it costs the library nothing and tells the caller something false.
for (const scope of ALL_SCOPES) {
test(`[${scope}] the keys not answering surfaces, instead of a keyless listing`, async () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, KEY_READ);
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
// And the point of the refusal, stated as the caller sees it: nothing came back that
// could be mistaken for "here are your documents".
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([note]);
});
}
// The other half of the same promise, and the more dangerous value of the two: an empty
// array reads as "this account created nothing", which an application renders as an
// empty screen rather than as a breakdown.
for (const scope of ALL_SCOPES) {
test(`[${scope}] the listing itself not answering surfaces, instead of "you own nothing"`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ);
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([]);
});
}
});