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
+129 -1
View File
@@ -1,5 +1,13 @@
import { test, expect } from "bun:test";
import { filterReadable, makeReadFilteredView } from "../src/emulated-verifier/read-filter";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
ITERATOR_HELPERS,
NON_MUTATING_ARRAY_ONLY,
NON_MUTATING_SET_ONLY,
filterReadable,
makeReadFilteredView,
} from "../src/emulated-verifier/read-filter";
import { CapRegistry } from "../src/emulated-verifier/caps";
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
@@ -241,3 +249,123 @@ test("the array form yields indices where a Set yields items", () => {
expect([...asSet.keys()]).toEqual([MINE]);
expect([...asSet.entries()]).toEqual([[MINE, MINE]]);
});
// The three sets this view serves are RESTATED from `@ng-org/alien-deepsignals`, because
// the package exports no subpath for them and `src/` may not import an optional peer at
// runtime anyway. A restatement drifts unless something reads the original, so this does:
// it parses the shipped `dist/iteratorHelpers.js` and fails when the two have parted
// company. Red here means upstream moved, not that this test is wrong.
test("the declared non-mutating surface still matches what upstream ships", () => {
const shipped = readFileSync(
join(import.meta.dir, "..", "node_modules", "@ng-org", "alien-deepsignals", "dist", "iteratorHelpers.js"),
"utf8",
);
/** The quoted string members of one `new Set([...])` literal, by its declared name. */
const members = (name: string): string[] => {
const body = shipped.split(`export const ${name} = new Set([`)[1]?.split("])")[0] ?? "";
// Deduplicated, because `new Set([...])` is: upstream's array literal names
// `toLocaleString` twice, and a parse that did not dedupe would read that as a
// divergence from a list that is identical.
return [...new Set([...body.matchAll(/"([^"]+)"/g)].map((m) => m[1]!))].sort();
};
expect(members("iteratorFnKeys")).toEqual([...ITERATOR_HELPERS].sort());
// Upstream's array and set lists are declared as their own entries `.union(iteratorFnKeys)`,
// and are restated here the same way - so the comparison is against the literal, not the union.
expect(members("nonMutatingArrayFnKeys").filter((k) => !ITERATOR_HELPERS.has(k)))
.toEqual([...NON_MUTATING_ARRAY_ONLY].sort());
expect(members("nonMutatingSetFnKeys").filter((k) => !ITERATOR_HELPERS.has(k)))
.toEqual([...NON_MUTATING_SET_ONLY].sort());
});
// F3 of the third adversarial pass: the OWNER, every item theirs, was refused on 18
// declared members of an ordered result and on the seven Set combinators - a leak traded
// for an outage. Each is exercised with an argument it actually takes, and each answer is
// checked to be drawn from the readable items rather than merely not to throw.
test("every declared non-mutating member answers for the holder, on both forms", () => {
const { caps } = setup("alice"); // alice holds MINE and LINKED, never FOREIGN
// Typed explicitly because six of these members are ES2023 and this workspace compiles
// against the ES2022 lib. The runtime has them - they are what upstream declares - so
// the declaration is local rather than a lib bump nothing else in the repo asked for.
type Ordered = Item[] & {
findLast(p: (i: Item) => boolean): Item | undefined;
findLastIndex(p: (i: Item) => boolean): number;
toReversed(): Item[];
toSorted(c: (a: Item, b: Item) => number): Item[];
toSpliced(start: number, n: number): Item[];
with(index: number, value: Item): Item[];
};
const ordered = makeReadFilteredView([MINE, FOREIGN, LINKED] as Ordered, caps);
expect(ordered.at(0)).toBe(MINE);
expect(ordered.at(-1)).toBe(LINKED);
expect(ordered.slice(1)).toEqual([LINKED]);
expect(ordered.indexOf(LINKED)).toBe(1);
expect(ordered.indexOf(FOREIGN)).toBe(-1); // not in the view, so nowhere in it
expect(ordered.includes(MINE)).toBe(true);
expect(ordered.includes(FOREIGN)).toBe(false);
expect(ordered.lastIndexOf(MINE)).toBe(0);
expect(ordered.join("|")).toBe([MINE, LINKED].join("|"));
expect(ordered.concat([NOGRAPH])).toEqual([MINE, LINKED, NOGRAPH]);
expect(ordered.findIndex((i) => i.id === "p")).toBe(1);
expect(ordered.findLast(() => true)).toBe(LINKED);
expect(ordered.findLastIndex(() => true)).toBe(1);
expect(ordered.flat()).toEqual([MINE, LINKED]);
expect(ordered.reduceRight((acc: string, i) => acc + i.id, "")).toBe("pa");
expect(typeof ordered.toLocaleString()).toBe("string");
expect(ordered.toReversed()).toEqual([LINKED, MINE]);
expect(ordered.toSorted((x, y) => x.id.localeCompare(y.id))).toEqual([MINE, LINKED]);
expect(ordered.toSpliced(0, 1)).toEqual([LINKED]);
expect(ordered.with(0, NOGRAPH)).toEqual([NOGRAPH, LINKED]);
// `copyWithin` mutates its receiver and upstream declares it non-mutating anyway. It
// runs on the snapshot, and because it hands its receiver back it comes back as the
// VIEW - so what a caller keeps hold of is the live filtered collection, and the one
// thing that must not happen, a write reaching the collection underneath, does not.
expect([...ordered.copyWithin(0, 1)]).toEqual([MINE, LINKED]);
expect([...ordered]).toEqual([MINE, LINKED]);
const asSet = makeReadFilteredView(new Set<Item>([MINE, FOREIGN, LINKED]), caps) as
Set<Item> & {
includes(v: Item): boolean;
union(o: Set<Item>): Set<Item>;
difference(o: Set<Item>): Set<Item>;
intersection(o: Set<Item>): Set<Item>;
isSubsetOf(o: Set<Item>): boolean;
isSupersetOf(o: Set<Item>): boolean;
isDisjointFrom(o: Set<Item>): boolean;
symmetricDifference(o: Set<Item>): Set<Item>;
};
expect(asSet.includes(MINE)).toBe(true);
expect(asSet.includes(FOREIGN)).toBe(false);
expect([...asSet.union(new Set([NOGRAPH]))]).toEqual([MINE, LINKED, NOGRAPH]);
expect([...asSet.difference(new Set([MINE]))]).toEqual([LINKED]);
expect([...asSet.intersection(new Set([MINE, FOREIGN]))]).toEqual([MINE]);
expect(asSet.isSubsetOf(new Set([MINE, LINKED, FOREIGN]))).toBe(true);
expect(asSet.isSupersetOf(new Set([MINE]))).toBe(true);
// FOREIGN is not in the view, so the view is disjoint from a set holding only it.
expect(asSet.isDisjointFrom(new Set([FOREIGN]))).toBe(true);
expect([...asSet.symmetricDifference(new Set([MINE]))]).toEqual([LINKED]);
});
// The view carries its own WRITE guarantee, and this is where that is proved: the target
// is a plain array, which accepts every one of these, so whatever refuses is the view.
// Upstream's ordered collection is a `readOnlyArray` that refuses them too - but resting
// on that is resting on a guarantee this view does not make, about a shape it does not
// control, which is the borrowed assumption behind every defect in this family.
test("a write to the view itself is refused, whatever the collection underneath allows", () => {
const items: Item[] = [MINE, LINKED];
const { caps, become } = setup("alice");
const view = makeReadFilteredView(items, caps) as Item[] & { length: number };
become("bob"); // and it is refused for the holder too: this is not a filter, it is a rule
expect(() => {
view[0] = { id: "SPY" };
}).toThrow(/not filtered/i);
expect(() => {
delete view[0];
}).toThrow(/not filtered/i);
expect(() => {
view.length = 0;
}).toThrow(/not filtered/i);
expect(() => Object.defineProperty(view, "0", { value: { id: "SPY" } })).toThrow(/not filtered/i);
// The plain array underneath accepted nothing, so the view is what stood in the way.
expect(items.map((i) => i.id)).toEqual(["a", "p"]);
});
+14 -3
View File
@@ -18,8 +18,9 @@
* { data, promise, subscription, isLoading, nextPage, previousPage }
*
* Each of the six is reproduced with the cardinality the declaration gives it:
* - `data` is a `Set` for an unordered config and an ARRAY once `orderBy` is asked for
* (upstream `SubscriptionData<T, CONF>`), and `undefined` while nothing has arrived;
* - `data` is a `DeepSignalSet` for an unordered config and a `readOnlyArray` over a
* `DeepSignal` array once `orderBy` is asked for (upstream `SubscriptionData<T, CONF>`),
* built with upstream's OWN constructors, and `undefined` while nothing has arrived;
* - `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;
@@ -35,6 +36,14 @@
* the target proves things about a surface nobody ships.
*/
// The REAL collection kinds, not stand-ins for them. `data` is what `useDeepSignal` wraps
// - a `DeepSignalSet` unordered, a `readOnlyArray` over a `DeepSignal` array once an
// `orderBy` is asked for - and the two behave in ways a plain `Set` and a plain array do
// not: the set's `add` hands the proxy back, and the ordered one REFUSES a write to an
// index, to `length`, and even a `get` of `push`. A fake that used the plain kinds was
// MORE PERMISSIVE than what ships (it let a caller overwrite an item by index), having
// been more CORRECT than it one round earlier. Neither is a fake's business.
import { deepSignal, readOnlyArray } from "@ng-org/alien-deepsignals";
import type { BaseType } from "@ng-org/shex-orm";
import type { ShapeResult, UseShapeLike } from "../src/model/types";
@@ -94,7 +103,9 @@ export function makeUseShapeHook(
}
// `SubscriptionData<T, CONF>`: a set with no `orderBy`, an ordered array with one.
const ordered = typeof conf === "object" && conf.orderBy !== undefined;
const collection: unknown = ordered ? [...items] : new Set(items);
const collection: unknown = ordered
? readOnlyArray(deepSignal([...items]) as never)
: deepSignal(new Set(items));
lastRaw = collection;
const subscription: FakeSubscription = {
signalObject: collection,
+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