eb956c880d
L-ASYMETRIE, une fois de plus, et c-est elle le defaut. `delete` et `clear` interrogeaient `keep`, `add` non - ce qui rendait la phrase publiee vraie par coincidence : le `add` non garde FABRIQUAIT l-etat que les deux autres ne traitaient qu-a moitie. Bob plante un objet dans le document d-ALICE, ne le revoit jamais (la vue le refiltre), la liaison bidirectionnelle le commite la-bas, puis `clear()` le laisse derriere. Les trois posent maintenant la meme question ; ce qu-elles repondent differe parce que les membres different : `delete` a `false`, qui est le mot du contrat Set pour "il n-y etait pas" et qui est vrai de la vue ; `add` n-a aucune valeur de ce sens, donc il leve. LA VUE PORTE SES PROPRES ECRITURES. Aucun piege `set`/`deleteProperty`/ `defineProperty` : contre un tableau ordonne, `view[0] = x`, `delete view[0]` et `view.length = 0` passaient - le dernier defaisant `clear` entierement. Ce qui est livre est un `readOnlyArray` qui les refuse, donc la production etait protegee PAR LA CIBLE : la vue s-appuyait sur une garantie qu-elle ne donne pas, sur une forme qu-elle ne controle pas. C-est l-hypothese empruntee qui est derriere chaque defaut de cette famille. LE PROPRIETAIRE EST SERVI. 18 membres declares d-un resultat ordonne levaient pour celui dont ce sont les donnees, plus `includes` et les sept combinateurs d-ensemble : une fuite echangee contre une panne. Tout ce que `@ng-org/alien-deepsignals` declare non mutant est servi sur les objets lisibles, et un test relit le fichier livre pour que la liste restee ici ne derive pas. Aussi : le descripteur d-un ACCESSEUR routait vers la cible ; le faux construit maintenant les VRAIES collections (`deepSignal`, `readOnlyArray`) au lieu d-un Set et d-un tableau nus, ce qui le rendait plus permissif que ce qui est livre apres l-avoir rendu plus correct au round precedent.
486 lines
22 KiB
TypeScript
486 lines
22 KiB
TypeScript
/**
|
|
* The published `useShape` - what an identity may see of what the SDK handed back.
|
|
*
|
|
* -- Why this file exists, and what it is the answer to --------------------
|
|
* One shared wallet hosts every identity, so the hook's own answer is every identity's
|
|
* objects and the capability filter is the whole of the isolation. Until 2026-09-24 that
|
|
* filter was written for the collection `@ng-org/orm@0.1.2-alpha.18` returned, while the
|
|
* version this package builds against - `alpha.21` - returns a `UseShapeResult` ENVELOPE
|
|
* around it. Three plain members of that envelope (`data`, `promise`, `subscription`) each
|
|
* carried the items and each came back RAW, and no test in this repository could see it,
|
|
* because no fake returned the shape the SDK ships. `test/use-shape-fake.ts` now does, read
|
|
* off the installed package's own `.d.ts`, and this file is what it made possible.
|
|
*
|
|
* Every document below is OBTAINED through the published placement call under the identity
|
|
* that owns it - never named by the test - so "hers" and "his" are facts of the system.
|
|
* Nothing hands a `ReadCap` across the boundary: the stranger holds what a stranger holds.
|
|
*/
|
|
import { test, expect, afterEach } from "bun:test";
|
|
import { configure, storeRegistry, useShape } from "../src/index";
|
|
import {
|
|
configureStoreRegistry,
|
|
resetCaps,
|
|
resetConfig,
|
|
resetStoreRegistry,
|
|
setCurrentUser,
|
|
} from "../src/shared-wallet/bootstrap";
|
|
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
|
|
import { makeWallet, SESSION, type Quad } from "./wallet-fake";
|
|
import { makeUseShapeHook } from "./use-shape-fake";
|
|
import type { FakeShapeHook } from "./use-shape-fake";
|
|
import type { NgLike, Nuri, ShapeResult, UseShapeLike } from "../src/model/types";
|
|
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
|
// TYPE-only: no `@ng-org/orm` module is loaded at runtime here. What this line checks is
|
|
// the claim the injection type MAKES - that the SDK's OWN React hook is assignable to
|
|
// `UseShapeLike` as it stands, with no wrapper and no cast, so `configure({ useShape })`
|
|
// is the whole of what a caller writes. It is also what keeps that type honest: it was
|
|
// `(...args: any[]) => any`, which no shape could ever contradict.
|
|
import type { reactUseShape } from "@ng-org/orm/all";
|
|
type _TheSdkHookFits = typeof reactUseShape extends UseShapeLike ? true : never;
|
|
const _sdkFits: _TheSdkHookFits = true;
|
|
void _sdkFits;
|
|
|
|
const NoteShape = {
|
|
shape: "urn:use-shape-test:NoteShape",
|
|
schema: {
|
|
"urn:use-shape-test:NoteShape": {
|
|
iri: "urn:use-shape-test:NoteShape",
|
|
predicates: [
|
|
{
|
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
|
readablePredicate: "@type",
|
|
dataTypes: [{ valType: "iri", literals: ["urn:use-shape-test:Note"] }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} satisfies ShapeType<BaseType>;
|
|
|
|
/** Everything the local dataset holds - the scope under which this filter is the only
|
|
* thing between the caller and every identity's objects. */
|
|
const EVERYTHING = { graphs: "did:ng:i" };
|
|
|
|
function inject(hook: FakeShapeHook): void {
|
|
const quads: Quad[] = [];
|
|
const ng: NgLike = makeWallet(quads);
|
|
configure({ ng, useShape: hook.hook });
|
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
|
resetRegistryCache();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
}
|
|
|
|
/** A document that really belongs to `who` - created by them, through the published call,
|
|
* which is also what files its cap in their keyring. */
|
|
async function documentOf(who: string): Promise<Nuri> {
|
|
setCurrentUser(who);
|
|
return storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
|
}
|
|
|
|
/** One ORM object placed in `doc`, as the ORM itself carries it. */
|
|
function noteIn(doc: Nuri, id: string): BaseType {
|
|
return { "@id": `${doc}:q:${id}`, "@graph": doc } as BaseType;
|
|
}
|
|
|
|
afterEach(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
// -- THE RIGHTS CASE: the stranger ------------------------------------------
|
|
|
|
// The defect, stated as a test. Each of the three members below is one the `alpha.21`
|
|
// declaration gives the items to, and each used to hand back the unfiltered collection.
|
|
test("useShape: no member of the result reaches a document this identity holds no cap for", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
|
|
// `data` - what the SDK's own example destructures first.
|
|
expect([...(result.data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
expect((result.data as { size: number }).size).toBe(1);
|
|
// `promise` - the same collection, one await away.
|
|
expect([...((await result.promise) as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
// `subscription` - the SDK's own handle, which reads and writes with no filter at all.
|
|
expect(() => result.subscription).toThrow(/RdfOrmSubscription/);
|
|
|
|
// and the collection underneath really does hold both, so it is the filter that
|
|
// removed one rather than the fake never having offered it.
|
|
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2);
|
|
});
|
|
|
|
// Iterating is not the only way in, and the members a reactive set puts forward are the
|
|
// ones a consumer reaches for first. Same rule as the collection view's own regression.
|
|
test("useShape: the item-yielding members of `data` are filtered too, not just iteration", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
const foreign = noteIn(hers, "a1");
|
|
hook.items.push(foreign, noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const data = useShape(NoteShape, EVERYTHING).data as {
|
|
values(): Iterable<BaseType>;
|
|
forEach(cb: (v: BaseType) => void): void;
|
|
has(v: BaseType): boolean;
|
|
};
|
|
expect([...data.values()].map((o) => o["@graph"])).toEqual([his]);
|
|
const seen: string[] = [];
|
|
data.forEach((o) => seen.push(o["@graph"]));
|
|
expect(seen).toEqual([his]);
|
|
expect(data.has(foreign)).toBe(false);
|
|
});
|
|
|
|
// -- THE OTHER DIRECTION: the holder is served ------------------------------
|
|
|
|
// A repair that closes a leak by making the call unusable has traded one defect for
|
|
// another. Every member the declaration offers a holder is exercised here on their OWN
|
|
// data, and the two page commands with it.
|
|
test("useShape: the holder reads their own items through every member the SDK offers", async () => {
|
|
const hook = makeUseShapeHook({ paginated: true });
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(hers, "a2"));
|
|
|
|
setCurrentUser("alice");
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
|
|
expect([...(result.data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
|
|
expect((result.data as { size: number }).size).toBe(2);
|
|
expect([...((await result.promise) as Iterable<BaseType>)]).toHaveLength(2);
|
|
// The load state is a boolean and passes on its own, with no entry naming it. It reads
|
|
// `true` on a payload that HAS arrived, because the shipped hook computes it as
|
|
// `ormSubscription.isReady` while the declaration beside it says the opposite. That
|
|
// inversion is upstream's; the fake reproduces it rather than correcting it, and this
|
|
// assertion is what stops anyone here quietly making the target nicer than it is.
|
|
expect(result.isLoading).toBe(true);
|
|
// The page commands forward: they return `void`, and what they load lands in a
|
|
// collection only ever read back through the filter.
|
|
result.nextPage?.();
|
|
result.previousPage?.();
|
|
expect(hook.pages).toEqual(["next", "previous"]);
|
|
// Reading `data` twice hands back the same view, so a caller may put it in a
|
|
// dependency array without it looking like new data on every read.
|
|
expect(result.data).toBe(result.data);
|
|
});
|
|
|
|
// Before the first cap exists the emulation is not in force, exactly as `getObjects` and
|
|
// `useShapeQuery` behave: a consumer that never touches caps keeps reading everything,
|
|
// and gets the SDK's own object back untouched.
|
|
test("useShape: passthrough while no cap has been issued", () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
hook.items.push(noteIn("did:ng:o:someone" as Nuri, "x"));
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
expect([...(result.data as Iterable<BaseType>)]).toHaveLength(1);
|
|
expect(result.subscription).not.toBeUndefined();
|
|
});
|
|
|
|
// Upstream: "If the conf is explicitly set to `undefined`, the returned data is
|
|
// `undefined` and `isLoading` is false". Nothing was opened, so there is nothing to
|
|
// refuse, and `subscription` answers the SDK's own value rather than throwing.
|
|
test("useShape: an undefined config answers undefined rather than refusing", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
await documentOf("alice");
|
|
setCurrentUser("alice");
|
|
const result: ShapeResult = useShape(NoteShape, undefined);
|
|
expect(result.data).toBeUndefined();
|
|
expect(result.promise).toBeUndefined();
|
|
expect(result.subscription).toBeUndefined();
|
|
expect(result.isLoading).toBe(false);
|
|
});
|
|
|
|
// -- A MUTATION MAY NOT REACH PAST WHAT THE VIEW SHOWS ---------------------
|
|
|
|
// THE RIGHTS CASE, second kind, and it is the member the SDK's own example reaches for
|
|
// first (`expenses.add({...})`). `Set.prototype.add` RETURNS THE COLLECTION so calls can
|
|
// be chained - measured, not assumed - so a mutation forwarded bound to the target handed
|
|
// the unfiltered collection back as its return value. The view filtered every way IN and
|
|
// gave the whole set away on the way OUT.
|
|
test("useShape: adding an item does not hand back the unfiltered collection", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const data = useShape(NoteShape, EVERYTHING).data as {
|
|
add(item: BaseType): unknown;
|
|
[Symbol.iterator](): Iterator<BaseType>;
|
|
};
|
|
expect([...data].map((o) => o["@graph"])).toEqual([his]); // the way in is filtered
|
|
|
|
const handedBack = data.add(noteIn(his, "b2"));
|
|
expect(handedBack).not.toBe(hook.raw());
|
|
// Chaining still works, and what it chains onto is the VIEW: still bob's documents only.
|
|
expect([...(handedBack as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his, his]);
|
|
// and the write really did reach the collection underneath.
|
|
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(3);
|
|
});
|
|
|
|
// `has(item)` has always refused to answer about an item the holder may not read. Acting
|
|
// on one is the same question with a worse consequence, and the two disagreed: `has` said
|
|
// no while `delete` removed it.
|
|
test("useShape: deleting reaches only what the view shows", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
const hersItem = noteIn(hers, "a1");
|
|
hook.items.push(hersItem, noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const data = useShape(NoteShape, EVERYTHING).data as {
|
|
delete(item: BaseType): boolean;
|
|
has(item: BaseType): boolean;
|
|
clear(): void;
|
|
};
|
|
expect(data.has(hersItem)).toBe(false);
|
|
expect(data.delete(hersItem)).toBe(false);
|
|
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2); // hers survives
|
|
|
|
// and emptying the view empties the view, not the collection every identity shares.
|
|
data.clear();
|
|
expect([...(hook.raw() as Iterable<BaseType>)].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);
|
|
});
|
|
|
|
// THE THIRD MUTATION, and the one that was ungated. `delete` and `clear` asked `keep` and
|
|
// `add` did not, which made the published rule true by coincidence: the ungated `add`
|
|
// MANUFACTURED the state the other two then handled partially. Bob plants an object into
|
|
// ALICE's document, never sees it again - the view filters it straight back out - and the
|
|
// ORM's two-way binding commits it there. Then `clear()` leaves it behind.
|
|
//
|
|
// Each of the three is run here against a document this identity does not hold, and the
|
|
// collection underneath is checked afterwards. That is the contract's mutation sentence,
|
|
// clause by clause, executed rather than read.
|
|
test("useShape: none of the three mutations reaches a document this identity holds no cap for", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
const hersItem = noteIn(hers, "a1");
|
|
hook.items.push(hersItem, noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const data = useShape(NoteShape, EVERYTHING).data as {
|
|
add(item: BaseType): unknown;
|
|
delete(item: BaseType): boolean;
|
|
clear(): void;
|
|
has(item: BaseType): boolean;
|
|
};
|
|
|
|
// add - refuses, because it has no return value meaning "it did not happen".
|
|
expect(() => data.add(noteIn(hers, "PLANT"))).toThrow(/does not hold the cap/i);
|
|
// delete - answers `false`, which is the Set contract's own word for "not there" and is
|
|
// true of the view.
|
|
expect(data.has(hersItem)).toBe(false);
|
|
expect(data.delete(hersItem)).toBe(false);
|
|
// clear - empties the view, which holds none of hers.
|
|
data.clear();
|
|
|
|
// The collection underneath is exactly as it was, minus bob's own item.
|
|
expect([...(hook.raw() as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers]);
|
|
// And the holder's OWN write still goes through and still reaches the collection.
|
|
expect([...(data.add(noteIn(his, "b2")) as Iterable<BaseType>)].map((o) => o["@graph"]))
|
|
.toEqual([his]);
|
|
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2);
|
|
});
|
|
|
|
// A write to the collection itself is refused. Upstream's ordered `readOnlyArray` refuses
|
|
// these too, so here the two agree - which is the point of the fake now building the REAL
|
|
// collection kinds. That the VIEW refuses on its own account, whatever the target allows,
|
|
// is proved one floor down in `read-filter.test.ts` against a plain array, because relying
|
|
// on the target's guarantee is relying on something this view does not promise and does
|
|
// not control. `length = 0` in particular defeated the gated `clear` outright.
|
|
test("useShape: a write to the view itself is refused, whatever the collection underneath allows", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(hers, "a2"));
|
|
|
|
setCurrentUser("bob");
|
|
const data = useShape(NoteShape, { ...EVERYTHING, orderBy: { liveMark: "asc" } }).data as
|
|
Record<number, unknown> & { length: number };
|
|
expect(() => {
|
|
data[0] = { "@id": "SPY", "@graph": "did:ng:o:spy" };
|
|
}).toThrow(/not filtered/i);
|
|
expect(() => {
|
|
delete data[0];
|
|
}).toThrow(/not filtered/i);
|
|
expect(() => {
|
|
data.length = 0;
|
|
}).toThrow(/not filtered/i);
|
|
// The collection underneath is untouched.
|
|
expect([...(hook.raw() as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
|
|
});
|
|
|
|
// The descriptor trap handed an ACCESSOR back untouched, so the getter behind it - which
|
|
// reads the TARGET - was a door around the view. Not reachable on the shape that ships,
|
|
// where every member reports a data descriptor; reachable on the next one, which is what
|
|
// this view claims to be safe for.
|
|
test("useShape: an accessor member is read through the view, not around it", async () => {
|
|
const base = makeUseShapeHook();
|
|
const withGetter: FakeShapeHook = {
|
|
...base,
|
|
hook: ((shapeType, conf) => {
|
|
const built = base.hook(shapeType, conf);
|
|
const raw = base.raw();
|
|
return {
|
|
isLoading: built.isLoading,
|
|
promise: built.promise,
|
|
subscription: built.subscription,
|
|
get data(): unknown {
|
|
return raw;
|
|
},
|
|
};
|
|
}) as UseShapeLike,
|
|
};
|
|
inject(withGetter);
|
|
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);
|
|
const described = Object.getOwnPropertyDescriptor(result, "data");
|
|
expect(typeof described?.get).toBe("function");
|
|
const throughTheDescriptor = described?.get?.();
|
|
expect(throughTheDescriptor).not.toBe(base.raw());
|
|
expect([...(throughTheDescriptor as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
});
|
|
|
|
// -- 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. */
|
|
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 () => {
|
|
const base = makeUseShapeHook();
|
|
const later: FakeShapeHook = {
|
|
...base,
|
|
hook: ((shapeType, conf) => {
|
|
const result: LaterResult = {
|
|
...base.hook(shapeType, conf),
|
|
rows: base.raw(),
|
|
totalCount: 7,
|
|
sample: () => [...(base.raw() as Iterable<BaseType>)][0],
|
|
};
|
|
return result;
|
|
}) as UseShapeLike,
|
|
};
|
|
inject(later);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
base.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
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.
|
|
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.
|
|
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);
|
|
});
|
|
|
|
// An `orderBy` config makes the collection an ARRAY (upstream `SubscriptionData`), whose
|
|
// count is spelled `length` and is a number - so it used to pass as a harmless primitive
|
|
// while publishing how many items the holder may NOT read.
|
|
test("useShape: an ordered result does not publish the unfiltered count", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(his, "b2"));
|
|
|
|
setCurrentUser("alice");
|
|
const data = useShape(NoteShape, { ...EVERYTHING, orderBy: { liveMark: "asc" } }).data;
|
|
expect(Array.isArray(hook.raw())).toBe(true);
|
|
expect((hook.raw() as BaseType[]).length).toBe(3);
|
|
expect((data as { length: number }).length).toBe(1);
|
|
expect([...(data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers]);
|
|
// An index names a position in the READABLE items, so the holder reads their own by
|
|
// index and the positions past them do not exist at all. `Object.keys` and `in` have to
|
|
// agree with `length`, or the count comes back out through the other three.
|
|
expect((data as Record<number, BaseType>)[0]!["@graph"]).toBe(hers);
|
|
expect((data as Record<number, BaseType>)[1]).toBeUndefined();
|
|
expect(Object.keys(data as object)).toEqual(["0"]);
|
|
expect(1 in (data as object)).toBe(false);
|
|
expect(JSON.parse(JSON.stringify(data)) as BaseType[]).toHaveLength(1);
|
|
});
|
|
|
|
// REGRESSION, found by re-running the adversary on the first fix (2026-09-24).
|
|
// `Object.getOwnPropertyDescriptor` reads a property WITHOUT the `get` trap, so a view
|
|
// that filtered `get` alone handed the raw value straight back to whoever asked for the
|
|
// descriptor - including `subscription`, the one member `get` refuses outright. Everything
|
|
// built on it goes the same way: a spread, `Object.entries`, a console inspection.
|
|
test("useShape: the descriptor of a member answers exactly what reading it answers", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
|
|
const described = Object.getOwnPropertyDescriptor(result, "data");
|
|
expect(described?.value).toBe(result.data);
|
|
expect(described?.value).not.toBe(hook.raw());
|
|
expect([...(described?.value as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
expect(() => Object.getOwnPropertyDescriptor(result, "subscription")).toThrow(
|
|
/RdfOrmSubscription/,
|
|
);
|
|
});
|