feat!: useShapeQuery prend le Scope du SDK, et un store se nomme par sa reference

This commit is contained in:
Sylvain Duchesne
2026-09-16 16:47:09 +02:00
parent ee9319f89b
commit d0ca5c5d41
31 changed files with 812 additions and 291 deletions
+6 -2
View File
@@ -561,8 +561,12 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<
// ── the acts, expressed as the application expresses them ───────────────────
/**
* Show the notes of `scope` — the list is per-scope, so acting on a note means looking at
* the right shelf first.
* Choose a shelf, and wait for the list to settle.
*
* The shelf decides where the NEXT note is written and nothing else: since `1.0.0-dev.5`
* a read cannot be scoped by store — the SDK has no way to say it and neither has this
* package — so the application lists every note its identity holds, whatever shelf it went
* to. Selecting one still re-renders, which is what the wait below is for.
*
* The wait is not decoration: the application's `change` handler runs `void refresh()`,
* un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS
+51 -29
View File
@@ -25,7 +25,7 @@ import {
inbox,
storeRegistry,
useShape as libUseShape,
watchShape,
useShapeQuery,
} from "@ng-eventually/polyfill";
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
// application must not — but through the internal path, never the published entry.
@@ -48,6 +48,24 @@ import { ensureIdentity } from "@ng-eventually/polyfill";
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
// plain strings and validate inside). Internal path, like the rest of its machinery.
import { isNuri } from "../src/model/nuri";
/**
* The published reference of one of the connected identity's three stores.
*
* Placement stopped being spelled as a word on the published surface in `1.0.0-dev.5`:
* an application names a store by what `privateStore()`, `protectedStore()` or
* `publicStore()` hands it. The journeys below still walk the three, so this is the step
* from one to the other - through the published calls.
*/
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
const EVERYTHING = { graphs: "did:ng:i" };
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
t === "public"
? storeRegistry.publicStore()
: t === "protected"
? storeRegistry.protectedStore()
: storeRegistry.privateStore();
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill";
const { IdentityStore } = virtualUsers;
@@ -575,16 +593,16 @@ const identity = new IdentityStore(
// and the caps of what you create are filed under the identity you were acting
// as. Creating B's document while connected as A is not a thing the model has.
setCurrentUser(idA);
const dA1 = await storeRegistry.createEntityDoc("public");
const dA2 = await storeRegistry.createEntityDoc("public");
const dA1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
const dA2 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc("public");
const dB1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
setCurrentUser(idA);
let listA: string[] = [];
for (let i = 0; i < 12; i++) {
registryInternals.resetRegistryCache();
listA = await storeRegistry.listMyEntityDocs("public");
listA = await registryInternals.listMyEntityDocs(idA, "public");
if (listA.includes(dA1) && listA.includes(dA2)) break;
await new Promise((r) => setTimeout(r, 1000));
}
@@ -616,7 +634,7 @@ const identity = new IdentityStore(
// document is filed under nobody and the very session that created it is
// refused the write below.
setCurrentUser(id);
const entityNuri = await storeRegistry.createEntityDoc(scope);
const entityNuri = await storeRegistry.createEntityDoc(await storeOfType(scope));
const marker = "recon-" + Date.now();
await docs.sparqlUpdate(
s.session_id,
@@ -629,7 +647,7 @@ const identity = new IdentityStore(
let listed: string[] = [];
for (let i = 0; i < 15; i++) {
registryInternals.resetRegistryCache();
listed = await storeRegistry.listMyEntityDocs(scope);
listed = await registryInternals.listMyEntityDocs(id, scope);
if (listed.includes(entityNuri)) break;
await new Promise((r) => setTimeout(r, 1000));
}
@@ -655,7 +673,7 @@ const identity = new IdentityStore(
await connectedUser();
registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(scope);
const listed = await registryInternals.listMyEntityDocs(id, scope);
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
// many rows the bare anchored query resolves for a not-yet-opened repo (the
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
@@ -703,10 +721,14 @@ const identity = new IdentityStore(
const rec = await registryInternals.ensureAccount(id);
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
},
// `resolveScopeGraph` stopped being published in `1.0.0-dev.5` — a store's graph holds
// its containment triples and nothing else, so the value it handed out matched nothing
// as a read scope upstream. The probe keeps measuring the mapping, at its internal path:
// a harness may do that, an application may not.
async scopeResolvers() {
const priv = await storeRegistry.resolveScopeGraph("private");
const prot = await storeRegistry.resolveScopeGraph("protected");
const pub = await storeRegistry.resolveScopeGraph("public");
const priv = await registryInternals.resolveScopeGraph("private");
const prot = await registryInternals.resolveScopeGraph("protected");
const pub = await registryInternals.resolveScopeGraph("public");
return { priv, prot, pub };
},
@@ -771,8 +793,8 @@ const identity = new IdentityStore(
}
},
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
// ── useShapeQuery (reactive useQuery-shaped read) ────────────────────────
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. useShapeQuery
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
// to know which @type to keep — generic, no application domain.
_wsProbes: {} as Record<
@@ -781,17 +803,17 @@ const identity = new IdentityStore(
>,
/**
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
* document carrying an rdf:type=<e2e class> triple, then open a `watchShape` over
* (that class shape, "protected"). Capture the FIRST snapshot right after subscribe
* document carrying an rdf:type=<e2e class> triple, then open a `useShapeQuery` over
* (that class shape, the whole dataset). Capture the FIRST snapshot right after subscribe
* (must be isPending) so the caller can then wait event-driven for isSuccess with
* the seeded datum present. Returns the handle + the initial snapshot + the seed
* doc/type so the runner can assert the data landed.
*/
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
async useShapeQuerySeedAndSubscribe(handle: string, cls: string) {
registryInternals.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc("protected");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
const s = await sessionReady;
// Seed the entity doc with the shape's type + a title (anchored default graph).
await docs.sparqlUpdate(
@@ -802,7 +824,7 @@ const identity = new IdentityStore(
// Wait until this session sees the index append (data persisted on the broker).
for (let i = 0; i < 15; i++) {
registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs("protected");
const listed = await registryInternals.listMyEntityDocs(id, "protected");
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
@@ -823,19 +845,19 @@ const identity = new IdentityStore(
},
},
};
const obs = watchShape(shape, "protected") as ShapeObservable;
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {});
// First snapshot immediately after subscribe — the barrier is not yet crossed
// (docs opening via doc_subscribe), so this MUST be pending.
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
// NB: leave the current user SET for the probe's lifetime — watchShape resolves
// NB: leave the current user SET for the probe's lifetime — useShapeQuery resolves
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
// keeps a stable identity. watchShapeStop clears it.
// keeps a stable identity. useShapeQueryStop clears it.
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
},
/** Current snapshot of a watchShape probe (event-driven poll target). */
watchShapeSnapshot(handle: string) {
/** Current snapshot of a useShapeQuery probe (event-driven poll target). */
useShapeQuerySnapshot(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (!rec) return null;
const snap: ShapeQuery = rec.obs.getSnapshot();
@@ -848,17 +870,17 @@ const identity = new IdentityStore(
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
};
},
watchShapeStop(handle: string) {
useShapeQueryStop(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (rec) rec.unsub();
setCurrentUser(null);
},
/**
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
* EMPTY-scope proof: a brand-new identity with NO entity docs at all. `useShapeQuery`
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
* the handle; poll watchShapeSnapshot for the transition.
* the handle; poll useShapeQuerySnapshot for the transition.
*/
watchShapeEmptyStart(handle: string, cls: string) {
useShapeQueryEmptyStart(handle: string, cls: string) {
registryInternals.resetRegistryCache();
const id = "@ws-empty-" + handle;
setCurrentUser(id);
@@ -879,11 +901,11 @@ const identity = new IdentityStore(
},
},
};
const obs = watchShape(shape, "protected") as ShapeObservable;
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
// Keep the identity set for the probe's lifetime (watchShapeStop clears it): a
// Keep the identity set for the probe's lifetime (useShapeQueryStop clears it): a
// faithful "empty scope for a real identity", not "no identity at all".
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
},
+13 -13
View File
@@ -386,45 +386,45 @@ async function main(): Promise<void> {
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
});
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
// ── useShapeQuery (reactive useQuery-shaped read) ───────────────────────
// The real cycle: first subscription reads isPending (barrier not yet crossed),
// then after the broker sync it reaches isSuccess with the seeded datum present.
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
// distinction the announced `useShapeQuery` makes native, surfaced here from getSyncState).
console.log("\n── useShapeQuery (reactive useQuery-shaped read) ──");
await step("useShapeQuery: first subscribe isPending → isSuccess with data present", async () => {
const h = "cyc" + Date.now();
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
const seed = await sdk<any>(frame, "useShapeQuerySeedAndSubscribe", h, CLS);
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
// Event-driven: wait for the barrier to cross + the datum to land.
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && s.isSuccess && s.dataLen >= 1;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
await sdk(frame, "watchShapeStop", h);
await sdk(frame, "useShapeQueryStop", h);
});
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
await step("useShapeQuery: empty scope reaches isSuccess with data:[]", async () => {
const h = "empty" + Date.now();
const CLS = "urn:e2e:ws:Event";
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
await sdk<any>(frame, "useShapeQueryEmptyStart", h, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && s.isSuccess;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
await sdk(frame, "watchShapeStop", h);
await sdk(frame, "useShapeQueryStop", h);
});
// ── caps / read-filter (in-memory cap model) ────────────────────────────