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:
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user