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:
Sylvain Duchesne
2026-09-24 20:10:13 +02:00
parent 7a37e44ac0
commit a4ec2546bf
4 changed files with 265 additions and 74 deletions
+74 -14
View File
@@ -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);
});