From 98e46effc28ac4ffadd7f65b54fe50aec77da99f Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 24 Sep 2026 19:03:10 +0200 Subject: [PATCH] fix: le resultat d-une commande de page suit la meme regle que tout le reste Balayage execute sur les deux proxys apres le correctif precedent : aucun membre de la vue de collection ne rend plus la cible, mais une commande de page de l-enveloppe qui rendrait la collection la rendait BRUTE. `nextPage` est declare `() => void` et ne rend rien aujourd-hui, donc ce n-est pas un defaut vivant - c-est la meme justification jamais executee, sur l-autre proxy, et c-est exactement le motif qu-il fallait casser. Elle s-execute toujours ; seul son retour est tenu a la regle. --- .../contract_polyfill-surface.md | 2 +- .../src/emulated-verifier/read-filter.ts | 20 ++++++++++++---- packages/polyfill/test/use-shape.test.ts | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md b/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md index 92e75c6..09581e9 100644 --- a/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md +++ b/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md @@ -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` and the two page commands pass through, 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. **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. **`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. diff --git a/packages/polyfill/src/emulated-verifier/read-filter.ts b/packages/polyfill/src/emulated-verifier/read-filter.ts index c676016..ccff09b 100644 --- a/packages/polyfill/src/emulated-verifier/read-filter.ts +++ b/packages/polyfill/src/emulated-verifier/read-filter.ts @@ -500,12 +500,24 @@ export function makeReadFilteredShapeResult(result: R, caps: C "`useShapeQuery`.", ); } - // THE PAGE COMMANDS. They return `void` and yield nothing, so they forward, exactly - // as `add`/`delete`/`clear` do on the collection view. What they load lands in the - // raw collection, which is only ever read back through the filter above. + // THE PAGE COMMANDS. They forward: what they load lands in the raw collection, which + // is only ever read back through the filter above. + // + // Their RESULT is held to the same rule as everything else here, and that is the + // lesson of the collection view rather than a worry about this one. `nextPage` is + // declared `() => void` and the shipped hook assigns the subscription's own, so today + // it yields nothing - but "it returns void" was the written, unexecuted ground on + // which `add` was forwarded, and `Set.prototype.add` returns the collection. A page + // command that ever hands something back gets the same answer as any other member: + // a scalar passes, anything that could reach an item refuses. if (prop === "nextPage" || prop === "previousPage") { const fn = Reflect.get(target, prop, target); - return typeof fn === "function" ? fn.bind(target) : fn; + if (typeof fn !== "function") return fn; + return (...args: unknown[]): unknown => { + const out = (fn as (...a: unknown[]) => unknown).apply(target, args); + if (cannotReachAnItem(out)) return out; + throw refusalOf(prop, "the result envelope"); + }; } return answerUnnamedMember(target, prop, "the result envelope"); }; diff --git a/packages/polyfill/test/use-shape.test.ts b/packages/polyfill/test/use-shape.test.ts index d552be5..f72007a 100644 --- a/packages/polyfill/test/use-shape.test.ts +++ b/packages/polyfill/test/use-shape.test.ts @@ -257,6 +257,29 @@ test("useShape: deleting reaches only what the view shows", async () => { expect([...(hook.raw() as Iterable)].map((o) => o["@graph"])).toEqual([hers]); }); +// The page commands forward, and their RESULT is held to the same rule as every other +// member. `nextPage` is declared `() => void` and yields nothing today, so this pins the +// RULE rather than a live defect - which is the point: "it returns void" was the written, +// never-executed ground on which `add` was forwarded, and `add` returns the collection. +test("useShape: a page command that handed something back would be refused, not forwarded", async () => { + const base = makeUseShapeHook({ paginated: true }); + const leaky: FakeShapeHook = { + ...base, + hook: ((shapeType, conf) => ({ + ...base.hook(shapeType, conf), + nextPage: (): void => base.raw() as undefined, + })) as UseShapeLike, + }; + inject(leaky); + 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); + expect(() => result.nextPage?.()).toThrow(/not filtered/i); +}); + // -- SAFE FOR A SHAPE NOBODY HAS SEEN -------------------------------------- /** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two