fix: une mutation ne rend ni n-atteint ce que la vue cache

`Set.prototype.add` rend THIS pour permettre le chainage - EXECUTE, pas deduit,
sur le `Set` nu et sur le vrai `DeepSignalSet`, qui rendent tous deux la
collection. Les trois mutations etaient transmises liees a la cible sur le motif
ecrit juste au-dessus : "elles prennent un objet et rendent void ou un booleen,
donc elles ne laissent rien fuir". Faux pour `add`, et `add` est le PREMIER
membre que l-exemple de documentation du SDK emploie. Toutes les entrees etaient
filtrees et la sortie rendait la collection entiere.

Les deux autres rendaient bien ce que le motif disait, et AGISSAIENT au-dela de
la vue : `delete` retirait un objet dont `has` refuse meme de confirmer
l-existence - les deux se contredisaient - et `clear` vidait la collection que
toutes les identites partagent.

Une seule regle : une mutation atteint la collection dessous, mais ne rend ni
n-atteint ce que la vue cache. `add` rend la VUE, `delete` n-agit que sur ce que
la vue montre, `clear` vide la vue. L-ecriture atteint toujours le document.

Le motif commun aux cinq fuites de cette famille est une JUSTIFICATION ECRITE
SUR UNE VALEUR DE RETOUR JAMAIS EXECUTEE. Chacune etait raisonnable, aucune
n-avait ete jouee.

