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
@@ -260,7 +260,7 @@ Where a call must first find out whether something already exists — a document
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named. **TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
**No unfiltered read through `useShape`, and no way round it through the object it hands back.** What the call returns is NextGraph's own result, filtered: `data` and `promise` carry only items in documents you hold the key for, `isLoading` passes through, the two page commands run and whatever they hand back is held to the same rule as any other member, and `subscription` - the ORM's own handle, which reads and writes with no capability filter at all - is refused out loud rather than handed on. Any other member of that object is refused too unless what is behind it is a plain scalar, which is how a member a later `@ng-org/orm` adds is decided before anyone has seen it: whatever can carry an item is an object or a function, and both refuse. Inside `data`, every member that yields items is filtered, and an ordered result addresses and counts the items you may read: `size`, `length`, an index, `Object.keys`, `in` and `JSON.stringify` all agree, and none of them says how many you may not. **Reading a member's DESCRIPTOR answers exactly what reading the member answers**, which is not a detail: `Object.getOwnPropertyDescriptor` - and the spread, `Object.entries` and console inspection built on it - reads a property without going through the view's own read, so a refused member throws there too rather than handing its value over. **A mutation reaches the collection underneath, and neither hands back nor acts past what the view shows.** Writing is not broken - `add` reaches the document and the reactivity it drives - but `add` hands back the VIEW rather than the collection, because `Set.prototype.add` returns the collection so calls can be chained and chaining onto the unfiltered one is the whole leak; `delete` removes only an item the view shows, which is the same question `has` already declines to answer about; and `clear` empties the view rather than the collection every identity shares. A document reached through that view alone, read nowhere else first, does not appear. **No unfiltered read through `useShape`, and no way round it through the object it hands back.** What the call returns is NextGraph's own result, filtered: `data` and `promise` carry only items in documents you hold the key for, `isLoading` passes through, the two page commands run and whatever they hand back is held to the same rule as any other member, and `subscription` - the ORM's own handle, which reads and writes with no capability filter at all - is refused out loud rather than handed on. Any other member of that object is refused too unless what is behind it is a plain scalar, which is how a member a later `@ng-org/orm` adds is decided before anyone has seen it: whatever can carry an item is an object or a function, and both refuse. Inside `data`, every member that yields items is filtered, and an ordered result addresses and counts the items you may read: `size`, `length`, an index, `Object.keys`, `in` and `JSON.stringify` all agree, and none of them says how many you may not. **Reading a member's DESCRIPTOR answers exactly what reading the member answers**, which is not a detail: `Object.getOwnPropertyDescriptor` - and the spread, `Object.entries` and console inspection built on it - reads a property without going through the view's own read, so a refused member throws there too rather than handing its value over. That holds for an ACCESSOR as much as for a value: the getter you are handed reads through the view, and its setter is refused. **A mutation reaches the collection underneath, and neither hands back nor acts past what the view shows.** Writing is not broken - your own `add` reaches the document and the reactivity it drives. **All three ask the same question**: `add` into a document you hold no key for THROWS (it returns the collection whether or not it did anything, so it has no value meaning "that did not happen"), `delete` of an item the view does not show answers `false` - the Set contract's own word for "it was not there", and true of the view - and `clear` empties the view rather than the collection every identity shares. `add` hands back the VIEW rather than the collection, because `Set.prototype.add` returns the collection so calls can chain and chaining onto the unfiltered one gives it away entirely. **Writing to the collection's own properties is refused** - `data[0] = x`, `delete data[0]`, `data.length = 0` - on this view's own account rather than because the ordered collection upstream happens to refuse them too. **Every member `@ng-org/alien-deepsignals` declares non-mutating answers**, computed over the items you may read: the iterator helpers, the array reads (`at`, `slice`, `join`, `indexOf`, `toSorted`, `with`) and the ES2025 set combinators (`union`, `difference`, `isSubsetOf`). A document reached through that view alone, read nowhere else first, does not appear.
**`isLoading` does not mean what its name says, and the fault is upstream's.** `@ng-org/orm`'s declaration describes it as "`true` when no data is available yet"; the hook that ships computes it as the subscription's `isReady`, which is the inverse. Both artifacts were read and they disagree. This package forwards the value it is handed rather than correcting it, because a layer that quietly improves the target teaches you a behaviour you would have to unlearn. **Branch on `data === undefined`**, which is unambiguous, and treat `isLoading` as unusable until upstream settles it. **`isLoading` does not mean what its name says, and the fault is upstream's.** `@ng-org/orm`'s declaration describes it as "`true` when no data is available yet"; the hook that ships computes it as the subscription's `isReady`, which is the inverse. Both artifacts were read and they disagree. This package forwards the value it is handed rather than correcting it, because a layer that quietly improves the target teaches you a behaviour you would have to unlearn. **Branch on `data === undefined`**, which is unambiguous, and treat `isLoading` as unusable until upstream settles it.
@@ -294,7 +294,9 @@ What each level means here, in this package's own terms:
- **The two arguments are the SDK's own types**, where the signature took `unknown` for both. `useShape(shapeType: ShapeType<T>, conf: RdfOrmConfig<T> | string | undefined)`, which is what the surface already said the pair was. A caller passing the SDK's shape and config compiles unchanged; one passing something else now does not, which is why this counts. - **The two arguments are the SDK's own types**, where the signature took `unknown` for both. `useShape(shapeType: ShapeType<T>, conf: RdfOrmConfig<T> | string | undefined)`, which is what the surface already said the pair was. A caller passing the SDK's shape and config compiles unchanged; one passing something else now does not, which is why this counts.
- **`size` and `length` count what you may read, and so does every other way of asking.** An ordered config (`orderBy`) makes the collection an array, which says how many items it holds in four places - `length`, `Object.keys`, `in`, and the descriptor of each index - and only the first was filtered. `data[0]` now names the first item you may read rather than the first the engine returned, and the positions past them do not exist. - **`size` and `length` count what you may read, and so does every other way of asking.** An ordered config (`orderBy`) makes the collection an array, which says how many items it holds in four places - `length`, `Object.keys`, `in`, and the descriptor of each index - and only the first was filtered. `data[0]` now names the first item you may read rather than the first the engine returned, and the positions past them do not exist.
- **A member's DESCRIPTOR answers what reading it answers.** `Object.getOwnPropertyDescriptor(result, "data")` returned the collection UNFILTERED, and `...(result, "subscription")` the handle that reading it refuses: that call does not go through the view's own read. Neither does a spread, `Object.entries`, `JSON.stringify` or a console inspection, so all of them were reading round the filter. They now read through it - which also means `{...result}` and `console.log(result)` throw on `subscription`, where they used to print it. Log `data`. - **A member's DESCRIPTOR answers what reading it answers.** `Object.getOwnPropertyDescriptor(result, "data")` returned the collection UNFILTERED, and `...(result, "subscription")` the handle that reading it refuses: that call does not go through the view's own read. Neither does a spread, `Object.entries`, `JSON.stringify` or a console inspection, so all of them were reading round the filter. They now read through it - which also means `{...result}` and `console.log(result)` throw on `subscription`, where they used to print it. Log `data`.
- **A mutation no longer hands back the collection, nor acts past the view.** `add` returned it - `Set.prototype.add` returns `this` so calls chain, measured on the plain `Set` and on the real `DeepSignalSet` alike - so `view.add(x)` gave every identity's items to a caller whose every way IN was filtered, through the first member the ORM's own example reaches for. It now hands back the view. `delete` refused nothing and removed an item the view declines to confirm the existence of; it now removes only what the view shows. `clear` emptied the collection every identity shares; it now empties the view. **Writing still reaches the document** - that is the one thing that must not change, and `e2e/orm-live-read.ts` measures it against the deployed broker. - **A mutation no longer hands back the collection, nor acts past the view, and all three ask the same question.** `add` returned the collection - `Set.prototype.add` returns `this` so calls chain, measured on the plain `Set` and on the real `DeepSignalSet` alike - so `view.add(x)` gave every identity's items to a caller whose every way IN was filtered, through the first member the ORM's own example reaches for. It now hands back the view, and it now REFUSES an object naming a document you hold no key for: ungated, it let you plant an object into someone else's document, never see it again, and have the two-way binding commit it there. `delete` removed an item the view declines to confirm the existence of; `clear` emptied the collection every identity shares. **Your own writes still reach the document** - that is the one thing that must not change, and `e2e/orm-live-read.ts` measures it against the deployed broker.
- **A write to the collection's own properties is refused.** `data[0] = x`, `delete data[0]` and `data.length = 0` all went through against a collection that allowed them; the last defeated the emptying rule outright. The view refuses them itself now, rather than relying on the ordered collection upstream refusing them for it.
- **The members upstream declares non-mutating answer again.** Eighteen array reads on an ordered result, plus `includes` and the seven set combinators, were throwing for the holder whose data it was - a leak traded for an outage. They are served over the items you may read.
- **An ordered result yields indices where it used to yield items.** `keys()` handed back items, `entries()` handed back `[item, item]` and `forEach` handed the item as its second argument - the Set contract, applied to an array. No leak, and wrong output on your own data. - **An ordered result yields indices where it used to yield items.** `keys()` handed back items, `entries()` handed back `[item, item]` and `forEach` handed the item as its second argument - the Set contract, applied to an array. No leak, and wrong output on your own data.
- **Unchanged**: the read still spends no round-trip asking a public store for a key, a document you have read nowhere else still does not appear, and nothing about `useShapeQuery`, `getObjects` or `insertObject` moves. - **Unchanged**: the read still spends no round-trip asking a public store for a key, a document you have read nowhere else still does not appear, and nothing about `useShapeQuery`, `getObjects` or `insertObject` moves.
@@ -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, * 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}. * 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. */ /** `"0"`, `"12"` - an array index, in the canonical form a property key takes. */
const INDEX = /^(0|[1-9][0-9]*)$/; 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. * The descriptor of a member, filtered the same way reading it is.
* *
@@ -142,8 +188,24 @@ function filteredDescriptor(
): PropertyDescriptor | undefined { ): PropertyDescriptor | undefined {
const d = Reflect.getOwnPropertyDescriptor(target, prop); const d = Reflect.getOwnPropertyDescriptor(target, prop);
if (d === undefined) return undefined; if (d === undefined) return undefined;
// An accessor carries no value of its own; the `get` trap is what answers it. // AN ACCESSOR. It carries no value of its own, and that is exactly why handing it back
if (!("value" in d)) return d; // 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(); const value = read();
if (Object.is(d.value, value)) return d; if (Object.is(d.value, value)) return d;
// A property that is neither configurable nor writable cannot be REPORTED with another // 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; 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, * 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 * 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. */ * call this, so neither can answer what the other refuses. */
const read = (target: object, prop: string | symbol): unknown => { const read = (target: object, prop: string | symbol): unknown => {
if (prop === Symbol.iterator) return function* () { yield* kept(target); }; 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 // 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 // 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 // `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 // 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. // 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); 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 reactive-set extras, named here because each is computed from the filtered
// The list is `iteratorHelperKeys` from `@ng-org/alien-deepsignals` — an earlier // items rather than delegated. They are part of upstream's `iteratorFnKeys` - the
// pass whitelisted half of it and threw on the rest, so a holder's calls on their // comment used to call that set `iteratorHelperKeys`, which is not its name and not
// OWN data crashed (`toArray`, `reduce`, `first`…). Filtering is the answer for all // the list it held: `includes` and `Symbol.asyncIterator` are in it and were missing
// of them; refusing is only for what is not on this list. // 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 === "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 === "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); 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 // three different ways: a mutation may not reach past what the view shows, and may not
// hand back what the view hides. // 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 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 // "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 // 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); const fn = Reflect.get(target, prop, target);
if (typeof fn !== "function") return fn; if (typeof fn !== "function") return fn;
// `add` chains, so its result is the collection and must become the view. // `add` chains, so its result is the collection and must become the view.
return (...args: unknown[]): unknown => return (...args: unknown[]): unknown => {
handBack((fn as (...a: unknown[]) => unknown).apply(target, args), target, prop); if (!keep(args[0])) throw writeRefusalOf(args[0]);
return handBack((fn as (...a: unknown[]) => unknown).apply(target, args), target, prop);
};
} }
if (prop === "delete") { if (prop === "delete") {
const fn = Reflect.get(target, prop, target); 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 // 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 // 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. // 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); const i = indexOf(prop);
return i === null ? Reflect.has(target, prop) : i < kept(target).length; 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) { ownKeys(target) {
// A Set holds its items internally and owns no key naming one, so there is nothing // 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 // 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 * 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 * SAME reference WITHIN one call.
* 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 * It does not survive a re-render, and the note here used to claim it did: the shipped hook
* force, so a view can never answer from a keyring that has been replaced. * 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 }>(); const filteredCollections = new WeakMap<object, { caps: CapRegistry; view: object }>();
+129 -1
View File
@@ -1,5 +1,13 @@
import { test, expect } from "bun:test"; 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"; import { CapRegistry } from "../src/emulated-verifier/caps";
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in), // 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.keys()]).toEqual([MINE]);
expect([...asSet.entries()]).toEqual([[MINE, 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 } * { data, promise, subscription, isLoading, nextPage, previousPage }
* *
* Each of the six is reproduced with the cardinality the declaration gives it: * 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 * - `data` is a `DeepSignalSet` for an unordered config and a `readOnlyArray` over a
* (upstream `SubscriptionData<T, CONF>`), and `undefined` while nothing has arrived; * `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; * - `promise` resolves to that same collection, and is `undefined` when `conf` is;
* - `subscription` is the handle, carrying the items three ways of its own - * - `subscription` is the handle, carrying the items three ways of its own -
* `signalObject`, `readyPromise` and the raw arrays a change listener is handed; * `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 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 { BaseType } from "@ng-org/shex-orm";
import type { ShapeResult, UseShapeLike } from "../src/model/types"; 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. // `SubscriptionData<T, CONF>`: a set with no `orderBy`, an ordered array with one.
const ordered = typeof conf === "object" && conf.orderBy !== undefined; 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; lastRaw = collection;
const subscription: FakeSubscription = { const subscription: FakeSubscription = {
signalObject: collection, 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); 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 -------------------------------------- // -- SAFE FOR A SHAPE NOBODY HAS SEEN --------------------------------------
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two /** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two