fix: une liste qu'on ne peut pas ouvrir n'est pas une liste
listMyEntityDocs rendait la liste des documents même quand la lecture des clés échouait. Vu de l'appelant, une liste dont les documents ne s'ouvrent pas est INDISCERNABLE d'une liste dont ils s'ouvrent : rien ne signale la différence jusqu'à une lecture ultérieure qui revient vide, la cause étant alors loin derrière. C'était un choix délibéré — ne pas transformer un appel publié en levée, la liste étant déjà en main à ce moment-là. C'est précisément ce qui en faisait une demi-vérité plutôt qu'un raccourci. Et c'était le dernier membre connu de la famille qui a produit une panne chez une application cette semaine. Les deux lectures sur lesquelles l'appel repose remontent désormais : la branche Main dit quels documents sont là, la branche Store dit ce qui ouvre chacun. Un tableau vide signifie donc que ce compte n'a rien créé dans cette portée, et jamais que le store n'a pas été lu. readUserStore avalait le même échec pour son propre compte ; son autre appelant, ownsDocument, garde le comportement actuel par un catch explicite et documenté — toutes ses réponses étant des refus, il échoue en fermeture, ce qui est la règle qu'e32b6d0 avait posée. C'est un changement de comportement d'un appel publié, donc le contrat le dit, et docs/api-contract.md aussi. Au passage, deux citations pourries corrigées en citant un SYMBOLE plutôt qu'une ligne — types.rs:4251 désignait DialogRequest et non Link, index.d.ts:138 désignait const ng et non le type NG. Les douze autres références numériques du voisinage ont été vérifiées : aucune n'avait bougé.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `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, readUnion } from "../src/index";
|
||||
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
|
||||
import { SESSION, forgetEverything, makeWallet, signIn, 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";
|
||||
|
||||
/**
|
||||
* 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: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
const quads: Quad[] = [];
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(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: Scope[] = ["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 storeRegistry.listMyEntityDocs(scope)).toContain(note);
|
||||
const subjects = await readUnion([note]);
|
||||
expect(subjects.map((s) => s.props[TITLE]?.[0])).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(storeRegistry.listMyEntityDocs(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 storeRegistry.listMyEntityDocs(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(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
|
||||
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
|
||||
expect(answered).toBeNull();
|
||||
expect(answered).not.toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user