fix: ce qui n-est pas DECIDE est refuse, et les deux vues portent la meme regle
LE PRINCIPE ETAIT FAUX, pas un nom manquant. "Un primitif ne peut atteindre aucun objet, donc il passe" : or un COMPTE est un primitif qui DECRIT les objets. Le fichier le savait deja, puisque `size` et `length` sont calcules - par une liste de deux noms, c-est-a-dire la liste de noms interdite reapparue en repli des que le principe a cede. Tout ce qui s-epelle autrement passait : mesure, un porteur qui peut lire 1 objet sur 3 lisait `totalCount` a 3. Et LA SUITE EPINGLAIT LA REPONSE QUI FUIT COMME CORRECTE, six lignes au-dessus d-un test dont le commentaire appelle ce meme compte non filtre le defaut. Aucun test de TYPE ne separe un primitif qui est une valeur d-un primitif derive de la collection : un nombre est un nombre. Dit clairement, parce que la reponse precedente pretendait le contraire. Le defaut s-inverse donc : ce que ce fichier a DECIDE, il le sert ; tout le reste refuse, primitif ou non. C-est sur pour une forme que personne n-a vue PAR CONSTRUCTION. Deux exceptions, aucune n-etant une epellation du SDK : un membre que la cible ne porte pas rend `undefined`, et les cles de protocole du LANGAGE, fermees par la specification. LES DEUX VUES, PAS UNE. L-enveloppe avait deux pieges contre neuf : `delete resultat.data`, `resultat.isLoading = x`, `setPrototypeOf` et `preventExtensions` atteignaient l-objet du SDK - dont les deux derniers avaient ete fermes sur la collection au round precedent. Les cinq pieges d-ecriture vivent maintenant dans UN objet etale dans les deux. `add` demandait `keep`, qui repond vrai pour un objet ne nommant AUCUN document : juste pour une lecture, vide de sens pour une ecriture. L-objet atterrissait dans la collection partagee, et seul l-amont l-arretait - la garantie empruntee que ce fichier avait justement retiree un round plus tot. Enfin `copyWithin`, que la liste amont classe non mutant a tort et que son propre `readOnlyArray` refuse : il n-est plus servi.
This commit is contained in:
@@ -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.
|
||||
|
||||
**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 itself is refused** - `data[0] = x`, `delete data[0]`, `data.length = 0`, `Object.defineProperty`, `Object.setPrototypeOf` and `Object.freeze` - on this view's own account rather than because the ordered collection upstream happens to refuse some of 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.
|
||||
**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 member this package has not DECIDED is refused, whatever its type** - that is how a member a later `@ng-org/orm` adds is settled before anyone has seen it. It is not decided by type: a COUNT is a plain number that describes the items, and nothing separates it from a page size, so nothing undecided passes. A member the SDK does not carry at all reads `undefined`, since there is nothing behind it. 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` THROWS for an object naming a document you hold no key for, and for one naming NO document (there is then nothing that owns it, and it would land in a collection every identity shares - set `@graph` to a document you hold) (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 what you are handed is refused** - `data[0] = x`, `delete data[0]`, `data.length = 0`, `Object.defineProperty`, `Object.setPrototypeOf` and `Object.freeze` - on the RESULT OBJECT as much as on `data`, and on this package's own account rather than because the ordered collection upstream happens to refuse some of 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`). The one exception is `copyWithin`, which that list names and which WRITES; upstream's own ordered collection throws on it too, and so does this. 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.
|
||||
|
||||
@@ -297,6 +297,9 @@ What each level means here, in this package's own terms:
|
||||
- **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 itself is refused.** `data[0] = x`, `delete data[0]` and `data.length = 0` all went through against a collection that allowed them, the last defeating the emptying rule outright; `Object.setPrototypeOf(data, ...)` replaced the real collection's prototype, after which iterating it throws for every identity, and `Object.freeze(data)` made it non-extensible. The view refuses all of them itself now, rather than relying on the ordered collection upstream refusing some of 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.
|
||||
- **A member this package has not decided is refused, whatever its type.** The rule used to be "a plain scalar cannot reach an item, so it passes", and a COUNT is a plain scalar that describes the items: a member spelled `totalCount` published how many objects exist while you could read one. `size` and `length` were right only because they were computed by name. Nothing undecided passes now - what a later version adds refuses, loudly, naming itself.
|
||||
- **The RESULT OBJECT is as write-protected as `data`.** `delete result.data`, `result.isLoading = x`, `Object.setPrototypeOf(result, ...)` and `Object.preventExtensions(result)` all reached the SDK's own object; the last two had been closed on `data` one version earlier and not here.
|
||||
- **`add` refuses an object that names no document.** The gate asked the same question a READ asks, where an absent `@graph` means "names no document, nothing to check" - which let an object with no `@graph` into the collection every identity shares. Set `@graph` to a document you hold.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -74,12 +74,8 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this value NOT reach an item, whatever it turns out to be?
|
||||
*
|
||||
* The one question a filter can ask about a member it has never seen. A primitive holds no
|
||||
* reference to anything, so no item is reachable through it and it may pass. An object or a
|
||||
* function may hold one - directly, behind a promise, behind a getter, behind a whole
|
||||
* subscription - so it may not pass unless this file has a filtered form of it.
|
||||
* Is this value a primitive? Used where the question really is "can this hold a reference",
|
||||
* never as a licence to pass a member through - see {@link answerUndecidedMember}.
|
||||
*
|
||||
* `null` is a primitive in the model and `typeof null` is `"object"`, so it is named.
|
||||
*/
|
||||
@@ -87,6 +83,20 @@ function cannotReachAnItem(v: unknown): boolean {
|
||||
return v === null || (typeof v !== "object" && typeof v !== "function");
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys the LANGUAGE reads, whose values are primitives and describe the object rather
|
||||
* than its contents.
|
||||
*
|
||||
* This is not an SDK spelling list and cannot become one: it is closed by the ECMAScript
|
||||
* specification, not by `@ng-org/orm`, so no version bump adds to it. Without it a view
|
||||
* cannot be printed, tagged or concatenated, and refusing those refuses a question nobody
|
||||
* asked about the items.
|
||||
*/
|
||||
const LANGUAGE_PRIMITIVE_KEYS: ReadonlySet<string | symbol> = new Set([
|
||||
Symbol.toStringTag,
|
||||
Symbol.isConcatSpreadable,
|
||||
]);
|
||||
|
||||
/** What a refused member says. One wording for both shapes of refusal, so that a report of
|
||||
* either is greppable as the same event. */
|
||||
function refusalOf(prop: string | symbol, where: string): Error {
|
||||
@@ -103,24 +113,51 @@ function refusalOf(prop: string | symbol, where: string): Error {
|
||||
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)}`,
|
||||
doc === null
|
||||
? "[ng-eventually] read filter: refused - this object names no document in `@graph`, " +
|
||||
"so there is no document that owns it and nothing to check a cap against: it would " +
|
||||
"land in the collection every identity shares. Set `@graph` to a document you hold."
|
||||
: "[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}.
|
||||
* The answer for a member this file has NOT DECIDED: refuse it, whatever its type.
|
||||
*
|
||||
* ── Why the type-based default was wrong, and what replaces it ────────────
|
||||
* It used to be "a primitive cannot reach an item, so it passes". That is false, and the
|
||||
* file already knew it: a COUNT is a primitive that describes the items, which is why
|
||||
* `size` and `length` are computed rather than forwarded. Anything spelled otherwise went
|
||||
* straight through - measured 2026-09-24, a holder who may read one of three items read
|
||||
* `totalCount` as 3 - and `@ng-org/orm` already ships `pageSize`, `maxActivePages` and
|
||||
* `nextPage`, so a total-count member is an ordinary next bump rather than a hypothesis.
|
||||
*
|
||||
* There is NO TYPE-LEVEL TEST that separates a primitive which is a value from one derived
|
||||
* from the collection: a number is a number, and nothing observable about a getter says
|
||||
* whether its 7 counts items or echoes a page size. Said plainly because the previous
|
||||
* answer pretended otherwise - and because the repair is not another name in a list.
|
||||
*
|
||||
* So the default inverts. **What this file has decided, it serves; everything else it
|
||||
* refuses**, primitive or not. That is safe for a shape nobody has seen BY CONSTRUCTION,
|
||||
* which a passing default never was: what the next version adds refuses, loudly, naming
|
||||
* itself. The prohibition worth keeping is on a list that lets unknowns THROUGH - that one
|
||||
* breaks silently on a bump, which is how this whole defect arrived.
|
||||
*
|
||||
* Two things are not refusals, and neither is an SDK spelling:
|
||||
* - a member the target does NOT CARRY answers `undefined`, because the SDK offers no
|
||||
* such member and there is nothing behind it to derive anything from;
|
||||
* - {@link LANGUAGE_PRIMITIVE_KEYS}, closed by the language rather than by any package.
|
||||
*
|
||||
* A function is refused AT THE CALL rather than at the read, which is not politeness: a
|
||||
* function value is read by things that never call it - a logger, a devtools inspection, a
|
||||
* `typeof` guard - and refusing those would be refusing a question nobody asked. An object
|
||||
* has no such later moment, so it is refused at the read.
|
||||
* `typeof` guard - and refusing those would be refusing a question nobody asked.
|
||||
*/
|
||||
function answerUnnamedMember(target: object, prop: string | symbol, where: string): unknown {
|
||||
function answerUndecidedMember(target: object, prop: string | symbol, where: string): unknown {
|
||||
if (!(prop in target)) return undefined;
|
||||
const v = Reflect.get(target, prop, target);
|
||||
if (cannotReachAnItem(v)) return v;
|
||||
if (LANGUAGE_PRIMITIVE_KEYS.has(prop) && cannotReachAnItem(v)) return v;
|
||||
if (typeof v === "function") {
|
||||
return () => {
|
||||
throw refusalOf(prop, where);
|
||||
@@ -132,6 +169,46 @@ 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 traps that stop a WRITE reaching the object underneath, written ONCE and spread into
|
||||
* both views.
|
||||
*
|
||||
* Written once because the last three defects in this file were all one shape: a rule
|
||||
* applied in one of two places. These five were added to the collection view and not to the
|
||||
* envelope, so on the result object `delete result.data` succeeded, `result.isLoading = x`
|
||||
* wrote to the SDK's own object, and `Object.setPrototypeOf` / `Object.preventExtensions`
|
||||
* reached it too - the very pair the round before had found and trapped, on one view of
|
||||
* two. A spread object cannot be added to one and forgotten on the other.
|
||||
*
|
||||
* Nothing legitimate is lost on either: a caller mutates an ITEM (handed out whole, and
|
||||
* only when readable) or calls a member; writing to the view's own properties is not a
|
||||
* gesture the SDK or this surface offers.
|
||||
*/
|
||||
const writeTraps: Pick<
|
||||
ProxyHandler<object>,
|
||||
"set" | "deleteProperty" | "defineProperty" | "setPrototypeOf" | "preventExtensions"
|
||||
> = {
|
||||
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");
|
||||
},
|
||||
// The two that reach the target without naming a property: `setPrototypeOf` replaced the
|
||||
// real collection's prototype, after which iterating it throws for EVERY identity, and
|
||||
// `preventExtensions` - what `Object.freeze` begins with - both writes to the target and
|
||||
// strands the index projection, which is only lawful while the target is extensible.
|
||||
setPrototypeOf() {
|
||||
throw refusalOf("the prototype", "a write to the view itself");
|
||||
},
|
||||
preventExtensions() {
|
||||
throw refusalOf("extensibility", "a write to the view itself");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The members `@ng-org/alien-deepsignals` declares NON-MUTATING, restated.
|
||||
*
|
||||
@@ -157,8 +234,23 @@ export const ITERATOR_HELPERS: ReadonlySet<string> = new Set([
|
||||
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",
|
||||
"toSpliced", "slice", "values", "with",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The ONE member upstream lists as non-mutating that is not: `Array.prototype.copyWithin`
|
||||
* writes. Upstream's own `readOnlyArray` agrees in practice - it THROWS "Cannot modify
|
||||
* readonly array" when you so much as read `copyWithin` off it - so the list and the
|
||||
* implementation next to it disagree, and the list is the one that is wrong.
|
||||
*
|
||||
* Serving it over the snapshot made it succeed and discard silently, which teaches a caller
|
||||
* that a write worked when the collection it ships against refuses the same call. So it is
|
||||
* not served: reading it off a real ordered collection raises upstream's own error, and off
|
||||
* anything else it is refused like any other undecided member. Named here rather than
|
||||
* dropped quietly, because the parse gate in `test/read-filter.test.ts` checks for exactly
|
||||
* this one exception and nothing more.
|
||||
*/
|
||||
export const UPSTREAM_MISLABELLED_AS_NON_MUTATING: ReadonlySet<string> = new Set(["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([
|
||||
@@ -264,6 +356,24 @@ function filteredDescriptor(
|
||||
// @provenance makeReadFilteredView kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — the same filter as a live view over a reactive set; removed at migration
|
||||
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
|
||||
const keep = (item: unknown): boolean => readable(item, caps);
|
||||
/**
|
||||
* May this identity WRITE this object into the collection?
|
||||
*
|
||||
* NOT {@link readable}, and the difference is the whole of the finding it answers. On the
|
||||
* READ side an item with no `@graph` is kept, because it names no document and so there
|
||||
* is no cap to ask about. On the WRITE side that reading makes the gate VACUOUS: an
|
||||
* object carrying no `@graph` named no document, so it passed, landed in the collection
|
||||
* every identity shares and became visible to all of them. The only thing stopping the
|
||||
* plant was UPSTREAM raising "The object's @graph is missing and could not be inferred"
|
||||
* - a guarantee borrowed from the target, which is exactly what this file removed for
|
||||
* `set`/`deleteProperty` one round earlier and then left standing here.
|
||||
*
|
||||
* So a write must NAME a document and the holder must hold it.
|
||||
*/
|
||||
const writable = (item: unknown): boolean => {
|
||||
const doc = docOf(item);
|
||||
return doc !== null && caps.capFor(doc) !== undefined;
|
||||
};
|
||||
/** The readable items, as a plain array — what every handled member works from. */
|
||||
const kept = (target: object): unknown[] => {
|
||||
const out: unknown[] = [];
|
||||
@@ -448,7 +558,7 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
if (typeof fn !== "function") return fn;
|
||||
// `add` chains, so its result is the collection and must become the view.
|
||||
return (...args: unknown[]): unknown => {
|
||||
if (!keep(args[0])) throw writeRefusalOf(args[0]);
|
||||
if (!writable(args[0])) throw writeRefusalOf(args[0]);
|
||||
return handBack((fn as (...a: unknown[]) => unknown).apply(target, args), target, prop);
|
||||
};
|
||||
}
|
||||
@@ -506,7 +616,7 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
||||
// 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.
|
||||
return answerUnnamedMember(target, prop, "the collection view");
|
||||
return answerUndecidedMember(target, prop, "the collection view");
|
||||
};
|
||||
|
||||
const proxy = new Proxy(set, {
|
||||
@@ -522,39 +632,9 @@ 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");
|
||||
},
|
||||
// The two that reach the target without naming a property, found by sweeping this
|
||||
// file's own repair rather than by reasoning about it: `Object.setPrototypeOf(view, other)`
|
||||
// replaced the real collection's prototype - after which `[...collection]` throws for
|
||||
// EVERY identity, so it is the `clear` problem again in a different key - and
|
||||
// `Object.preventExtensions(view)` (which is what `Object.freeze` starts with) made
|
||||
// the target non-extensible, which both writes to it and breaks `ownKeys` below, whose
|
||||
// projection is only lawful while the target is extensible.
|
||||
setPrototypeOf() {
|
||||
throw refusalOf("the prototype", "a write to the view itself");
|
||||
},
|
||||
preventExtensions() {
|
||||
throw refusalOf("extensibility", "a write to the view itself");
|
||||
},
|
||||
// A WRITE to the collection itself, refused on this view's own account rather than
|
||||
// because the ordered collection upstream happens to refuse some of them too.
|
||||
...writeTraps,
|
||||
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
|
||||
@@ -671,6 +751,13 @@ export function makeReadFilteredShapeResult<R extends object>(result: R, caps: C
|
||||
"`useShapeQuery`.",
|
||||
);
|
||||
}
|
||||
// THE REQUEST'S OWN STATE. Decided rather than left to a default, which is the whole
|
||||
// repair: it is a boolean and it used to pass because it is a primitive, on a rule
|
||||
// that also passed `totalCount`. It is served because it describes the REQUEST - has
|
||||
// the payload arrived - and not the items, so it says nothing about what was filtered
|
||||
// out. (Its VALUE is upstream's own inversion; see `ShapeResult.isLoading`.)
|
||||
if (prop === "isLoading") return Reflect.get(target, prop, target);
|
||||
|
||||
// THE PAGE COMMANDS. They forward: what they load lands in the raw collection, which
|
||||
// is only ever read back through the filter above.
|
||||
//
|
||||
@@ -690,12 +777,16 @@ export function makeReadFilteredShapeResult<R extends object>(result: R, caps: C
|
||||
throw refusalOf(prop, "the result envelope");
|
||||
};
|
||||
}
|
||||
return answerUnnamedMember(target, prop, "the result envelope");
|
||||
return answerUndecidedMember(target, prop, "the result envelope");
|
||||
};
|
||||
|
||||
return new Proxy(result, {
|
||||
get: (target, prop) => read(target, prop),
|
||||
getOwnPropertyDescriptor: (target, prop) =>
|
||||
filteredDescriptor(target, prop, () => read(target, prop)),
|
||||
// The SAME write traps as the collection view, from the same object. This view had two
|
||||
// traps to the other's nine, so the SDK's own result could be deleted from, written to
|
||||
// and re-prototyped through it.
|
||||
...writeTraps,
|
||||
}) as R;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ITERATOR_HELPERS,
|
||||
NON_MUTATING_ARRAY_ONLY,
|
||||
NON_MUTATING_SET_ONLY,
|
||||
UPSTREAM_MISLABELLED_AS_NON_MUTATING,
|
||||
filterReadable,
|
||||
makeReadFilteredView,
|
||||
} from "../src/emulated-verifier/read-filter";
|
||||
@@ -271,8 +272,16 @@ test("the declared non-mutating surface still matches what upstream ships", () =
|
||||
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());
|
||||
// ...minus the one member upstream mislabels, which is named in the source rather than
|
||||
// dropped quietly, so this gate still goes red on any OTHER change to the list.
|
||||
expect(
|
||||
members("nonMutatingArrayFnKeys").filter(
|
||||
(k) => !ITERATOR_HELPERS.has(k) && !UPSTREAM_MISLABELLED_AS_NON_MUTATING.has(k),
|
||||
),
|
||||
).toEqual([...NON_MUTATING_ARRAY_ONLY].sort());
|
||||
expect([...UPSTREAM_MISLABELLED_AS_NON_MUTATING].every((k) =>
|
||||
members("nonMutatingArrayFnKeys").includes(k),
|
||||
)).toBe(true);
|
||||
expect(members("nonMutatingSetFnKeys").filter((k) => !ITERATOR_HELPERS.has(k)))
|
||||
.toEqual([...NON_MUTATING_SET_ONLY].sort());
|
||||
});
|
||||
@@ -315,11 +324,12 @@ test("every declared non-mutating member answers for the holder, on both forms",
|
||||
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]);
|
||||
// `copyWithin` is the one member upstream MISLABELS as non-mutating: it writes, and
|
||||
// upstream's own `readOnlyArray` throws on so much as reading it. Serving it over the
|
||||
// snapshot made it succeed and discard in silence, so it is refused instead - against a
|
||||
// real ordered collection that refusal is upstream's own.
|
||||
expect(() => (ordered as unknown as { copyWithin(a: number, b: number): unknown })
|
||||
.copyWithin(0, 1)).toThrow();
|
||||
expect([...ordered]).toEqual([MINE, LINKED]);
|
||||
|
||||
const asSet = makeReadFilteredView(new Set<Item>([MINE, FOREIGN, LINKED]), caps) as
|
||||
@@ -378,3 +388,30 @@ test("a write to the view itself is refused, whatever the collection underneath
|
||||
expect(Object.getPrototypeOf(items)).toBe(Array.prototype);
|
||||
expect(Object.isExtensible(items)).toBe(true);
|
||||
});
|
||||
|
||||
// REGRESSION (fourth adversarial pass, 2026-09-24). The default was "a primitive cannot
|
||||
// reach an item, so it passes" - and a COUNT is a primitive that describes the items. The
|
||||
// file already knew it, because `size` and `length` are computed; anything spelled
|
||||
// otherwise went through. No type-level test separates a derived count from a page size,
|
||||
// so the default inverted: what this file has decided it serves, everything else refuses.
|
||||
test("a member this file has not decided is refused, primitive or not", () => {
|
||||
const set = new Set<Item>([MINE, FOREIGN, LINKED]) as Set<Item> & { count?: number };
|
||||
Object.defineProperty(set, "count", { value: 3, configurable: true });
|
||||
const { caps, become } = setup("alice");
|
||||
const view = makeReadFilteredView(set, caps) as Set<Item> & {
|
||||
count?: number;
|
||||
neverShipped?: unknown;
|
||||
};
|
||||
become("bob"); // holds nothing of the three
|
||||
|
||||
// The two spellings this file DECIDED answer what may be read,
|
||||
expect(view.size).toBe(0);
|
||||
// and the third spelling of the same number is refused rather than forwarded.
|
||||
expect(() => view.count).toThrow(/not filtered/i);
|
||||
// A member the collection does not carry answers `undefined`: nothing is behind it to
|
||||
// derive anything from, so refusing would refuse a question nobody asked.
|
||||
expect(view.neverShipped).toBeUndefined();
|
||||
// The language's own primitive keys still answer, and they are closed by the spec rather
|
||||
// than by any package, so no version bump adds to them.
|
||||
expect(Object.prototype.toString.call(view)).toBe("[object Set]");
|
||||
});
|
||||
|
||||
@@ -388,19 +388,24 @@ test("useShape: an accessor member is read through the view, not around it", asy
|
||||
|
||||
// -- SAFE FOR A SHAPE NOBODY HAS SEEN --------------------------------------
|
||||
|
||||
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two
|
||||
* nobody has declared - one holding the collection, one holding a count. */
|
||||
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus three
|
||||
* nobody has declared - one holding the collection, one COUNTING it, one yielding an item. */
|
||||
interface LaterResult extends ShapeResult {
|
||||
readonly rows: unknown;
|
||||
readonly totalCount: number;
|
||||
readonly sample: () => unknown;
|
||||
}
|
||||
|
||||
// The whole point of deciding by TYPE rather than by a list of names. A name list is what
|
||||
// breaks silently on the next bump - which is exactly how this defect arrived - so the
|
||||
// question asked of an unnamed member is what no version number can change: can the value
|
||||
// behind it reach an item at all?
|
||||
test("useShape: a member no version has declared yet cannot leak, while a scalar one still passes", async () => {
|
||||
// WHAT A MEMBER NOBODY HAS DECIDED ANSWERS, and the third version of this test, because the
|
||||
// first two were wrong in the same way. It asserted that `totalCount` PASSING was correct,
|
||||
// on the ground that "a primitive cannot hold a reference to anything" - six lines above a
|
||||
// test whose own comment calls an unfiltered count the defect. The suite asserted both that
|
||||
// the leak was correct and that it was the defect, which is worse than the leak: it would
|
||||
// have kept it green for ever.
|
||||
//
|
||||
// A count IS a primitive and DOES describe the items, and no type-level test separates it
|
||||
// from a page size. So nothing undecided passes now, whatever its type.
|
||||
test("useShape: no member this package has not decided is answered, whatever its type", async () => {
|
||||
const base = makeUseShapeHook();
|
||||
const later: FakeShapeHook = {
|
||||
...base,
|
||||
@@ -422,16 +427,21 @@ test("useShape: a member no version has declared yet cannot leak, while a scalar
|
||||
setCurrentUser("bob");
|
||||
const result = useShape(NoteShape, EVERYTHING) as LaterResult;
|
||||
|
||||
// An OBJECT member nobody named holds the collection, so it is refused at the read -
|
||||
// there is no later moment to refuse it at.
|
||||
// An OBJECT member holds the collection, so it is refused at the read - there is no
|
||||
// later moment to refuse it at.
|
||||
expect(() => result.rows).toThrow(/not filtered/i);
|
||||
// A FUNCTION member nobody named is refused at the CALL, so reading it (a logger, a
|
||||
// `typeof` guard) is not itself refused.
|
||||
// A FUNCTION member is refused at the CALL, so reading it (a logger, a `typeof` guard)
|
||||
// is not itself refused.
|
||||
expect(typeof result.sample).toBe("function");
|
||||
expect(() => result.sample()).toThrow(/not filtered/i);
|
||||
// A PRIMITIVE cannot hold a reference to anything, so it passes with no entry of its
|
||||
// own - which is why `isLoading` needs none either.
|
||||
expect(result.totalCount).toBe(7);
|
||||
// A PRIMITIVE is refused too, and this is the case the earlier version of this test had
|
||||
// backwards: `totalCount` counts items the holder may not read. Three exist, one may be
|
||||
// read, and the number that may not be published is 3.
|
||||
expect([...(result.data as Iterable<BaseType>)]).toHaveLength(1);
|
||||
expect(() => result.totalCount).toThrow(/not filtered/i);
|
||||
// A member the SDK does not carry at all answers `undefined`: there is nothing behind it
|
||||
// to derive anything from, so refusing would refuse a question nobody asked.
|
||||
expect((result as unknown as { neverShipped?: unknown }).neverShipped).toBeUndefined();
|
||||
});
|
||||
|
||||
// An `orderBy` config makes the collection an ARRAY (upstream `SubscriptionData`), whose
|
||||
@@ -483,3 +493,53 @@ test("useShape: the descriptor of a member answers exactly what reading it answe
|
||||
/RdfOrmSubscription/,
|
||||
);
|
||||
});
|
||||
|
||||
// REGRESSION (fourth adversarial pass). The envelope view had TWO traps where the
|
||||
// collection view had nine, so the SDK's own result object could be written to through it -
|
||||
// the same fix applied in one of two places, which is the shape of every defect this file
|
||||
// has had. Both views now take the write traps from ONE object, so one cannot be repaired
|
||||
// without the other.
|
||||
test("useShape: the result object cannot be written to through the view either", async () => {
|
||||
const hook = makeUseShapeHook();
|
||||
inject(hook);
|
||||
const hers = await documentOf("alice");
|
||||
await documentOf("bob");
|
||||
hook.items.push(noteIn(hers, "a1"));
|
||||
|
||||
setCurrentUser("bob");
|
||||
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
||||
const asRecord = result as unknown as Record<string, unknown>;
|
||||
expect(() => {
|
||||
delete asRecord["data"];
|
||||
}).toThrow(/not filtered/i);
|
||||
expect(() => {
|
||||
asRecord["isLoading"] = true;
|
||||
}).toThrow(/not filtered/i);
|
||||
expect(() => Object.setPrototypeOf(result, { stolen: 1 })).toThrow(/not filtered/i);
|
||||
expect(() => Object.preventExtensions(result)).toThrow(/not filtered/i);
|
||||
// The request state still reads, because it is DECIDED rather than passed: it describes
|
||||
// the request, not the items.
|
||||
expect(typeof result.isLoading).toBe("boolean");
|
||||
});
|
||||
|
||||
// REGRESSION (fourth adversarial pass). The write gate asked `keep`, which answers `true`
|
||||
// for an object naming NO document - right for a read, where there is no cap to ask about,
|
||||
// and VACUOUS for a write: the object landed in the collection every identity shares. The
|
||||
// only thing stopping it was upstream raising "@graph is missing", which is a guarantee
|
||||
// borrowed from the target - the very thing removed for `set`/`deleteProperty` one round
|
||||
// earlier and left standing here.
|
||||
test("useShape: adding an object that names no document at all is refused", async () => {
|
||||
const hook = makeUseShapeHook();
|
||||
inject(hook);
|
||||
const hers = await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
hook.items.push(noteIn(hers, "a1"));
|
||||
|
||||
setCurrentUser("bob");
|
||||
const data = useShape(NoteShape, EVERYTHING).data as { add(item: BaseType): unknown };
|
||||
expect(() => data.add({ "@id": "PLANT" } as BaseType)).toThrow(/names no document/i);
|
||||
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(1);
|
||||
// A write that names a document this identity holds still goes through.
|
||||
data.add(noteIn(his, "b1"));
|
||||
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user