fix: le dossier dit la verite sur le code, et la garde peut echouer

Cinq constats, aucun sur la frontiere des droits : le dossier et la garde.

LE MOTIF ENREGISTRE SUR `copyWithin` ETAIT FAUX, et c-etait raisonne, pas joue -
dans le meme lot que `rule_justify-a-return-value-by-running-it`, dont la
premiere application est a son propre voisin. Mesure : le vrai `readOnlyArray`
ne leve PAS, il execute et DETRUIT le premier element (["A","B","C"] devient
["B","C","C"]), tandis que `push`/`splice`/`sort`/`reverse`/`fill`/`shift`
levent a la lecture. `copyWithin` echappe a cette garde pour la raison exacte
qui le fait nommer ici : il est dans la meme liste mal etiquetee dont la garde
est faite. Le refuser est donc PLUS protecteur que la cible, pas equivalent :
une divergence deliberee, consignee comme telle, avec la mesure a cote.

LE FICHIER DOCUMENTAIT ENCORE LA REGLE QUE CE ROUND A SUPPRIMEE, et se
contredisait : deux en-tetes decrivaient le defaut par TYPE ("un compte, un
drapeau passent") et disaient que `isLoading` n-est pas nomme, alors que la
ligne 759 le nomme. Le contrat gardait les deux politiques a la fois. Une
doctrine qui survit a une reecriture est precisement ce qui egare le suivant.

LA GARDE NE VERIFIAIT PAS CE QU-ELLE ANNONCAIT. Prouve : une copie verbatim avec
{copyWithin, slice} en exceptions et `slice` retire de la liste servie passait
1 pass / 0 fail. Elle compare maintenant une PARTITION - l-ensemble effectif
amont egale l-union, les trois parts sont disjointes, l-exception vaut exactement
un membre nomme - et lit aussi les entrees SYMBOLE, invisibles a l-ancienne
expression. Verifie rouge dans les trois directions.

LA DEROGATION ECMASCRIPT ETAIT INFONDEE TELLE QUE RAISONNEE : la specification
ferme les CLES, pas les VALEURS. Un `Symbol.toStringTag` implemente en accesseur
rendant `Set(${size})` publiait le compte non filtre. Les deux cles sont
calculees a partir de la forme enveloppee. Et ce que la vue divulgue par
ailleurs - l-existence d-une cle sur la cible - est dit exactement, avec la
mesure qui montre qu-aucune presence de cle ne depend des objets.

`reduce` branchait sur `init === undefined` au lieu de l-arite.
This commit is contained in:
Sylvain Duchesne
2026-09-24 20:43:04 +02:00
parent 5c50ca67c0
commit a88b4497d9
4 changed files with 207 additions and 84 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.
**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.
**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: it is refused here, and that is STRICTER than NextGraph's own ordered collection, which runs it and destroys an element (its guard is built from the same mislabelled list). A deliberate difference, stated so you do not discover it as a surprise. 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.
@@ -290,10 +290,10 @@ What each level means here, in this package's own terms:
- **Read your objects through `data`, not by iterating what the call returned.** `@ng-org/orm`'s React hook returns `{ data, promise, subscription, isLoading, nextPage?, previousPage? }` around the collection, and it has for every release this package builds against. This package went on treating the return AS the collection: `[...result]` and `result.size` threw, and `result.data`, `result.promise` and `result.subscription` each handed back **every identity's items**, with nothing raised anywhere. If you were reading `.data`, you were reading unfiltered. That is the defect this version closes, and it is why this entry is worth reading before upgrading rather than after.
- **`subscription` is REFUSED where it used to be handed over.** It is the ORM's own `RdfOrmSubscription`, which reads and writes with no capability filter at all and which `## Non-guarantees` has always said this package does not publish. Reading it now throws, naming itself. `data`, `promise`, `isLoading` and the two page commands are what remains, and they are what a caller needs: the SDK's own example destructures `data` and `isLoading` and nothing else.
- **A member of that object that this package does not know is refused unless it is a plain scalar.** The rule is the value's TYPE, not its name: whatever can carry an item is an object or a function, so a member the next `@ng-org/orm` adds is already decided, while a flag or a count still passes. The previous rule - "anything that is not a function is harmless" - is precisely what let three members of the new shape through, so it has been replaced rather than extended with three names.
- **A member of that object that this package has not DECIDED is refused, whatever its type.** All six of the members `@ng-org/orm` ships are decided; a seventh that a later version adds refuses until someone decides it. Two earlier rules were tried and both let a leak through - "anything that is not a function is harmless", which handed over three members of the current shape, and "a plain scalar cannot reach an item", which handed over any count not spelled `size` or `length`. There is no test on a value's type that separates a count derived from your data from a page size, so the rule is not about types at all: it is that nothing undecided is answered.
- **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.
- **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 `Object.keys(result)` THROW on `subscription`, where they used to hand it over. **But do not read that as "logging is filtered".** A console is the HOST's, not this package's: `console.log(result)` in Bun and in a browser devtools inspects the object BEHIND the view and prints `data` and `subscription` in full, for every identity, and no interception can prevent it - measured. That is a property of runtime introspection, not a hole a version can close. **Do not log or inspect what a shape read hands you when more than one identity shares the page**; iterate `data` and log what you mean to show.
- **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.
@@ -84,18 +84,20 @@ function cannotReachAnItem(v: unknown): boolean {
}
/**
* The keys the LANGUAGE reads, whose values are primitives and describe the object rather
* than its contents.
* WHAT THIS VIEW DISCLOSES BESIDE THE ITEMS, said exactly rather than claimed away.
*
* 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.
* A member the target does not carry answers `undefined` (see {@link answerUndecidedMember}),
* so a caller can tell a key the SDK has from one it does not: `view.by_a` refuses where
* `view.by_zzz` answers. That is deliberate, not an oversight - refusing a member nobody
* ships would refuse a question nobody asked - and it discloses the SDK's PUBLISHED SHAPE,
* which is in the package, not the holder's data.
*
* It is not a count channel either, and that is MEASURED rather than assumed: on every
* shape that ships, no key's PRESENCE depends on the items. `Object.keys(deepSignal(new
* Set(...)))` is empty whatever the set holds, and the array form's own keys are its
* indices, which this view projects onto the readable ones. If a future collection grew a
* key per item, that presence would become a count and this note is where to come back to.
*/
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. */
@@ -145,10 +147,16 @@ function writeRefusalOf(item: unknown): Error {
* 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.
* One thing is not a refusal: 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. What that discloses, and what it does not, is set out above.
*
* Note what is NOT here any more. A carve-out used to forward the target's own value for
* `Symbol.toStringTag` and `Symbol.isConcatSpreadable`, on the ground that the ECMAScript
* specification closes that key set. It closes the KEYS; it says nothing about the VALUES,
* so a tag implemented as a getter returning `Set(${size})` would have published the
* unfiltered count through a key this file had called harmless. Those two are DECIDED in
* the collection view now, with values the view computes from the shape it wraps.
*
* 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
@@ -157,7 +165,6 @@ function writeRefusalOf(item: unknown): Error {
function answerUndecidedMember(target: object, prop: string | symbol, where: string): unknown {
if (!(prop in target)) return undefined;
const v = Reflect.get(target, prop, target);
if (LANGUAGE_PRIMITIVE_KEYS.has(prop) && cannotReachAnItem(v)) return v;
if (typeof v === "function") {
return () => {
throw refusalOf(prop, where);
@@ -239,16 +246,30 @@ export const NON_MUTATING_ARRAY_ONLY: ReadonlySet<string> = new Set([
/**
* 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.
* WRITES.
*
* 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.
* ── What was recorded here, and what is actually true ─────────────────────
* This note used to say upstream's own `readOnlyArray` throws on reading `copyWithin`, so
* that refusing it merely matched the target. That was reasoned, not run, and it is FALSE.
* Measured against the installed package:
*
* ro.copyWithin -> function (no throw on the read)
* ro.copyWithin(0, 1) -> ["B","C","C"] (from ["A","B","C"] - the first
* element is DESTROYED)
* ro.push / splice / sort / reverse / fill / shift
* -> THROWS on the READ:
* "Readonly arrays do not expose non-mutating..."
*
* So `readOnlyArray` refuses every mutator it knows about - and `copyWithin` escapes that
* refusal for exactly the reason it is named here: it sits in the same mislabelled list the
* refusal is built from. Upstream's list and upstream's guard agree with each other and are
* both wrong.
*
* Refusing it is therefore MORE protective than the target, not equal to it: a deliberate
* divergence, recorded as one. The alternative is worse - serving it over the snapshot made
* it succeed and discard in silence, which teaches a caller that a write worked. Named here
* rather than dropped quietly, because the parse gate in `test/read-filter.test.ts` pins
* this exception by identity and cardinality.
*/
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
@@ -338,20 +359,25 @@ function filteredDescriptor(
* They are wired to one `read` function below rather than to three copies of the rule,
* because a boundary made of parallel switch statements is a boundary that drifts.
*
* ── Why the default is decided by TYPE, and not by a list of names ────────
* Until 2026-09-24 that reasoning was applied to FUNCTION members alone: everything else
* was forwarded raw, on the recorded ground that "a plain property carries no items". That
* ground was never a property of the model, only of the object that happened to be wrapped
* and the moment the wrapped object changed, three plain properties of it each carried
* every identity's items ({@link makeReadFilteredShapeResult}). A boundary that is safe for
* the shape in front of it and unsafe for the next one is not a boundary either.
* ── The default: DECIDED, or refused ─────────────────────────────────────
* Every member this file serves is named below. Anything else refuses, whatever its type.
* That is the whole rule, and {@link answerUndecidedMember} is where it lives.
*
* So the default is {@link cannotReachAnItem}, which asks of the VALUE what no version
* number can change: a primitive cannot hold a reference to anything, so it cannot reach an
* item and passes untouched - `size`, `isLoading`, a count, a flag, a name. An object or a
* function can, so it refuses unless this file has a filtered form of it. That is why
* nothing below names `isLoading`, and why a member nobody has seen yet is already decided:
* if it carries items it is an object or a function, and both refuse.
* Two earlier defaults are gone, and both are worth knowing because each read as obviously
* sound when it was written:
* 1. "a plain property carries no items" - true of the object that happened to be wrapped
* then, and false the moment the wrapped object changed, when three plain properties
* each carried every identity's items ({@link makeReadFilteredShapeResult});
* 2. "a primitive cannot reach an item, so it passes" - false because a COUNT is a
* primitive that DESCRIBES the items. The file already contradicted itself on this:
* `size` and `length` were computed, so a third spelling of the same number went
* through raw.
*
* 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 - so the default cannot be a type at all. What
* makes the current one safe for a shape nobody has seen is not that it classifies
* correctly, but that it refuses: a member the next version adds says so, loudly, naming
* itself.
*/
// @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 {
@@ -452,6 +478,13 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
* call this, so neither can answer what the other refuses. */
const read = (target: object, prop: string | symbol): unknown => {
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
// HOW THE LANGUAGE NAMES THIS VIEW. Computed from the shape wrapped, never forwarded:
// the specification closes the KEY, not the value, so a tag implemented as a getter
// returning `Set(${size})` would have published the unfiltered count. Answered only
// when the target carries one, so the view grows no member the collection lacks -
// measured, a `DeepSignalSet` carries the constant "Set" and a `readOnlyArray` none.
if (prop === Symbol.toStringTag && prop in target) return arrayLike ? "Array" : "Set";
if (prop === Symbol.isConcatSpreadable && prop in target) return arrayLike;
// 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) {
@@ -515,10 +548,17 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
if (prop === "drop") return (n: number) => kept(target).slice(n);
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
if (prop === "reduce") {
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
init === undefined
? kept(target).reduce(fn as never)
: kept(target).reduce(fn as never, init);
// On ARITY, not on `init === undefined`. Branching on the value made an EXPLICIT
// `reduce(fn, undefined)` look like the one-argument form: an owner with no readable
// items got "reduce of empty array with no initial value" where a Set or an Array
// answers `undefined`, and with one item got that item back without `fn` ever being
// called. Measured on both counts.
return function (this: unknown, ...args: unknown[]): unknown {
const fn = args[0] as never;
return args.length > 1
? kept(target).reduce(fn, args[1])
: kept(target).reduce(fn);
};
}
if (prop === "getById" || prop === "getBy") {
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
@@ -705,13 +745,14 @@ function filteredPromise(value: PromiseLike<unknown>, caps: CapRegistry): Promis
* object cannot be both, so there are two views and this is the one `surface/use-shape.ts`
* applies.
*
* ── What each member answers, and why nothing here names `isLoading` ──────
* ── What each member answers ──────────────────────────────────────────────
* The three that carry the items are named and filtered; the two that command a page are
* named and forwarded, because they return `void` and load rows into a collection that is
* only ever seen through the filter. `isLoading` is NOT named: it is a boolean, and the
* default already passes anything that cannot reach an item. That is the whole point of
* deciding by type - a member added by a later version is already decided, and a name list
* would have been another thing to get right on the next bump.
* only ever seen through the filter; `isLoading` is named and served, because it describes
* the REQUEST rather than the items. All six of `alpha.21`'s members are therefore decided
* here, and a seventh that a later version adds refuses until someone decides it - which is
* the point, and the reason this list is not the "name list" the earlier type-based default
* was written to avoid. That default passed unknowns; this one refuses them.
*
* `subscription` is REFUSED rather than wrapped, and the reason is this surface's own: it
* is the SDK's `RdfOrmSubscription`, which this package deliberately does not publish
+6 -3
View File
@@ -3,9 +3,12 @@
* arguments. Once the cap emulation is in force, what comes back is a read-filtered VIEW
* over the RESULT ENVELOPE the hook returns - `data` and `promise` carry only items in
* documents the current holder has the `ReadCap` of, `subscription` is refused, and the
* page commands and the load state pass through. A member's DESCRIPTOR answers what
* reading it answers, so the spread and the inspections built on that call read through
* the view rather than round it. Before the first cap is issued the real
* page commands and the load state pass through. A member's DESCRIPTOR answers what reading
* it answers, so what is built on that call reads through the view rather than round it -
* which for the envelope means a SPREAD of the result THROWS on `subscription` rather than
* copying it out. What no trap reaches is the HOST's own introspection: `console.log` and a
* devtools inspector read the object behind the proxy and print every identity's items
* (measured on Bun/JSC). The contract says so; there is nothing here to fix. Before the first cap is issued the real
* result passes through unchanged. At migration the filtering disappears: the broker only
* delivers documents whose cap the wallet holds.
*
+111 -32
View File
@@ -253,43 +253,71 @@ test("the array form yields indices where a Set yields items", () => {
// 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.
// runtime anyway. A restatement drifts unless something reads the original, so this does.
//
// It asserts a PARTITION and not three filtered comparisons, which is what the earlier
// version got wrong: it checked that each restated list matched upstream minus the
// exceptions, which is satisfied by any number of exceptions. PROVED - a copy of it with
// `{copyWithin, slice}` as the exception set and `slice` dropped from the served list ran
// 1 pass / 0 fail, so nothing stopped a second exception from silently removing
// `data.slice()` from the owner. Now upstream's list must equal the union exactly, the
// exception set must be exactly the one member named, and the three parts must be
// disjoint - so dropping a member goes red, and smuggling one into the exceptions goes red
// on a different assertion. The SYMBOL entries are read too, because the old regex saw
// only quoted strings and upstream declares four keys as symbols.
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[] => {
/** One `new Set([...])` literal, by its declared name: its quoted members and its
* `Symbol.x` members, deduplicated the way `new Set` deduplicates them. */
const literal = (name: string): { strings: string[]; symbols: 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();
return {
strings: [...new Set([...body.matchAll(/"([^"]+)"/g)].map((m) => m[1]!))].sort(),
symbols: [...new Set([...body.matchAll(/Symbol\.(\w+)/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.
// ...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());
const sorted = (...sets: ReadonlySet<string>[]): string[] =>
[...new Set(sets.flatMap((x) => [...x]))].sort();
// (a) the iterator helpers are restated exactly.
const iterator = literal("iteratorFnKeys");
expect(iterator.strings).toEqual([...ITERATOR_HELPERS].sort());
// (b) upstream's EFFECTIVE array set is exactly the union of what is served, the helpers
// and the exception - so a served member that goes missing has nowhere to hide.
// "Effective" because upstream declares these lists as a literal `.union(iteratorFnKeys)`,
// so three helpers (`drop`, `take`, `toArray`) are in the set without being in the
// literal; comparing against the literal alone would have failed for that reason and not
// for a real divergence.
const array = literal("nonMutatingArrayFnKeys");
const effectiveArray = [...new Set([...array.strings, ...iterator.strings])].sort();
expect(effectiveArray).toEqual(
sorted(NON_MUTATING_ARRAY_ONLY, ITERATOR_HELPERS, UPSTREAM_MISLABELLED_AS_NON_MUTATING),
);
// (c) and the three parts are disjoint, so a member cannot be counted in two of them.
expect(sorted(NON_MUTATING_ARRAY_ONLY, ITERATOR_HELPERS, UPSTREAM_MISLABELLED_AS_NON_MUTATING).length)
.toBe(NON_MUTATING_ARRAY_ONLY.size + ITERATOR_HELPERS.size + UPSTREAM_MISLABELLED_AS_NON_MUTATING.size);
// (d) the same partition for the set list, which has no exception of its own.
const set = literal("nonMutatingSetFnKeys");
const effectiveSet = [...new Set([...set.strings, ...iterator.strings])].sort();
expect(effectiveSet).toEqual(sorted(NON_MUTATING_SET_ONLY, ITERATOR_HELPERS));
// (e) THE EXCEPTION IS EXACTLY ONE MEMBER, NAMED. Without this a second one could be
// added to the set and every comparison above would still pass.
expect([...UPSTREAM_MISLABELLED_AS_NON_MUTATING]).toEqual(["copyWithin"]);
expect(array.strings).toContain("copyWithin");
// (f) the SYMBOL entries, invisible to the old string-only parse. A new one appearing is
// a member this view has not decided what to do with, so it must go red here.
expect(iterator.symbols).toEqual(["asyncIterator", "iterator"]);
expect(array.symbols).toEqual(["toString", "toStringTag"]);
expect(set.symbols).toEqual(["toString", "toStringTag"]);
});
// 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
@@ -324,10 +352,11 @@ 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` 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.
// `copyWithin` is the one member upstream MISLABELS as non-mutating: it writes. Refusing
// it is MORE protective than the target rather than equal to it - measured, the real
// `readOnlyArray` runs it and destroys its first element, because its own guard is built
// from the same mislabelled list. Serving it over the snapshot made it succeed and
// discard in silence, which taught a caller that a write had worked.
expect(() => (ordered as unknown as { copyWithin(a: number, b: number): unknown })
.copyWithin(0, 1)).toThrow();
expect([...ordered]).toEqual([MINE, LINKED]);
@@ -415,3 +444,53 @@ test("a member this file has not decided is refused, primitive or not", () => {
// than by any package, so no version bump adds to them.
expect(Object.prototype.toString.call(view)).toBe("[object Set]");
});
// REGRESSION (fifth adversarial pass, 2026-09-24). Two carved keys used to forward the
// TARGET's value on the ground that the ECMAScript specification closes that key set. It
// closes the KEYS, not the values: a tag implemented as a getter is free to return
// `Set(${size})`, and the carve-out would have published the unfiltered count through a key
// this file had called harmless. They are computed from the shape wrapped now.
test("the language's own tag is computed, not forwarded from the collection", () => {
const hostile = new Set<Item>([MINE, FOREIGN, FOREIGN]) as Set<Item>;
Object.defineProperty(hostile, Symbol.toStringTag, {
get(this: Set<Item>) {
return `Set(${this.size})`;
},
configurable: true,
});
const { caps } = setup("alice"); // holds MINE only, so one of three is readable
const view = makeReadFilteredView(hostile, caps);
expect(view.size).toBe(1);
expect(Object.prototype.toString.call(view)).toBe("[object Set]");
expect(String(Object.prototype.toString.call(view))).not.toContain("3");
// and the array form is tagged as one only if the collection is tagged at all.
const ordered = makeReadFilteredView([MINE, FOREIGN], caps);
expect(Object.prototype.toString.call(ordered)).toBe("[object Array]");
});
// REGRESSION (fifth adversarial pass). `reduce` branched on `init === undefined` instead of
// on ARITY, so an EXPLICIT `reduce(fn, undefined)` was read as the one-argument form: an
// owner with no readable items got "reduce of empty array with no initial value" where a Set
// or an Array answers `undefined`, and with one item got that item back without `fn` ever
// running. Both measured against the native behaviour they must match.
test("reduce follows the native arity, not the value of the accumulator", () => {
const { caps, become } = setup("alice");
const view = makeReadFilteredView(new Set<Item>([MINE, FOREIGN]), caps) as Set<Item> & {
reduce(fn: (acc: unknown, v: Item) => unknown, init?: unknown): unknown;
};
// One readable item, an EXPLICIT undefined accumulator: `fn` runs once, as on an array.
let calls = 0;
expect(view.reduce((acc) => {
calls += 1;
return acc;
}, undefined)).toBeUndefined();
expect(calls).toBe(1);
expect([MINE].reduce((acc: unknown) => acc, undefined)).toBeUndefined(); // the native answer
// No readable items, an explicit undefined accumulator: `undefined`, not a TypeError.
become("bob");
expect(view.reduce((acc) => acc, undefined)).toBeUndefined();
// and the one-argument form on an empty view still throws, exactly as an array does.
expect(() => view.reduce((acc) => acc)).toThrow(/empty/i);
});