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
@@ -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 }>();