feat(client): watchShape — lecture réactive à la forme TanStack useQuery
Phase A du refactor des lectures. Surface la barrière de sync interne
(open-repo `getSyncState`) dans une API useQuery-shaped, en anticipation de la
mise à jour prévue de useShape par NextGraph — distingue nativement « sync en
cours » de « synchronisé mais vide ».
`watchShape<T>(shapeType, scope): { getSnapshot(): ShapeQuery<T>, subscribe(cb),
refetch() }` avec `ShapeQuery = { data, isPending, isSuccess, isError, error }`.
OBSERVABLE (pas de dépendance React — l'app câblera useSyncExternalStore en
phase B) ; getSnapshot rend une référence stable.
- Scope LOGIQUE (public/protected/private) résolu au wallet virtuel :
listMyEntityDocs(getCurrentUser, scope) + découverte foldée pour public.
- isPending tant que la barrière n'est pas atteinte / 1er readUnion non rendu ;
isSuccess après ; timed-out → isSuccess (best-effort, pas isError).
- Réactif SANS polling : subscribeDoc sur les docs + le doc d'index de scope
(+ index découverte) → re-read/re-résolution au push ; souscriptions idempotentes.
- Générique : aucune logique domaine Festipod dans le lib ; filtre par la shape
SHEX (rdf:type). Pas de double filtre cap.
gate : tsc 0 ; bun test 120 (+4 : pending→success, synced-vide, réactif, timed-out) ;
test:e2e 42 passed (+scénario broker réel : isPending au 1er snapshot → isSuccess
avec données, scope vide → isSuccess data:[]).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,8 +25,10 @@ import {
|
||||
discovery,
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
watchShape,
|
||||
accounts,
|
||||
} from "@ng-eventually/client";
|
||||
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||
|
||||
const { IdentityStore } = accounts;
|
||||
|
||||
@@ -563,6 +565,123 @@ const identity = new IdentityStore(
|
||||
return { priv, prot, pub };
|
||||
},
|
||||
|
||||
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
|
||||
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
|
||||
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
|
||||
// to know which @type to keep — generic, no application domain.
|
||||
_wsProbes: {} as Record<
|
||||
string,
|
||||
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
|
||||
>,
|
||||
/**
|
||||
* 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
|
||||
* (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) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const id = "@ws-" + handle;
|
||||
setCurrentUser(id);
|
||||
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
||||
const s = await sessionReady;
|
||||
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <${doc}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
|
||||
doc,
|
||||
);
|
||||
// Wait until this session sees the index append (data persisted on the broker).
|
||||
for (let i = 0; i < 15; i++) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
||||
if (listed.includes(doc)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const shape = {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const obs = watchShape(shape, "protected") 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
|
||||
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
|
||||
// keeps a stable identity. watchShapeStop 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) {
|
||||
const rec = (window as any).__sdk._wsProbes[handle];
|
||||
if (!rec) return null;
|
||||
const snap: ShapeQuery = rec.obs.getSnapshot();
|
||||
return {
|
||||
isPending: snap.isPending,
|
||||
isSuccess: snap.isSuccess,
|
||||
isError: snap.isError,
|
||||
dataLen: snap.data.length,
|
||||
// The seeded title, if the datum is present (proves the real data landed).
|
||||
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
|
||||
};
|
||||
},
|
||||
watchShapeStop(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
|
||||
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
|
||||
* the handle; poll watchShapeSnapshot for the transition.
|
||||
*/
|
||||
watchShapeEmptyStart(handle: string, cls: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const id = "@ws-empty-" + handle;
|
||||
setCurrentUser(id);
|
||||
const shape = {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const obs = watchShape(shape, "protected") 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
|
||||
// faithful "empty scope for a real identity", not "no identity at all".
|
||||
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
|
||||
},
|
||||
|
||||
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
||||
// The read-filter over the injected useShape Set-like. Boundary note: the
|
||||
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
||||
|
||||
Reference in New Issue
Block a user