fix: les trois mutations posent la meme question, et la vue porte ses propres ecritures

L-ASYMETRIE, une fois de plus, et c-est elle le defaut. `delete` et `clear`
interrogeaient `keep`, `add` non - ce qui rendait la phrase publiee vraie par
coincidence : le `add` non garde FABRIQUAIT l-etat que les deux autres ne
traitaient qu-a moitie. Bob plante un objet dans le document d-ALICE, ne le
revoit jamais (la vue le refiltre), la liaison bidirectionnelle le commite
la-bas, puis `clear()` le laisse derriere. Les trois posent maintenant la meme
question ; ce qu-elles repondent differe parce que les membres different :
`delete` a `false`, qui est le mot du contrat Set pour "il n-y etait pas" et qui
est vrai de la vue ; `add` n-a aucune valeur de ce sens, donc il leve.

LA VUE PORTE SES PROPRES ECRITURES. Aucun piege `set`/`deleteProperty`/
`defineProperty` : contre un tableau ordonne, `view[0] = x`, `delete view[0]` et
`view.length = 0` passaient - le dernier defaisant `clear` entierement. Ce qui
est livre est un `readOnlyArray` qui les refuse, donc la production etait
protegee PAR LA CIBLE : la vue s-appuyait sur une garantie qu-elle ne donne pas,
sur une forme qu-elle ne controle pas. C-est l-hypothese empruntee qui est
derriere chaque defaut de cette famille.

LE PROPRIETAIRE EST SERVI. 18 membres declares d-un resultat ordonne levaient
pour celui dont ce sont les donnees, plus `includes` et les sept combinateurs
d-ensemble : une fuite echangee contre une panne. Tout ce que
`@ng-org/alien-deepsignals` declare non mutant est servi sur les objets
lisibles, et un test relit le fichier livre pour que la liste restee ici ne
derive pas.

Aussi : le descripteur d-un ACCESSEUR routait vers la cible ; le faux construit
maintenant les VRAIES collections (`deepSignal`, `readOnlyArray`) au lieu d-un
Set et d-un tableau nus, ce qui le rendait plus permissif que ce qui est livre
apres l-avoir rendu plus correct au round precedent.
This commit is contained in:
Sylvain Duchesne
2026-09-24 19:38:14 +02:00
parent b778fcff02
commit eb956c880d
5 changed files with 414 additions and 19 deletions
+106
View File
@@ -280,6 +280,112 @@ test("useShape: a page command that handed something back would be refused, not
expect(() => result.nextPage?.()).toThrow(/not filtered/i);
});
// THE THIRD MUTATION, and the one that was ungated. `delete` and `clear` asked `keep` and
// `add` did not, which made the published rule true by coincidence: the ungated `add`
// MANUFACTURED the state the other two then handled partially. Bob plants an object into
// ALICE's document, never sees it again - the view filters it straight back out - and the
// ORM's two-way binding commits it there. Then `clear()` leaves it behind.
//
// Each of the three is run here against a document this identity does not hold, and the
// collection underneath is checked afterwards. That is the contract's mutation sentence,
// clause by clause, executed rather than read.
test("useShape: none of the three mutations reaches a document this identity holds no cap for", 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 {
add(item: BaseType): unknown;
delete(item: BaseType): boolean;
clear(): void;
has(item: BaseType): boolean;
};
// add - refuses, because it has no return value meaning "it did not happen".
expect(() => data.add(noteIn(hers, "PLANT"))).toThrow(/does not hold the cap/i);
// delete - answers `false`, which is the Set contract's own word for "not there" and is
// true of the view.
expect(data.has(hersItem)).toBe(false);
expect(data.delete(hersItem)).toBe(false);
// clear - empties the view, which holds none of hers.
data.clear();
// The collection underneath is exactly as it was, minus bob's own item.
expect([...(hook.raw() as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers]);
// And the holder's OWN write still goes through and still reaches the collection.
expect([...(data.add(noteIn(his, "b2")) as Iterable<BaseType>)].map((o) => o["@graph"]))
.toEqual([his]);
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2);
});
// A write to the collection itself is refused. Upstream's ordered `readOnlyArray` refuses
// these too, so here the two agree - which is the point of the fake now building the REAL
// collection kinds. That the VIEW refuses on its own account, whatever the target allows,
// is proved one floor down in `read-filter.test.ts` against a plain array, because relying
// on the target's guarantee is relying on something this view does not promise and does
// not control. `length = 0` in particular defeated the gated `clear` outright.
test("useShape: a write to the view itself is refused, whatever the collection underneath allows", async () => {
const hook = makeUseShapeHook();
inject(hook);
const hers = await documentOf("alice");
await documentOf("bob");
hook.items.push(noteIn(hers, "a1"), noteIn(hers, "a2"));
setCurrentUser("bob");
const data = useShape(NoteShape, { ...EVERYTHING, orderBy: { liveMark: "asc" } }).data as
Record<number, unknown> & { length: number };
expect(() => {
data[0] = { "@id": "SPY", "@graph": "did:ng:o:spy" };
}).toThrow(/not filtered/i);
expect(() => {
delete data[0];
}).toThrow(/not filtered/i);
expect(() => {
data.length = 0;
}).toThrow(/not filtered/i);
// The collection underneath is untouched.
expect([...(hook.raw() as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
});
// The descriptor trap handed an ACCESSOR back untouched, so the getter behind it - which
// reads the TARGET - was a door around the view. Not reachable on the shape that ships,
// where every member reports a data descriptor; reachable on the next one, which is what
// this view claims to be safe for.
test("useShape: an accessor member is read through the view, not around it", async () => {
const base = makeUseShapeHook();
const withGetter: FakeShapeHook = {
...base,
hook: ((shapeType, conf) => {
const built = base.hook(shapeType, conf);
const raw = base.raw();
return {
isLoading: built.isLoading,
promise: built.promise,
subscription: built.subscription,
get data(): unknown {
return raw;
},
};
}) as UseShapeLike,
};
inject(withGetter);
const hers = await documentOf("alice");
const his = await documentOf("bob");
base.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
setCurrentUser("bob");
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
const described = Object.getOwnPropertyDescriptor(result, "data");
expect(typeof described?.get).toBe("function");
const throughTheDescriptor = described?.get?.();
expect(throughTheDescriptor).not.toBe(base.raw());
expect([...(throughTheDescriptor as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
});
// -- SAFE FOR A SHAPE NOBODY HAS SEEN --------------------------------------
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two