Aussi : la forme ordonnee rend des index la ou un Set rend des objets
(`keys`/`entries`/`forEach`), et le faux partage reproduit desormais le
`isLoading` LIVRE - inverse de sa propre declaration. L-inversion est amont ;
un faux plus correct que la cible recree l-aveuglement que cette tache enleve.
This commit is contained in:
Sylvain Duchesne
2026-09-24 19:00:19 +02:00
parent 0e2b8ba394
commit c5de1cea35
6 changed files with 221 additions and 24 deletions
@@ -186,3 +186,58 @@ test("the array form counts and addresses the readable items, by every route", (
// target cannot configure away - but it is not a position and does not enumerate.
expect(Object.getOwnPropertyNames(view)).toContain("length");
});
// REGRESSION (fourth adversarial pass, 2026-09-24). The three mutations were forwarded
// bound to the TARGET, on the written ground that "they take an item and return void or a
// boolean, so they yield nothing to leak". RUN rather than reasoned about, that ground is
// false for one of the three: `Set.prototype.add` returns THIS so calls can be chained, so
// `view.add(x)` handed back the unfiltered collection - and `add` is the first member the
// ORM's own documentation example reaches for. The other two returned what the ground
// said, and ACTED past the view instead.
test("a mutation neither hands back nor reaches past what the view hides", () => {
const set = new Set<Item>([MINE, FOREIGN]);
const { caps, become } = setup("alice"); // alice holds MINE, not FOREIGN
const view = makeReadFilteredView(set, caps);
// `add` hands back the VIEW, so chaining lands on the filtered collection.
const chained = view.add({ id: "c", "@graph": "did:ng:o:alice" });
expect(chained).not.toBe(set);
expect([...chained].map((i) => i.id)).toEqual(["a", "c"]);
expect(set.has(FOREIGN)).toBe(true); // the write reached the real set, and only added
// `delete` acts only on what the view shows - the same question `has` already refuses.
expect(view.has(FOREIGN)).toBe(false);
expect(view.delete(FOREIGN)).toBe(false);
expect(set.has(FOREIGN)).toBe(true);
expect(view.delete(MINE)).toBe(true);
expect(set.has(MINE)).toBe(false);
// `clear` empties the VIEW, not the collection every identity shares.
view.clear();
expect([...view]).toEqual([]);
expect([...set].map((i) => i.id)).toEqual(["n"]);
become("bob");
expect(set.size).toBe(1); // FOREIGN survived a holder emptying "their" set
});
// An `orderBy` result is an ARRAY, and an array's positions are indices. Answering it with
// the Set contract gave a holder the wrong output on their own data: `keys()` yielded items
// instead of indices, `entries()` yielded `[item, item]`, and `forEach`'s second argument
// was the item. No leak, just wrong - and wrong output on the owner path is the other half
// of what this view owes.
test("the array form yields indices where a Set yields items", () => {
const items: Item[] = [MINE, FOREIGN, LINKED];
const { caps } = setup("alice"); // holds MINE and LINKED
const view = makeReadFilteredView(items, caps);
expect([...view.keys()]).toEqual([0, 1]);
expect([...view.entries()]).toEqual([[0, MINE], [1, LINKED]]);
expect([...view.values()]).toEqual([MINE, LINKED]);
const seen: Array<[string, unknown]> = [];
view.forEach((item, i) => seen.push([item.id, i]));
expect(seen).toEqual([["a", 0], ["p", 1]]);
// The Set form is unchanged: both halves of a pair are the item.
const asSet = makeReadFilteredView(new Set<Item>([MINE, FOREIGN]), caps);
expect([...asSet.keys()]).toEqual([MINE]);
expect([...asSet.entries()]).toEqual([[MINE, MINE]]);
});
+11 -4
View File
@@ -23,7 +23,11 @@
* - `promise` resolves to that same collection, and is `undefined` when `conf` is;
* - `subscription` is the handle, carrying the items three ways of its own -
* `signalObject`, `readyPromise` and the raw arrays a change listener is handed;
* - `isLoading` is a boolean, the one member that carries nothing;
* - `isLoading` is a boolean, the one member that carries nothing - reproduced with the
* shipped EXPRESSION, `ormSubscription.isReady`, which is the INVERSE of what the
* declaration beside it describes ("`true` when no data is available yet"). The
* inversion is upstream's, and a fake that quietly corrected it would be measuring a
* hook nobody ships - which is the exact blindness this file exists to remove;
* - the two page commands exist only under a paginated config, as `WithMaybePagination`
* `Omit`s them otherwise and the shipped hook leaves them `undefined`.
*
@@ -60,8 +64,9 @@ export interface FakeShapeHook {
* A fake `useShape` over an in-memory row list.
*
* `loaded` (the default) answers as a hook whose payload has arrived. Turn it off for the
* state the declaration describes as "still loading": `data` and `promise` present but the
* data not there yet.
* state the declaration describes as "still loading": `promise` present but `data` not
* there yet. Note which way `isLoading` then reads - see the note above; it follows the
* shipped expression, not the shipped prose.
*/
export function makeUseShapeHook(
options: { loaded?: boolean; paginated?: boolean } = {},
@@ -104,7 +109,9 @@ export function makeUseShapeHook(
data: loaded ? collection : undefined,
promise: Promise.resolve(collection),
subscription,
isLoading: !loaded,
// Upstream, verbatim: `!ormSubscription ? false : ormSubscription.isReady`. There is
// a subscription here, so this is `isReady` - and `isReady` is `loaded`.
isLoading: loaded,
nextPage: paginated ? (): void => void pages.push("next") : undefined,
previousPage: paginated ? (): void => void pages.push("previous") : undefined,
};
+61 -2
View File
@@ -159,8 +159,12 @@ test("useShape: the holder reads their own items through every member the SDK of
expect([...(result.data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
expect((result.data as { size: number }).size).toBe(2);
expect([...((await result.promise) as Iterable<BaseType>)]).toHaveLength(2);
// The load state is a boolean and passes on its own, with no entry naming it.
expect(result.isLoading).toBe(false);
// The load state is a boolean and passes on its own, with no entry naming it. It reads
// `true` on a payload that HAS arrived, because the shipped hook computes it as
// `ormSubscription.isReady` while the declaration beside it says the opposite. That
// inversion is upstream's; the fake reproduces it rather than correcting it, and this
// assertion is what stops anyone here quietly making the target nicer than it is.
expect(result.isLoading).toBe(true);
// The page commands forward: they return `void`, and what they load lands in a
// collection only ever read back through the filter.
result.nextPage?.();
@@ -198,6 +202,61 @@ test("useShape: an undefined config answers undefined rather than refusing", asy
expect(result.isLoading).toBe(false);
});
// -- A MUTATION MAY NOT REACH PAST WHAT THE VIEW SHOWS ---------------------
// THE RIGHTS CASE, second kind, and it is the member the SDK's own example reaches for
// first (`expenses.add({...})`). `Set.prototype.add` RETURNS THE COLLECTION so calls can
// be chained - measured, not assumed - so a mutation forwarded bound to the target handed
// the unfiltered collection back as its return value. The view filtered every way IN and
// gave the whole set away on the way OUT.
test("useShape: adding an item does not hand back the unfiltered collection", async () => {
const hook = makeUseShapeHook();
inject(hook);
const hers = await documentOf("alice");
const his = await documentOf("bob");
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
setCurrentUser("bob");
const data = useShape(NoteShape, EVERYTHING).data as {
add(item: BaseType): unknown;
[Symbol.iterator](): Iterator<BaseType>;
};
expect([...data].map((o) => o["@graph"])).toEqual([his]); // the way in is filtered
const handedBack = data.add(noteIn(his, "b2"));
expect(handedBack).not.toBe(hook.raw());
// Chaining still works, and what it chains onto is the VIEW: still bob's documents only.
expect([...(handedBack as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his, his]);
// and the write really did reach the collection underneath.
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(3);
});
// `has(item)` has always refused to answer about an item the holder may not read. Acting
// on one is the same question with a worse consequence, and the two disagreed: `has` said
// no while `delete` removed it.
test("useShape: deleting reaches only what the view shows", async () => {
const hook = makeUseShapeHook();
inject(hook);
const hers = await documentOf("alice");
const his = await documentOf("bob");
const hersItem = noteIn(hers, "a1");
hook.items.push(hersItem, noteIn(his, "b1"));
setCurrentUser("bob");
const data = useShape(NoteShape, EVERYTHING).data as {
delete(item: BaseType): boolean;
has(item: BaseType): boolean;
clear(): void;
};
expect(data.has(hersItem)).toBe(false);
expect(data.delete(hersItem)).toBe(false);
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2); // hers survives
// and emptying the view empties the view, not the collection every identity shares.
data.clear();
expect([...(hook.raw() as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers]);
});
// -- SAFE FOR A SHAPE NOBODY HAS SEEN --------------------------------------
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two