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:
Sylvain Duchesne
2026-09-24 18:27:02 +02:00
parent 5993a8c4a2
commit 3a8941159f
13 changed files with 817 additions and 94 deletions
@@ -33,7 +33,11 @@ Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
// The ORM's REACT hook. It is NOT on `@ng-org/orm`'s main entry - that resolves to
// `core`, which exports no `useShape` - so import it from `@ng-org/orm/react` (the
// default export) or as `reactUseShape` from `@ng-org/orm/all`. Pass it as it
// stands; no wrapper, no cast.
useShape: UseShapeLike;
// `RdfOrmSubscription.getOrCreate` from @ng-org/orm - the SDK's shape query, which
// `useShapeQuery` reads through. Pass it as it stands; no wrapper, no cast.
ormSubscription?: OrmSubscriptionLike;
@@ -70,7 +74,10 @@ export const storeRegistry: { // no identity parameter — a session is
// document. `readUnion` and `UnionSubject` were published here until `1.0.0-dev.8`;
// nothing takes their place, because they were that call in a loop plus policies of
// this package's own. See `## Change policy`.
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
export function useShape<T extends BaseType>( // read-filtered view
shapeType: ShapeType<T>,
conf: RdfOrmConfig<T> | string | undefined,
): ShapeResult; // { data, promise, subscription, isLoading, nextPage?, previousPage? }
// TWO positional arguments — the same pair `useShape` takes, both of them the SDK's own
// types. `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in, and
// its items are the SHAPE's objects: `@id`, `@graph` and the shape's readable predicates.
@@ -231,7 +238,7 @@ Where a call must first find out whether something already exists — a document
**The wrapped `ng` is a wrapper, and a wrapper can be wrong.** It forwards every member to the object you gave `configure`, adding two refusals of its own and nothing else. Until `1.0.0-dev.7` it did not forward them at all - every member came back as a promise, and calling one failed with NextGraph's own `<member> is not a function`. The lesson to keep is that a member of `ng` that misbehaves is worth reporting here rather than worked around: nothing in this package's own code goes through that wrapper, so this surface is the only place such a defect shows.
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` returns a set you cannot tell from an empty one. The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case. Since `1.0.0-dev.9` the commonest way in is closed for `useShapeQuery`: a document NAMED in `graphs` is opened and refused before the query is handed over, so an unreachable named document reads `isError` rather than pending for ever. What remains is the start the engine refuses for a document you can perfectly well reach, and everything under `graphs: "did:ng:i"`, where there was no name to check.
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` answers with the ORM's own two states, which do not carry the distinction - its `isLoading` says whether the initial payload has arrived and has no value for "it never will". The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case. Since `1.0.0-dev.9` the commonest way in is closed for `useShapeQuery`: a document NAMED in `graphs` is opened and refused before the query is handed over, so an unreachable named document reads `isError` rather than pending for ever. What remains is the start the engine refuses for a document you can perfectly well reach, and everything under `graphs: "did:ng:i"`, where there was no name to check.
**No object removal call, and no document deletion at all.** There is no `deleteObject` and no `removeDocument` at any level of NextGraph, so there is none here. You remove an object by deleting it from the LIVE set a shape query handed you - the item the set is holding, reached by iterating it, by `.first()` or by `.getBy(graph, subject)`. What that removes is the predicates your SHAPE declares on that subject: triples under a predicate the shape does not name survive, the subject survives, and the document survives. Deleting an item of a `getObjects` result removes nothing, because that result is a clone. `@ng-org/orm@0.1.2-alpha.21` ships a `removeObject(graphNuri, subjectIri)`; it is not published here, because it exists in the package and not in the source this package is verified against, and a symbol whose only evidence is what an npm build happens to export is not something to build a guarantee on.
@@ -253,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`.** Members that yield items are filtered and mutations pass through; anything else throws. 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` and the two page commands pass through, 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 other member of that object is refused too unless what is behind it is a plain scalar, which is how a member a later `@ng-org/orm` adds is decided before anyone has seen it: whatever can carry an item is an object or a function, and both refuse. Inside `data`, every member that yields items is filtered, mutations pass through, `size` and `length` count what you may read and not what is there, and an index into an ordered result is refused rather than guessed at. A document reached through that view alone, read nowhere else first, does not appear.
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it — `docs.sparqlQuery` anchored on it — and it appears.
@@ -271,11 +278,20 @@ What each level means here, in this package's own terms:
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.10`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.11`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.10` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.11` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.10`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.10`, the string you pinned, never the version it is heading for.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.11`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.11`, the string you pinned, never the version it is heading for.
`1.0.0-dev.11` is a **major**, and all of it is one rule: **`useShape` hands back NextGraph's own RESULT OBJECT, filtered - and the filter no longer decides what to hand on by what a member is CALLED.** Nothing is added, renamed or removed from the entry.
- **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.
- **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.** An ordered config (`orderBy`) makes the collection an array, whose count is a number - and a number used to pass through untouched, publishing how many items you may NOT read. Indexing that array (`data[0]`) is refused rather than projected.
- **Unchanged**: the read still spends no round-trip asking a public store for a key, a document you have read nowhere else still does not appear, mutations of the live collection still pass through, and nothing about `useShapeQuery`, `getObjects` or `insertObject` moves.
`1.0.0-dev.10` is a **major**, and all of it is one rule: **"is this document open in this session" now has exactly one answer.** This package kept three records of that fact - a set of live bootstrap subscriptions, a per-document barrier state, and the subscription layer's own listeners - and the first two outlived the third. Only one remains, the state of the document's one real subscription; the other two are gone rather than reconciled, because two records of one fact can disagree and these did. Nothing is added, renamed or removed from the entry.
+100 -26
View File
@@ -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 -3
View File
@@ -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",
+34 -23
View File
@@ -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
+10
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ng-eventually/polyfill",
"version": "1.0.0-dev.10",
"version": "1.0.0-dev.11",
"type": "module",
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts",
@@ -73,6 +73,50 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
return out;
}
/**
* Can this value NOT reach an item, whatever it turns out to be?
*
* The one question a filter can ask about a member it has never seen. A primitive holds no
* reference to anything, so no item is reachable through it and it may pass. An object or a
* function may hold one - directly, behind a promise, behind a getter, behind a whole
* subscription - so it may not pass unless this file has a filtered form of it.
*
* `null` is a primitive in the model and `typeof null` is `"object"`, so it is named.
*/
function cannotReachAnItem(v: unknown): boolean {
return v === null || (typeof v !== "object" && typeof v !== "function");
}
/** What a refused member says. One wording for both shapes of refusal, so that a report of
* either is greppable as the same event. */
function refusalOf(prop: string | symbol, where: string): Error {
return new Error(
`[ng-eventually] read filter: \`${String(prop)}\` is not filtered, so reading it ` +
"would return items this identity may not read. Add it to " +
`\`emulated-verifier/read-filter.ts\` (${where}), filtered \u2014 do not bypass the view.`,
);
}
/**
* The answer for a member this file does not name: decided by the TYPE of what is behind it,
* never by its name. See the note on {@link makeReadFilteredView}.
*
* 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
* `typeof` guard - and refusing those would be refusing a question nobody asked. An object
* has no such later moment, so it is refused at the read.
*/
function answerUnnamedMember(target: object, prop: string | symbol, where: string): unknown {
const v = Reflect.get(target, prop, target);
if (cannotReachAnItem(v)) return v;
if (typeof v === "function") {
return () => {
throw refusalOf(prop, where);
};
}
throw refusalOf(prop, where);
}
/**
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
*
@@ -95,8 +139,20 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
* hits it exactly what to do: add the member here, filtered. A boundary whose unknown
* cases leak is not a boundary.
*
* Everything that is not a function passes through untouched (`size` is handled above):
* a plain property carries no items.
* ── 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.
*
* 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.
*/
// @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 {
@@ -110,7 +166,13 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
return new Proxy(set, {
get(target, prop, receiver) {
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
if (prop === "size") return kept(target).length;
// HOW MANY, under both spellings. `data` is a `DeepSignalSet` for an unordered read
// and an array-like `DeepSignal<ReadOnlyArray>` once the config asks for an
// `orderBy`, and `length` is a number - so it used to pass as a primitive and
// publish the UNFILTERED count, which is showing more than the holder may read.
// Each is answered only when the target carries it, so the view keeps the shape of
// what it wraps instead of growing a member the collection does not have.
if ((prop === "size" || prop === "length") && prop in target) return kept(target).length;
// A Set yields the item for both halves of a `[key, value]` pair; `DeepSignalSet`
// follows the same shape, so `keys`/`values`/`entries` are the Set contract.
if (prop === "values" || prop === "keys") return () => kept(target)[Symbol.iterator]();
@@ -176,18 +238,113 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
);
}
const v = Reflect.get(target, prop, target);
if (typeof v !== "function") return v;
// UNKNOWN function member: refuse rather than forward. See the header — forwarding
// is a silent leak, and this view's one job is that it cannot show more than the
// holder may read.
return () => {
throw new Error(
`[ng-eventually] read filter: \`${String(prop)}\` is not filtered, so calling it ` +
"would return items this identity may not read. Add it to " +
"`emulated-verifier/read-filter.ts`, filtered — do not bypass the view.",
);
};
// ANY OTHER MEMBER, function or not: refuse rather than forward, unless the value
// behind it cannot reach an item at all. See the header — forwarding is a silent
// leak, and this view's one job is that it cannot show more than the holder may read.
// An index on an array-like `data` lands here and is refused: it hands back a raw
// item, and there is no projection of it that this file would not have to invent.
return answerUnnamedMember(target, prop, "the collection view");
},
}) as S;
}
/**
* One filtered view per underlying collection, so that reading `.data` twice hands back the
* SAME reference. A caller puts that value in a dependency array; a fresh proxy on every
* read would make it look like new data on every render. Keyed weakly on the collection the
* SDK handed us, and rebuilt if the registry it was filtered against is no longer the one in
* force, so a view can never answer from a keyring that has been replaced.
*/
const filteredCollections = new WeakMap<object, { caps: CapRegistry; view: object }>();
/** The read-filtered view of one item-carrying value, memoized. */
function filteredCollection(value: object, caps: CapRegistry): object {
const known = filteredCollections.get(value);
if (known !== undefined && known.caps === caps) return known.view;
const view = makeReadFilteredView(value, caps);
filteredCollections.set(value, { caps, view });
return view;
}
/** Same, for the promise that resolves to one: the filtering happens on what it resolves to. */
const filteredPromises = new WeakMap<object, { caps: CapRegistry; view: Promise<unknown> }>();
function filteredPromise(value: PromiseLike<unknown>, caps: CapRegistry): Promise<unknown> {
const known = filteredPromises.get(value as object);
if (known !== undefined && known.caps === caps) return known.view;
const view = Promise.resolve(value).then((d) =>
cannotReachAnItem(d) ? d : filteredCollection(d as object, caps),
);
filteredPromises.set(value as object, { caps, view });
return view;
}
/**
* A read-filtered VIEW over the RESULT the SDK's `useShape` hands back - the envelope, not
* the collection inside it.
*
* ── Why there are two views and not one ───────────────────────────────────
* `@ng-org/orm@0.1.2-alpha.21`'s React `useShape` returns a `UseShapeResult`, an object
* whose members are of two different kinds: some carry the items, some are the request's
* own state. Until 2026-09-24 this package wrapped that envelope in
* {@link makeReadFilteredView}, which is written for a collection - so `[...result]` and
* `.size` threw (an envelope is not iterable), while `.data`, `.promise` and
* `.subscription` were each returned RAW and each handed back every identity's items. One
* 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` ──────
* 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.
*
* `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
* because one wallet hosts every identity here, so an unwrapped read through it returns
* every identity's objects. It reaches the items three separate ways - `signalObject`,
* `readyPromise`, and the raw `{adds, updates, removes}` handed to `addChangeListener` -
* and a wrapper of it would be a second published surface to keep honest. Refusing is
* loud, and what it refuses is the one member the contract already says is not published.
*/
// @provenance makeReadFilteredShapeResult kind=aligned level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — the same capability filter, over the result envelope that hook returns rather than over the collection inside it; removed at migration
export function makeReadFilteredShapeResult<R extends object>(result: R, caps: CapRegistry): R {
return new Proxy(result, {
get(target, prop) {
// THE ITEMS, under the three names alpha.21 offers them. Each is filtered rather
// than refused: a holder reading their own data through whichever member the SDK
// gives them is exactly what this view exists to keep working.
if (prop === "data") {
const d = Reflect.get(target, prop, target);
return cannotReachAnItem(d) ? d : filteredCollection(d as object, caps);
}
if (prop === "promise") {
const p = Reflect.get(target, prop, target);
return cannotReachAnItem(p) ? p : filteredPromise(p as PromiseLike<unknown>, caps);
}
if (prop === "subscription") {
const s = Reflect.get(target, prop, target);
// `undefined` when the caller passed no config: nothing was opened, so there is
// nothing to refuse, and answering the SDK's own value keeps that case honest.
if (cannotReachAnItem(s)) return s;
throw new Error(
"[ng-eventually] read filter: `subscription` hands back NextGraph's own " +
"`RdfOrmSubscription`, which reads and writes with no capability filter at all " +
"- one wallet hosts every identity here, so it would return every identity's " +
"objects. This package does not publish it. Read through `data`, `promise`, or " +
"`useShapeQuery`.",
);
}
// THE PAGE COMMANDS. They return `void` and yield nothing, so they forward, exactly
// as `add`/`delete`/`clear` do on the collection view. What they load lands in the
// raw collection, which is only ever read back through the filter above.
if (prop === "nextPage" || prop === "previousPage") {
const fn = Reflect.get(target, prop, target);
return typeof fn === "function" ? fn.bind(target) : fn;
}
return answerUnnamedMember(target, prop, "the result envelope");
},
}) as R;
}
+59 -5
View File
@@ -78,9 +78,61 @@ export type PrincipalId = string;
// @provenance NgLike kind=invention level=none ref=none — nothing upstream is injected; the type exists only to describe what `configure` receives
export type NgLike = Record<string, any>;
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
// @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape`
export type UseShapeLike = (...args: any[]) => any;
/**
* What the SDK's `useShape` hands back - the members this package reads off the result
* envelope, and not one more. Same discipline as {@link OrmSubscriptionHandle}.
*
* NOT published: an application never writes this type down, it reads the value. What is
* published is the call that returns one.
*
* `@ng-org/orm@0.1.2-alpha.21` calls it `UseShapeResult` and declares it in
* `dist/frontendAdapters/react/useShape.d.ts` - which no subpath of the package EXPORTS
* (`.` is `core.d.ts`, `/react` re-exports the hook alone, `/all` re-exports the config
* types alone), so it cannot be imported and is restated here. The three item-carrying
* members are `unknown` on purpose: each is read through the capability filter, never off
* this type, and narrowing them here would make the real hook unassignable to
* {@link UseShapeLike} for a difference no reader of this package can act on.
*
* The two page commands are OPTIONAL because upstream `WithMaybePagination` `Omit`s them
* unless the config asks for a `pageSize`, and the shipped hook sets them to `undefined`
* outside a paginated mode.
*/
// @provenance ShapeResult kind=aligned level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape - the result type of the cited hook, restated because the package exports it from no subpath; the cited revision returns a bare `DeepSignalSet` and the build returns this envelope around one, which is the gap the anchor records
export interface ShapeResult {
/** The requested objects once loaded, `undefined` while they are not. A `DeepSignalSet`
* for an unordered read, an array-like `DeepSignal<ReadOnlyArray>` once the config asks
* for an `orderBy`. Upstream: "the value returned by `RdfOrmSubscription.signalObject`". */
readonly data: unknown;
/** Resolves to the same data once it has arrived; `undefined` when the config was. */
readonly promise: unknown;
/** The `RdfOrmSubscription` underneath. This package refuses to hand it on - see
* `emulated-verifier/read-filter.ts` - and declares it here because refusing a member
* requires knowing it is there. */
readonly subscription: unknown;
/** Whether the initial payload is still outstanding. */
readonly isLoading: boolean;
/** Load the next page. Present only under a paginated config. */
readonly nextPage?: (() => void) | undefined;
/** Load the previous page. Present only under `maxActivePages`. */
readonly previousPage?: (() => void) | undefined;
}
/**
* Shape of `@ng-org/orm`'s React `useShape`, the hook an application injects at
* {@link configure}.
*
* It was `(...args: any[]) => any` until 2026-09-24, and that is not a detail: an `any`
* return means no declaration anywhere in this package said what the injected hook hands
* back, so the prose said "a bare `DeepSignalSet`" - true of `alpha.18`, false of the
* `alpha.21` this package builds against - and nothing could contradict it. The type now
* says it, and the real `reactUseShape` is assignable to it with no wrapper and no cast
* (pinned by `test/use-shape.test.ts`).
*/
// @provenance UseShapeLike kind=invention level=none ref=none — nothing upstream is injected; the type exists only to describe what `configure` receives, here for the ORM's React hook
export type UseShapeLike = <T extends BaseType>(
shapeType: ShapeType<T>,
conf: RdfOrmConfig<T> | string | undefined,
) => ShapeResult;
/**
* The shape query in force, as `surface/use-shape-query.ts` reads it - the four members
@@ -121,8 +173,10 @@ export interface OrmSubscriptionHandle {
*
* An application injects the real one (`ormSubscription: RdfOrmSubscription.getOrCreate`);
* it is assignable to this as it stands, with no wrapper and no cast. `useShape` is not a
* substitute: it is React's hook, it returns a bare `DeepSignalSet`, and a set cannot
* carry the request state `ShapeQuery` publishes.
* substitute: it is React's hook, so it can only be called from a render, and what it
* returns carries the ORM's own two states and not the three `ShapeQuery` publishes - an
* `isLoading` that does not tell a refused read from a pending one, and no error state at
* all.
*/
// @provenance OrmSubscriptionLike kind=invention level=none ref=none - nothing upstream is injected; the type exists only to describe what `configure` receives, here for the ORM's own `getOrCreate`
export type OrmSubscriptionLike = <T extends BaseType>(
+28 -11
View File
@@ -1,18 +1,35 @@
/**
* Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
* in force, the returned set is a read-filtered VIEW (only items in documents the
* current holder has the ReadCap of); before the first cap is issued it passes the
* real set through unchanged. At migration the filtering disappears — the broker
* only delivers documents whose cap the wallet holds.
* Wrapped `useShape`: the same call the SDK's React hook offers, and the same two
* 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. 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.
*
* ── What changed on 2026-09-24, and why it was invisible ──────────────────
* This wrapped the result in the COLLECTION view (`makeReadFilteredView`), which is
* written for a `DeepSignalSet`. That was right while the hook returned one - `alpha.18` -
* and wrong the moment `@ng-org/orm@0.1.2-alpha.21` started returning a `UseShapeResult`
* around it. Against that shape the collection view threw on `[...result]` and `.size`
* (an envelope is not iterable) and returned `data`, `promise` and `subscription` RAW,
* each of them every identity's items. Nothing failed, because no fake in this repository
* returned the shape the SDK ships.
*/
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import type { RdfOrmConfig } from "@ng-org/orm/all";
import { getConfig, getCaps } from "../shared-wallet/bootstrap";
import { makeReadFilteredView } from "../emulated-verifier/read-filter";
import { makeReadFilteredShapeResult } from "../emulated-verifier/read-filter";
import type { ShapeResult } from "../model/types";
// @provenance useShape kind=passthrough level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — forwards to the injected real hook; the read-filtered view emulates cryptographic isolation and the generic `T` is widened to `unknown`
export function useShape(shapeType: unknown, scope: unknown): unknown {
const set = getConfig().useShape(shapeType, scope) as object;
// @provenance useShape kind=passthrough level=3 ref=sdk/js/orm/src/frontendAdapters/react/useShape.ts:useShape — forwards to the injected real hook, with its two arguments; the read-filtered view over the result envelope emulates cryptographic isolation
export function useShape<T extends BaseType>(
shapeType: ShapeType<T>,
conf: RdfOrmConfig<T> | string | undefined,
): ShapeResult {
const result = getConfig().useShape(shapeType, conf);
const caps = getCaps();
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
return makeReadFilteredView(set, caps);
if (!caps.isEnforcing()) return result; // no cap issued yet → passthrough
return makeReadFilteredShapeResult(result, caps);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { getCaps } from "../src/shared-wallet/bootstrap";
import { makeUseShapeHook } from "./use-shape-fake";
import { test, expect, mock, afterEach } from "bun:test";
import { makeNg } from "../src/surface/ng-proxy";
import { configure } from "../src/index";
@@ -126,7 +127,7 @@ function asyncProxyNg(calls: string[]): Record<string, unknown> {
test("passthrough: a member of the REAL (proxy-shaped) `ng` comes back callable", async () => {
const calls: string[] = [];
const ng = asyncProxyNg(calls);
configure({ ng, useShape: () => undefined });
configure({ ng, useShape: makeUseShapeHook().hook });
const proxy = makeNg();
expect(typeof proxy.orm_start_graph).toBe("function");
@@ -145,7 +146,7 @@ test("passthrough: a plain-object `ng` keeps its receiver", async () => {
return this.mark;
},
};
configure({ ng, useShape: () => undefined });
configure({ ng, useShape: makeUseShapeHook().hook });
const proxy = makeNg() as unknown as { whoAmI: () => string };
expect(proxy.whoAmI()).toBe("the real ng");
});
+2 -1
View File
@@ -24,6 +24,7 @@
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { makeUseShapeHook } from "./use-shape-fake";
import { test, expect, mock, afterAll } from "bun:test";
import { readDocumentSubjects } from "../src/surface/read-document";
import type { Nuri } from "../src/model/types";
@@ -230,7 +231,7 @@ function injectTriples(triplesByDoc: Record<string, Array<[string, string, strin
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng, useShape: () => undefined });
configure({ ng, useShape: makeUseShapeHook().hook });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
+114
View File
@@ -0,0 +1,114 @@
/**
* use-shape-fake - a stand-in for the SDK's React hook, `@ng-org/orm`'s `useShape`.
*
* Not a `*.test.ts`, so `bun test` does not pick it up: it is the injection the suites
* share, and it is the seam the package is built on - an application hands `configure` the
* real hook, and a suite hands it this. Sibling of `orm-fake.ts`, which does the same for
* the shape query one floor down.
*
* -- Why it returns the ENVELOPE, and why that is the whole point -----------
* Every fake in this repository used to return a bare Set-like, because that is what the
* hook returned in `alpha.18`. `@ng-org/orm@0.1.2-alpha.21` returns a `UseShapeResult`
* AROUND the collection, and while no fake said so, a capability filter that handed three
* of that envelope's members back RAW could not fail a single test. So the shape below is
* read off the artifact that settles it - the installed package's
* `dist/frontendAdapters/react/useShape.d.ts`, and the `dist/.../useShape.js` beside it,
* which agree - and not off what this package wished the hook returned:
*
* { data, promise, subscription, isLoading, nextPage, previousPage }
*
* Each of the six is reproduced with the cardinality the declaration gives it:
* - `data` is a `Set` for an unordered config and an ARRAY once `orderBy` is asked for
* (upstream `SubscriptionData<T, CONF>`), and `undefined` while nothing has arrived;
* - `promise` resolves to that same collection, and is `undefined` when `conf` is;
* - `subscription` is the handle, carrying the items three ways of its own -
* `signalObject`, `readyPromise` and the raw arrays a change listener is handed;
* - `isLoading` is a boolean, the one member that carries nothing;
* - the two page commands exist only under a paginated config, as `WithMaybePagination`
* `Omit`s them otherwise and the shipped hook leaves them `undefined`.
*
* What it does NOT invent: no member the declaration does not carry. A fake richer than
* the target proves things about a surface nobody ships.
*/
import type { BaseType } from "@ng-org/shex-orm";
import type { ShapeResult, UseShapeLike } from "../src/model/types";
/** The subscription handle the envelope carries - the members `RdfOrmSubscription` exposes
* that reach the items, which is what makes handing it on a leak. */
export interface FakeSubscription {
readonly signalObject: unknown;
readonly readyPromise: Promise<unknown>;
readonly isReady: boolean;
addChangeListener(l: (c: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void): void;
close(): void;
}
export interface FakeShapeHook {
/** What goes into `configure({ useShape })`. */
hook: UseShapeLike;
/** The rows the fake engine holds. A suite mutates this array in place. */
items: BaseType[];
/** The RAW collection the last call handed back - what a leak reaches, and what a test
* compares the filtered view against. */
raw(): unknown;
/** Pages asked for, in order, so a test can see a forwarded command arrive. */
pages: string[];
}
/**
* A fake `useShape` over an in-memory row list.
*
* `loaded` (the default) answers as a hook whose payload has arrived. Turn it off for the
* state the declaration describes as "still loading": `data` and `promise` present but the
* data not there yet.
*/
export function makeUseShapeHook(
options: { loaded?: boolean; paginated?: boolean } = {},
): FakeShapeHook {
const items: BaseType[] = [];
const pages: string[] = [];
const loaded = options.loaded !== false;
const paginated = options.paginated === true;
let lastRaw: unknown = undefined;
const hook: UseShapeLike = (_shapeType, conf): ShapeResult => {
// Upstream: "If the conf is explicitly set to `undefined`, the returned data is
// `undefined` and `isLoading` is false" - and `promise`/`subscription` are typed
// `CONF extends undefined ? undefined : ...`, so all three go together.
if (conf === undefined) {
lastRaw = undefined;
return {
data: undefined,
promise: undefined,
subscription: undefined,
isLoading: false,
nextPage: undefined,
previousPage: undefined,
};
}
// `SubscriptionData<T, CONF>`: a set with no `orderBy`, an ordered array with one.
const ordered = typeof conf === "object" && conf.orderBy !== undefined;
const collection: unknown = ordered ? [...items] : new Set(items);
lastRaw = collection;
const subscription: FakeSubscription = {
signalObject: collection,
readyPromise: Promise.resolve(collection),
isReady: loaded,
addChangeListener(l): void {
l({ adds: [...items], updates: [], removes: [] });
},
close(): void {},
};
return {
data: loaded ? collection : undefined,
promise: Promise.resolve(collection),
subscription,
isLoading: !loaded,
nextPage: paginated ? (): void => void pages.push("next") : undefined,
previousPage: paginated ? (): void => void pages.push("previous") : undefined,
};
};
return { hook, items, raw: () => lastRaw, pages };
}
+268
View File
@@ -0,0 +1,268 @@
/**
* 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.
expect(result.isLoading).toBe(false);
// 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);
});
// -- 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 hands back a raw item and there is no projection of it this file would not
// have to invent, so it is refused rather than guessed at.
expect(() => (data as Record<number, unknown>)[0]).toThrow(/not filtered/i);
});