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:
@@ -97,6 +97,18 @@ function refusalOf(prop: string | symbol, where: string): Error {
|
||||
);
|
||||
}
|
||||
|
||||
/** What a WRITE across the boundary says. Worded like `emulated-verifier/reach.ts`, which
|
||||
* refuses the same thing on the paths that still go through `sparql_update` - the ORM's
|
||||
* own writes do not, which is why this view is the only door on this one. */
|
||||
function writeRefusalOf(item: unknown): Error {
|
||||
const doc = docOf(item);
|
||||
return new Error(
|
||||
"[ng-eventually] read filter: refused - this identity does not hold the cap of the " +
|
||||
`document this object names, so writing it there is a write into a document it ` +
|
||||
`cannot read. Naming a document does not grant access to it. ${JSON.stringify(doc)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer for a member this file does not name: decided by the TYPE of what is behind it,
|
||||
* never by its name. See the note on {@link makeReadFilteredView}.
|
||||
@@ -120,6 +132,40 @@ function answerUnnamedMember(target: object, prop: string | symbol, where: strin
|
||||
/** `"0"`, `"12"` - an array index, in the canonical form a property key takes. */
|
||||
const INDEX = /^(0|[1-9][0-9]*)$/;
|
||||
|
||||
/**
|
||||
* The members `@ng-org/alien-deepsignals` declares NON-MUTATING, restated.
|
||||
*
|
||||
* Restated rather than imported, and not by preference: the package's `exports` map has no
|
||||
* subpath for `iteratorHelpers`, so the three sets are unreachable from outside it - and a
|
||||
* runtime `@ng-org` import has no business in `src/` anyway, where every `@ng-org` package
|
||||
* is an OPTIONAL peer dependency. What keeps a restatement from drifting is a gate, not
|
||||
* care: `test/read-filter.test.ts` reads the shipped `dist/iteratorHelpers.js` and fails if
|
||||
* these lists and upstream's have parted company.
|
||||
*
|
||||
* Why they are here at all: every one of them is a READ over the collection, so every one
|
||||
* of them is this view's business. Refusing them - which is what an unlisted member gets -
|
||||
* took 18 declared members of an ordered result away from the holder whose data it is.
|
||||
* `Symbol.toString` appears in upstream's own lists and is not a symbol at all
|
||||
* (`Symbol.prototype.toString`); it is left out here rather than copied, since it can never
|
||||
* be a property key.
|
||||
*/
|
||||
export const ITERATOR_HELPERS: ReadonlySet<string> = new Set([
|
||||
"drop", "every", "filter", "find", "flatMap", "forEach", "includes", "map", "reduce",
|
||||
"some", "take", "toArray",
|
||||
]);
|
||||
/** Upstream's array-only additions; the full set is these plus {@link ITERATOR_HELPERS}. */
|
||||
export const NON_MUTATING_ARRAY_ONLY: ReadonlySet<string> = new Set([
|
||||
"at", "concat", "entries", "findIndex", "findLast", "findLastIndex", "flat", "indexOf",
|
||||
"join", "keys", "lastIndexOf", "reduceRight", "toLocaleString", "toReversed", "toSorted",
|
||||
"toSpliced", "slice", "values", "with", "copyWithin",
|
||||
]);
|
||||
/** Upstream's set-only additions - the ES2025 combinators, which need a real `Set` to run
|
||||
* on, plus the pair spellings handled explicitly below. */
|
||||
export const NON_MUTATING_SET_ONLY: ReadonlySet<string> = new Set([
|
||||
"difference", "entries", "has", "intersection", "isDisjointFrom", "isSubsetOf",
|
||||
"isSupersetOf", "keys", "symmetricDifference", "union", "values",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The descriptor of a member, filtered the same way reading it is.
|
||||
*
|
||||
@@ -142,8 +188,24 @@ function filteredDescriptor(
|
||||
): PropertyDescriptor | undefined {
|
||||
const d = Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
if (d === undefined) return undefined;
|
||||
// An accessor carries no value of its own; the `get` trap is what answers it.
|
||||
if (!("value" in d)) return d;
|
||||
// AN ACCESSOR. It carries no value of its own, and that is exactly why handing it back
|
||||
// untouched was a door around the view: the getter behind it reads the TARGET. Measured
|
||||
// 2026-09-24 on an envelope whose `data` is a getter - `v.data` came back filtered and
|
||||
// `Object.getOwnPropertyDescriptor(v, "data").get()` came back raw. Not reachable on the
|
||||
// shape that ships, where all six members report data descriptors or none; reachable on
|
||||
// the next one, which is the whole claim this file makes. The getter is re-pointed at
|
||||
// the view's own read, and a setter is refused: this view makes no promise about writes.
|
||||
if (!("value" in d)) {
|
||||
if (d.configurable !== true) throw refusalOf(prop, "its descriptor");
|
||||
const routed: PropertyDescriptor = { ...d };
|
||||
if (typeof d.get === "function") routed.get = (): unknown => read();
|
||||
if (typeof d.set === "function") {
|
||||
routed.set = (): never => {
|
||||
throw refusalOf(prop, "its descriptor");
|
||||
};
|
||||
}
|
||||
return routed;
|
||||
}
|
||||
const value = read();
|
||||
if (Object.is(d.value, value)) return d;
|
||||
// A property that is neither configurable nor writable cannot be REPORTED with another
|
||||
@@ -243,6 +305,26 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
return self.view;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve a member upstream declares non-mutating, over the READABLE items.
|
||||
*
|
||||
* The source is a fresh `Array` - the shape every iterator helper works on - or a fresh
|
||||
* `Set`, which the ES2025 combinators need. Either way it is built from `kept`, so it
|
||||
* holds nothing the holder may not read and nothing it returns can reach past the view.
|
||||
*/
|
||||
const served = (target: object, prop: string, asSet: boolean): unknown => {
|
||||
const items = kept(target);
|
||||
const source: object = asSet ? new Set(items) : items;
|
||||
const fn = Reflect.get(source, prop, source);
|
||||
if (typeof fn !== "function") return fn;
|
||||
return (...args: unknown[]): unknown => {
|
||||
const out = (fn as (...a: unknown[]) => unknown).apply(source, args);
|
||||
// A member that hands its own receiver back (`copyWithin`) hands back the snapshot;
|
||||
// the view is what a caller should keep hold of.
|
||||
return out === source ? viewOf(prop) : out;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What a MUTATION may hand back - the same question this file asks of any other member,
|
||||
* asked of a RESULT rather than of a property. Written once and called from each of
|
||||
@@ -260,6 +342,11 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
* call this, so neither can answer what the other refuses. */
|
||||
const read = (target: object, prop: string | symbol): unknown => {
|
||||
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
|
||||
// Upstream lists it beside `Symbol.iterator`. Answered only when the target carries
|
||||
// one, so the view never grows an iteration the collection does not have.
|
||||
if (prop === Symbol.asyncIterator && prop in target) {
|
||||
return async function* () { yield* kept(target); };
|
||||
}
|
||||
// HOW MANY, under both spellings. `data` is a `DeepSignalSet` for an unordered read
|
||||
// and an array-like `DeepSignal<ReadOnlyArray>` once the config asks for an
|
||||
// `orderBy`, and `length` is a number - so it used to pass as a primitive and
|
||||
@@ -301,11 +388,12 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
// answer discloses nothing new — but upstream an unreadable item is never delivered
|
||||
// at all, so "yes it is in there" would be an answer the target cannot give.
|
||||
if (prop === "has") return (item: unknown) => keep(item) && (target as Set<unknown>).has(item);
|
||||
// The reactive-set extras: they iterate, so they must iterate the filtered items.
|
||||
// The list is `iteratorHelperKeys` from `@ng-org/alien-deepsignals` — an earlier
|
||||
// pass whitelisted half of it and threw on the rest, so a holder's calls on their
|
||||
// OWN data crashed (`toArray`, `reduce`, `first`…). Filtering is the answer for all
|
||||
// of them; refusing is only for what is not on this list.
|
||||
// The reactive-set extras, named here because each is computed from the filtered
|
||||
// items rather than delegated. They are part of upstream's `iteratorFnKeys` - the
|
||||
// comment used to call that set `iteratorHelperKeys`, which is not its name and not
|
||||
// the list it held: `includes` and `Symbol.asyncIterator` are in it and were missing
|
||||
// here, so `data.includes(ownItem)` answered nothing on either shape. The rest of the
|
||||
// declared surface is served generically further down.
|
||||
if (prop === "map") return (fn: (v: unknown, i: number) => unknown) => kept(target).map(fn);
|
||||
if (prop === "filter") return (fn: (v: unknown, i: number) => boolean) => kept(target).filter(fn);
|
||||
if (prop === "find") return (fn: (v: unknown, i: number) => boolean) => kept(target).find(fn);
|
||||
@@ -336,6 +424,17 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
// three different ways: a mutation may not reach past what the view shows, and may not
|
||||
// hand back what the view hides.
|
||||
//
|
||||
// ALL THREE ARE GATED ON `keep`, and that is the arbitration rather than an accident.
|
||||
// `delete` and `clear` were gated first and `add` was not, which made the rule above
|
||||
// true by coincidence: the ungated `add` MANUFACTURED the very state the other two
|
||||
// then handled partially - a holder can plant an item into a document it cannot read,
|
||||
// never see it again, and watch `clear()` leave it behind. Gating two of three is the
|
||||
// asymmetry, not a half-measure, so all three ask the same question. What they answer
|
||||
// with differs because the members differ: `delete` has `false`, which is the Set
|
||||
// contract's own word for "it was not there" and is true of the view; `add` has no
|
||||
// such value - it returns the collection whether or not it did anything - so it
|
||||
// THROWS rather than reporting a write that did not happen.
|
||||
//
|
||||
// They were forwarded bound to the target until 2026-09-24, 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: `Set.prototype.add` returns THIS so
|
||||
@@ -348,8 +447,10 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
const fn = Reflect.get(target, prop, target);
|
||||
if (typeof fn !== "function") return fn;
|
||||
// `add` chains, so its result is the collection and must become the view.
|
||||
return (...args: unknown[]): unknown =>
|
||||
handBack((fn as (...a: unknown[]) => unknown).apply(target, args), target, prop);
|
||||
return (...args: unknown[]): unknown => {
|
||||
if (!keep(args[0])) throw writeRefusalOf(args[0]);
|
||||
return handBack((fn as (...a: unknown[]) => unknown).apply(target, args), target, prop);
|
||||
};
|
||||
}
|
||||
if (prop === "delete") {
|
||||
const fn = Reflect.get(target, prop, target);
|
||||
@@ -382,6 +483,26 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
);
|
||||
}
|
||||
|
||||
// EVERY OTHER MEMBER UPSTREAM DECLARES NON-MUTATING, served over the readable items.
|
||||
//
|
||||
// The named handlers above exist for the members whose meaning DIFFERS between the two
|
||||
// shapes, or which this view has to compute itself. The rest are ordinary reads, and
|
||||
// refusing them cost the owner 18 declared members of an ordered result - `at`,
|
||||
// `slice`, `join`, `toSorted`, `indexOf` - all throwing on data that is entirely
|
||||
// theirs - plus `includes` and the seven ES2025 combinators on the set form. A leak
|
||||
// traded for an outage is not a repair.
|
||||
//
|
||||
// They run against a FRESH collection built from `kept`, never against the target, so
|
||||
// there is nothing for them to reach: everything in it has already been filtered, and
|
||||
// whatever they return is drawn from it.
|
||||
if (typeof prop === "string") {
|
||||
if (arrayLike && (NON_MUTATING_ARRAY_ONLY.has(prop) || ITERATOR_HELPERS.has(prop))) {
|
||||
return served(target, prop, false);
|
||||
}
|
||||
if (!arrayLike && NON_MUTATING_SET_ONLY.has(prop)) return served(target, prop, true);
|
||||
if (!arrayLike && ITERATOR_HELPERS.has(prop)) return served(target, prop, false);
|
||||
}
|
||||
|
||||
// ANY OTHER MEMBER, function or not: refuse rather than forward, unless the value
|
||||
// behind it cannot reach an item at all. See the header — forwarding is a silent
|
||||
// leak, and this view's one job is that it cannot show more than the holder may read.
|
||||
@@ -401,6 +522,26 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
const i = indexOf(prop);
|
||||
return i === null ? Reflect.has(target, prop) : i < kept(target).length;
|
||||
},
|
||||
// WRITES THROUGH A PROPERTY, on this view's own account.
|
||||
//
|
||||
// There were no such traps until 2026-09-24, and against an ordered result that is a
|
||||
// plain array a holder could do `view[0] = x`, `delete view[0]` and `view.length = 0`
|
||||
// - the last one defeating the gated `clear` outright. What ships is a `readOnlyArray`
|
||||
// that refuses those itself, so production was protected BY THE TARGET: the view was
|
||||
// resting on a guarantee it did not make, about a shape it does not control. That is
|
||||
// the same borrowed assumption as every other defect in this family, so the view now
|
||||
// carries it. Nothing legitimate is lost: a caller mutates an ITEM (handed out whole,
|
||||
// and only when readable) or calls `add`/`delete`/`clear`; writing to the collection's
|
||||
// own properties is not a gesture either the SDK or this surface offers.
|
||||
set(_target, prop) {
|
||||
throw refusalOf(prop, "a write to the view itself");
|
||||
},
|
||||
deleteProperty(_target, prop) {
|
||||
throw refusalOf(prop, "a write to the view itself");
|
||||
},
|
||||
defineProperty(_target, prop) {
|
||||
throw refusalOf(prop, "a write to the view itself");
|
||||
},
|
||||
ownKeys(target) {
|
||||
// A Set holds its items internally and owns no key naming one, so there is nothing
|
||||
// to project: `Object.keys` on it is already empty (measured). An array owns one key
|
||||
@@ -421,10 +562,17 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
|
||||
/**
|
||||
* One filtered view per underlying collection, so that reading `.data` twice hands back the
|
||||
* SAME reference. A caller puts that value in a dependency array; a fresh proxy on every
|
||||
* read would make it look like new data on every render. Keyed weakly on the collection the
|
||||
* SDK handed us, and rebuilt if the registry it was filtered against is no longer the one in
|
||||
* force, so a view can never answer from a keyring that has been replaced.
|
||||
* SAME reference WITHIN one call.
|
||||
*
|
||||
* It does not survive a re-render, and the note here used to claim it did: the shipped hook
|
||||
* wraps `signalObject` in `useDeepSignal`, whose root proxy is "replaced on every relevant
|
||||
* rerender" by its own documentation, so the key this memo is built on is a different
|
||||
* object each time and a dependency array sees a new value regardless. What it does buy is
|
||||
* real and smaller - `result.data === result.data` - and that is what it now says.
|
||||
*
|
||||
* Keyed weakly on the collection the SDK handed us, and rebuilt if the registry it was
|
||||
* filtered against is no longer the one in force, so a view can never answer from a keyring
|
||||
* that has been replaced.
|
||||
*/
|
||||
const filteredCollections = new WeakMap<object, { caps: CapRegistry; view: object }>();
|
||||
|
||||
|
||||
@@ -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