fix: le prototype et l-extensibilite sont des ecritures, elles aussi
Trouve en balayant le correctif de ce round plutot qu-en raisonnant dessus. `Object.setPrototypeOf(vue, ...)` remplacait le prototype de la VRAIE collection - apres quoi l-iterer leve pour toutes les identites, c-est le probleme de `clear` dans une autre tonalite - et `Object.preventExtensions`, par quoi `Object.freeze` commence, la rendait non extensible : une ecriture, et de surcroit la projection d-index de `ownKeys` n-est licite que tant que la cible est extensible.
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.
|
**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'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.
|
**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.
|
||||||
|
|
||||||
**`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.
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ What each level means here, in this package's own terms:
|
|||||||
- **`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, 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 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -542,6 +542,19 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
|
|||||||
defineProperty(_target, prop) {
|
defineProperty(_target, prop) {
|
||||||
throw refusalOf(prop, "a write to the view itself");
|
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");
|
||||||
|
},
|
||||||
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
|
||||||
|
|||||||
@@ -366,6 +366,15 @@ test("a write to the view itself is refused, whatever the collection underneath
|
|||||||
view.length = 0;
|
view.length = 0;
|
||||||
}).toThrow(/not filtered/i);
|
}).toThrow(/not filtered/i);
|
||||||
expect(() => Object.defineProperty(view, "0", { value: { id: "SPY" } })).toThrow(/not filtered/i);
|
expect(() => Object.defineProperty(view, "0", { value: { id: "SPY" } })).toThrow(/not filtered/i);
|
||||||
|
// The two that reach the target without naming a property. Replacing the prototype
|
||||||
|
// breaks iteration of the real collection for EVERY identity; `preventExtensions` -
|
||||||
|
// which `Object.freeze` starts with - writes to it and unlawfully strands the index
|
||||||
|
// projection. Both were found by sweeping this view's own repair.
|
||||||
|
expect(() => Object.setPrototypeOf(view, { stolen: () => items })).toThrow(/not filtered/i);
|
||||||
|
expect(() => Object.preventExtensions(view)).toThrow(/not filtered/i);
|
||||||
|
expect(() => Object.freeze(view)).toThrow(/not filtered/i);
|
||||||
// The plain array underneath accepted nothing, so the view is what stood in the way.
|
// The plain array underneath accepted nothing, so the view is what stood in the way.
|
||||||
expect(items.map((i) => i.id)).toEqual(["a", "p"]);
|
expect(items.map((i) => i.id)).toEqual(["a", "p"]);
|
||||||
|
expect(Object.getPrototypeOf(items)).toBe(Array.prototype);
|
||||||
|
expect(Object.isExtensible(items)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user