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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user