fix: le filtre de lecture couvre l-enveloppe que le SDK renvoie vraiment
Le `useShape` publie enveloppait le resultat du hook dans le filtre ecrit pour une COLLECTION. Depuis que `@ng-org/orm` renvoie un `UseShapeResult` autour du set, `[...resultat]` et `.size` levaient, et `data`, `promise` et `subscription` revenaient BRUTS - chacun tous les objets de toutes les identites, sans que rien ne casse. Aucun faux de ce depot ne renvoyait la forme livree, donc aucune garde ne pouvait le voir. Deux vues au lieu d-une : celle de la collection, et celle de l-enveloppe que `surface/use-shape.ts` applique. `data` et `promise` sont filtres, `subscription` est refuse (c-est le handle de l-ORM, que cette surface ne publie pas), les deux commandes de page passent. Le defaut par TYPE et non par une liste de noms : un primitif ne peut atteindre aucun objet et passe, un objet ou une fonction refuse tant qu-il n-existe pas de forme filtree ici. C-est pourquoi `isLoading` n-est nomme nulle part, et pourquoi un membre qu-une version suivante ajoutera est deja tranche. `size` et `length` comptent ce que le porteur peut lire. `UseShapeLike` etait `(...args: any[]) => any`, ce qui est la raison pour laquelle les deux typechecks restaient a 0 pendant que la fuite etait ouverte : aucune declaration ne disait ce que le hook rend. Il rend `ShapeResult`, releve sur le `.d.ts` installe, et le vrai `reactUseShape` y est assignable sans cast. Le faux partage `test/use-shape-fake.ts` renvoie la forme livree et c-est celui que les deux gardes injectent - unitaire et e2e. La garantie du contrat et le test qui l-epingle arrivent ensemble ; `1.0.0-dev.11`, majeur.
This commit is contained in:
@@ -4,24 +4,31 @@
|
||||
* The 42-check harness injects the REAL `RdfOrmSubscription.getOrCreate` since
|
||||
* `1.0.0-dev.6` (that is what `useShapeQuery` reads through), so a live ORM read is no
|
||||
* longer unreachable from there. What stays here is the PUBLISHED `useShape` half: the
|
||||
* bare `DeepSignalSet` an application gets from the ORM hook, and a `.delete()` on it
|
||||
* reaching the document. That is a different surface from the snapshot `useShapeQuery`
|
||||
* publishes, and it has no other measurement.
|
||||
* collection an application reads off `data`, and a `.delete()` on it reaching the
|
||||
* document. That is a different surface from the snapshot `useShapeQuery` publishes, and
|
||||
* it has no other measurement.
|
||||
*
|
||||
* ── What the framework adapter contributes, and why it is not here ─────────
|
||||
* Upstream's `useShape` is React's (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`):
|
||||
* it calls `OrmSubscription.getOrCreate(shape, scope)`, hands `signalObject` to
|
||||
* `useDeepSignal`, and closes the subscription on unmount. The broker sees the first of
|
||||
* those three and nothing else — `useDeepSignal` is re-render bookkeeping. So the hook
|
||||
* injected below IS `getOrCreate(...).signalObject`, which is what a React render reaches
|
||||
* the broker with, without a React tree to mount.
|
||||
* injected below builds the SAME result object the shipped adapter builds, out of the same
|
||||
* `getOrCreate(...)`, which is what a React render reaches the broker with, without a React
|
||||
* tree to mount. What it must NOT do is return a substitute for that object: it did until
|
||||
* 2026-09-24, and that is the reason a capability filter which handed three of that
|
||||
* object's members back unfiltered could not fail anything here.
|
||||
*/
|
||||
|
||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||
import { initNg as ormInitNg, RdfOrmSubscription, type Scope as OrmScope } from "@ng-org/orm";
|
||||
import { initNg as ormInitNg, normalizeConf, RdfOrmSubscription, type Scope as OrmScope } from "@ng-org/orm";
|
||||
import type { RdfOrmConfig } from "@ng-org/orm/all";
|
||||
import { configure, docs, init, useShape as libUseShape } from "@ng-eventually/polyfill";
|
||||
import type { BaseType, Nuri, ShapeType } from "@ng-eventually/polyfill";
|
||||
import { adoptCurrentUser, configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
// The result envelope the published `useShape` hands back. Internal path, like every
|
||||
// other piece of machinery this page reaches for: an application never writes it down.
|
||||
import type { ShapeResult } from "../src/model/types";
|
||||
|
||||
// ── the shape under measurement ─────────────────────────────────────────────
|
||||
|
||||
@@ -94,14 +101,66 @@ const sessionReady = new Promise<BrokerSession>((r) => (sessionResolve = r));
|
||||
* next time it moves; `ReturnType` asks the package what it actually hands back.
|
||||
*/
|
||||
type LiveSubscription = ReturnType<
|
||||
typeof RdfOrmSubscription.getOrCreate<ShapeType<BaseType>, BaseType, OrmScope>
|
||||
typeof RdfOrmSubscription.getOrCreate<
|
||||
ShapeType<BaseType>,
|
||||
BaseType,
|
||||
ReturnType<typeof normalizeConf<BaseType>>
|
||||
>
|
||||
>;
|
||||
|
||||
let liveSubscription: LiveSubscription | null = null;
|
||||
function realOrmUseShape(shapeType: unknown, scope: unknown): unknown {
|
||||
const sub = RdfOrmSubscription.getOrCreate(shapeType as ShapeType<BaseType>, scope as OrmScope);
|
||||
|
||||
/**
|
||||
* The injected hook, returning the SHIPPED shape rather than a substitute for it.
|
||||
*
|
||||
* Until 2026-09-24 this returned `sub.signalObject` - a bare collection, which is what the
|
||||
* hook returned three releases ago and is NOT what the installed one returns. That is why
|
||||
* the capability filter could hand three members of the real result back unfiltered and
|
||||
* still pass every gate in this repository: no fake anywhere returned the shape the SDK
|
||||
* ships, so no test could fail on it.
|
||||
*
|
||||
* What it returns now is the envelope the installed
|
||||
* `dist/frontendAdapters/react/useShape.js` builds, expression for expression, minus the
|
||||
* two pieces that are React's and only React's: `useDeepSignal` (re-render bookkeeping
|
||||
* over the same `signalObject`) and the `useState` that flips once `readyPromise` resolves
|
||||
* - here the subscription's own `isReady` answers the same question. `isLoading` is
|
||||
* upstream's own expression and is reproduced as written, including the fact that it
|
||||
* CONTRADICTS its own documentation, which says `true` while no data is available yet: a
|
||||
* fake that quietly fixed the target would be measuring something nobody ships.
|
||||
*
|
||||
* The two page commands are `undefined`, which is what the shipped hook sets them to
|
||||
* outside a paginated mode - this read asks for no `orderBy`, so there is no page to turn.
|
||||
*/
|
||||
function realOrmUseShape(
|
||||
shapeType: ShapeType<BaseType>,
|
||||
conf: RdfOrmConfig<BaseType> | string | undefined,
|
||||
): ShapeResult {
|
||||
// Upstream: an undefined config opens nothing, and the three members that would carry
|
||||
// the data are `undefined` with `isLoading` false.
|
||||
if (conf === undefined) {
|
||||
liveSubscription = null;
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
promise: undefined,
|
||||
subscription: undefined,
|
||||
nextPage: undefined,
|
||||
previousPage: undefined,
|
||||
};
|
||||
}
|
||||
// `normalizeConf` is the SDK's OWN, imported rather than restated: it is the line the
|
||||
// shipped hook runs before `getOrCreate`, and a hand-written stand-in for it is the
|
||||
// class of substitute this whole file exists to stop making.
|
||||
const sub = RdfOrmSubscription.getOrCreate(shapeType, normalizeConf(conf));
|
||||
liveSubscription = sub;
|
||||
return sub.signalObject;
|
||||
return {
|
||||
data: sub.isReady ? sub.signalObject : undefined,
|
||||
isLoading: sub.isReady,
|
||||
promise: sub.readyPromise,
|
||||
subscription: sub,
|
||||
nextPage: undefined,
|
||||
previousPage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
configure({
|
||||
@@ -132,14 +191,30 @@ const state: { status: string; error?: string } = { status: "connecting" };
|
||||
interface LiveProbe {
|
||||
doc: Nuri;
|
||||
subject: string;
|
||||
/** What the published `useShape` returned — a Set-like of the matched objects. */
|
||||
view: Set<BaseType>;
|
||||
startError: string | null;
|
||||
readyMs: number | null;
|
||||
deleteError: string | null;
|
||||
}
|
||||
let probe: LiveProbe | null = null;
|
||||
|
||||
/**
|
||||
* The collection the published `useShape` hands back RIGHT NOW, by calling it again.
|
||||
*
|
||||
* Calling again is what a React render does, and it is what the result envelope requires:
|
||||
* `data` is `undefined` until the initial payload has arrived, and the envelope captured
|
||||
* at the first call cannot learn that it has - which is why the shipped hook re-runs on a
|
||||
* `useState` flip. `getOrCreate` pools on the shape and the scope, so asking again is the
|
||||
* same subscription and costs nothing.
|
||||
*
|
||||
* This is also the one place this page reads the published surface the way an application
|
||||
* does: through `data`, the member the SDK's own example destructures first.
|
||||
*/
|
||||
function liveView(): Set<BaseType> | undefined {
|
||||
if (probe === null) return undefined;
|
||||
const data = libUseShape(LIVE_SHAPE, { graphs: [probe.doc] }).data;
|
||||
return data === undefined ? undefined : (data as Set<BaseType>);
|
||||
}
|
||||
|
||||
/** Count the triples the document still holds for the seeded subject. */
|
||||
async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
|
||||
const s = await sessionReady;
|
||||
@@ -190,16 +265,15 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
|
||||
ormInitNg(realNg, s as unknown as Parameters<typeof ormInitNg>[1]);
|
||||
|
||||
let startError: string | null = null;
|
||||
let view: Set<BaseType>;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
view = libUseShape(LIVE_SHAPE, { graphs: [doc] }) as Set<BaseType>;
|
||||
libUseShape(LIVE_SHAPE, { graphs: [doc] });
|
||||
} catch (e) {
|
||||
startError = e instanceof Error ? e.message : String(e);
|
||||
probe = { doc, subject, view: new Set(), startError, readyMs: null, deleteError: null };
|
||||
probe = { doc, subject, startError, readyMs: null, deleteError: null };
|
||||
return { doc, subject, seeded, startError };
|
||||
}
|
||||
probe = { doc, subject, view, startError: null, readyMs: null, deleteError: null };
|
||||
probe = { doc, subject, startError: null, readyMs: null, deleteError: null };
|
||||
// Un-awaited on purpose: a refused start never resolves this, and the runner's own
|
||||
// bound is what turns that silence into a verdict.
|
||||
void liveSubscription?.readyPromise.then(() => {
|
||||
@@ -209,17 +283,15 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
|
||||
},
|
||||
|
||||
/** How many objects the published `useShape` view holds right now. */
|
||||
liveCount: () => (probe ? probe.view.size : -1),
|
||||
liveCount: () => liveView()?.size ?? -1,
|
||||
/** Milliseconds from the `useShape` call to `readyPromise`, or null while it is pending. */
|
||||
liveReadyMs: () => probe?.readyMs ?? null,
|
||||
liveItems: () =>
|
||||
probe
|
||||
? [...probe.view].map((i) => ({
|
||||
id: i["@id"] as string,
|
||||
graph: i["@graph"] as string,
|
||||
mark: i.liveMark as string,
|
||||
}))
|
||||
: [],
|
||||
[...(liveView() ?? [])].map((i) => ({
|
||||
id: i["@id"] as string,
|
||||
graph: i["@graph"] as string,
|
||||
mark: i.liveMark as string,
|
||||
})),
|
||||
|
||||
/**
|
||||
* Delete the seeded object through the set the hook returned — the gesture the consuming
|
||||
@@ -227,10 +299,12 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
|
||||
*/
|
||||
liveDelete() {
|
||||
if (!probe) return { deleted: false, error: "no probe" };
|
||||
const item = [...probe.view][0];
|
||||
const view = liveView();
|
||||
if (view === undefined) return { deleted: false, error: "no data yet" };
|
||||
const item = [...view][0];
|
||||
if (item === undefined) return { deleted: false, error: "view is empty" };
|
||||
try {
|
||||
const deleted = probe.view.delete(item);
|
||||
const deleted = view.delete(item);
|
||||
return { deleted, error: null };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* returns reach the document?
|
||||
*
|
||||
* Standalone, and deliberately outside `e2e/run.ts`: what it measures is the PUBLISHED
|
||||
* `useShape` - the bare `DeepSignalSet` the ORM hook hands an application, and a
|
||||
* `.delete()` on it reaching the document. `e2e/run.ts` exercises the other shape read,
|
||||
* `useShape` - the collection an application reads off the `data` member of what the ORM
|
||||
* hook hands it, and a `.delete()` on that collection reaching the document. `e2e/run.ts` exercises the other shape read,
|
||||
* `useShapeQuery`, which since `1.0.0-dev.6` reads through the SDK's shape query too. Two
|
||||
* surfaces, two runs.
|
||||
*
|
||||
@@ -93,7 +93,7 @@ const DELETE_MS = 30_000;
|
||||
*/
|
||||
const CHECKS: readonly string[] = [
|
||||
"the document accepted the seed (two triples for one subject)",
|
||||
"the live read STARTS: `useShape` returns a set and the subscription reports ready",
|
||||
"the live read STARTS: `useShape` answers and the subscription reports ready",
|
||||
"the initial payload carries the seeded object, with its `@id` and `@graph`",
|
||||
"`.delete()` on that set is accepted",
|
||||
"the deletion reached the DOCUMENT: the seeded subject's triples are gone",
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
// application must not — but through the internal path, never the published entry.
|
||||
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
||||
import * as registryInternals from "../src/shared-wallet/account-registry";
|
||||
// The shared `useShape` fake, the same one `bun test` injects. Type-only imports
|
||||
// inside it, so nothing of `bun:test` reaches this bundle.
|
||||
import { makeUseShapeHook } from "../test/use-shape-fake";
|
||||
// The harness plays SEVERAL identities on one page — something no application does, and
|
||||
// the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It
|
||||
// reaches them by their internal path, like the rest of its machinery.
|
||||
@@ -188,26 +191,18 @@ let session: BrokerSession | null = null;
|
||||
let sessionResolve!: (s: BrokerSession) => void;
|
||||
const sessionReady = new Promise<BrokerSession>((r) => (sessionResolve = r));
|
||||
|
||||
// A minimal in-memory Set-like, so the lib's read-filtered `useShape` view can be
|
||||
// exercised WITHOUT the real ORM (`@ng-org/orm` is not installed here, and the
|
||||
// read-filter is pure — it wraps whatever Set-like the injected useShape returns).
|
||||
// The injected useShape receives `(shapeType, scope)`; we ignore both and return
|
||||
// the items array captured by the test through window.__sdk.caps.seedSet().
|
||||
let injectedSetItems: any[] = [];
|
||||
function fakeUseShape(_shape: unknown, _scope: unknown): any {
|
||||
const items = () => injectedSetItems;
|
||||
return {
|
||||
get size() { return items().length; },
|
||||
[Symbol.iterator]() { return items()[Symbol.iterator](); },
|
||||
forEach(cb: (v: unknown) => void) { items().forEach(cb); },
|
||||
add(item: any) { injectedSetItems.push(item); },
|
||||
};
|
||||
}
|
||||
// The injected `useShape`, so the lib's read-filtered view can be exercised WITHOUT a
|
||||
// React tree. It is `test/use-shape-fake.ts` — the ONE fake in this repository that
|
||||
// returns the shape `@ng-org/orm`'s hook really ships, read off the installed package's
|
||||
// own `.d.ts`. This page used to carry a hand-written Set-like of its own, which is
|
||||
// exactly why the capability filter could hand three members of that envelope back raw
|
||||
// and still pass every gate: no fake anywhere said what the hook returns.
|
||||
const shapeHook = makeUseShapeHook();
|
||||
|
||||
// ── Inject the real SDK + polyfill settings (the consumer bootstrap) ────────
|
||||
configure({
|
||||
ng: realNg,
|
||||
useShape: fakeUseShape,
|
||||
useShape: shapeHook.hook,
|
||||
// The SDK's own shape query, passed as it stands - no wrapper, no cast. This is what
|
||||
// `useShapeQuery` reads through, so the journeys below run against the REAL broker
|
||||
// rather than against anything this page composed.
|
||||
@@ -1083,26 +1078,42 @@ const identity = new IdentityStore(
|
||||
// cap-surface is KEY POSSESSION — you read what your keyring holds, nothing else.
|
||||
capsReadFilter() {
|
||||
resetCaps();
|
||||
injectedSetItems = [
|
||||
shapeHook.items.length = 0;
|
||||
shapeHook.items.push(
|
||||
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
|
||||
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
|
||||
{ "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" },
|
||||
];
|
||||
);
|
||||
// What the hook hands back is the SDK's result envelope, so the items are read off
|
||||
// `data` - the member its own example destructures first, and one of the three that
|
||||
// used to come back unfiltered.
|
||||
const read = (): string[] =>
|
||||
[...(libUseShape(wsShape("urn:e2e:caps:Item"), EVERYTHING).data as Iterable<any>)].map(
|
||||
(i) => i.v,
|
||||
);
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open("did:ng:o:protdoc", "protected");
|
||||
const link = getCaps().open("did:ng:o:pubdoc", "public");
|
||||
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
const ownerView = read();
|
||||
// A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
|
||||
// no round-trip to spend (see `read-filter.ts`). That a public store would serve
|
||||
// the cap is proven on the read paths, not here.
|
||||
setCurrentUser("stranger");
|
||||
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
const strangerView = read();
|
||||
// …and the SDK's own handle is refused outright, whoever asks: it reads and writes
|
||||
// with no capability filter at all.
|
||||
let subscriptionRefused: string | null = null;
|
||||
try {
|
||||
void libUseShape(wsShape("urn:e2e:caps:Item"), EVERYTHING).subscription;
|
||||
} catch (e) {
|
||||
subscriptionRefused = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
getCaps().learn(link);
|
||||
const strangerWithLinkView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
const strangerWithLinkView = read();
|
||||
resetCaps();
|
||||
injectedSetItems = [];
|
||||
shapeHook.items.length = 0;
|
||||
setCurrentUser(null);
|
||||
return { ownerView, strangerView, strangerWithLinkView };
|
||||
return { ownerView, strangerView, strangerWithLinkView, subscriptionRefused };
|
||||
},
|
||||
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
|
||||
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
|
||||
|
||||
@@ -548,6 +548,16 @@ async function main(): Promise<void> {
|
||||
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
|
||||
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
|
||||
);
|
||||
// The three views above are read off `data`, one of the three members of the SDK's
|
||||
// result envelope that carry the items. The fourth way in is the ORM's own handle,
|
||||
// which reads and writes with no capability filter at all — so it is refused rather
|
||||
// than handed on, and that refusal is checked here rather than assumed.
|
||||
check(
|
||||
"the read-filtered view refuses the ORM's own subscription handle",
|
||||
typeof r.subscriptionRefused === "string" &&
|
||||
r.subscriptionRefused.includes("RdfOrmSubscription"),
|
||||
`subscription=${JSON.stringify(r.subscriptionRefused)}`,
|
||||
);
|
||||
});
|
||||
// MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays
|
||||
// shut until she gives Bob the key"): sharing is a journey, and it is worth more
|
||||
|
||||
Reference in New Issue
Block a user