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
+8 -3
View File
@@ -1,7 +1,7 @@
# @ng-eventually/polyfill
One entry point. Most of what it publishes has the same signature as the future SDK —
`ng`, `useShape`, `watchShape`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
`ng`, `useShape`, `useShapeQuery`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
and is a drop-in for `@ng-org/web` / `@ng-org/orm`: as NextGraph matures it resolves to
the real SDK (build alias removed) with no code change.
@@ -34,9 +34,14 @@ import {
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet });
await ensureIdentity(); // who I am (returned), connection work awaited
const doc = await storeRegistry.createEntityDoc("protected");
// A store is named by its reference — `doc_create` takes one, and the session is where
// upstream gets it. Omit it and the document goes to the private store, as upstream does.
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
const subjects = await readUnion(await storeRegistry.listMyEntityDocs("protected"));
// Reading is by SHAPE, over the SDK's own scope. `did:ng:i` is the whole local dataset;
// naming documents in `graphs` reads exactly those. There is no "list my documents" call
// and no way to scope a read by store — neither exists upstream.
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
```
## Principle — the polyfill compensates, it never extends
+1 -1
View File
@@ -314,7 +314,7 @@ from the reactive contract:
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
local commit in the same session — so the reactive re-read chain
([`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) `watchShape` → `reread` →
([`../src/surface/use-shape-query.ts`](../src/surface/use-shape-query.ts) `useShapeQuery` → `reread` →
[`../src/surface/read-model.ts`](../src/surface/read-model.ts) `readUnion`) never runs, and
consumers keep the STALE value until the next connection delivers a fresh
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
+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) ────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ng-eventually/polyfill",
"version": "1.0.0-dev.4",
"version": "1.0.0-dev.5",
"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",
@@ -61,7 +61,7 @@ import {
recordInbox,
type VirtualUserRecord,
} from "../shared-wallet/account-registry";
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from "../model/types";
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, StoreType } from "../model/types";
/**
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
@@ -102,7 +102,7 @@ export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
* the consumer spells the identity.
*/
// @provenance holdOwnCap kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the cap of a document you created, filed on the store's Store branch. WIRED upstream — `send_add_repo_to_store` constructs it
export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
export function holdOwnCap(id: string, scope: StoreType, doc: Nuri, cap: ReadCap): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
@@ -62,7 +62,7 @@
*/
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
import type { Nuri, PrincipalId, ReadCap, StoreType } from "../model/types";
/**
* The stand-in cap value, and the minting point — moved here from `model/nuri.ts`
@@ -330,7 +330,7 @@ export class CapRegistry {
* Deliberately does NOT touch write caps: those are decorative until cap-enforcement, and
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
open(nuri: Nuri, scope: StoreType): ReadCap {
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
//
// Every caller of this method files a STRUCTURAL document: one of the holder's three
+17 -4
View File
@@ -81,14 +81,22 @@
// Each one, and the signature that earns it its place:
// Nuri every reference the surface RETURNS
// NuriLike every reference the surface ACCEPTS
// Scope `storeRegistry.*`, `watchShape`
// PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig`
// NgLike `EventuallyConfig.ng`
// UseShapeLike `EventuallyConfig.useShape`
export type { Nuri, NuriLike, Scope, PrincipalId, NgLike, UseShapeLike } from "./model/types";
//
// `Scope` left this list in `1.0.0-dev.5`, and it is the only one that left because the
// TYPE was wrong rather than unreachable. It was `"public" | "protected" | "private"` -
// our own three strings under a name the SDK also uses, for a different thing: upstream
// a `Scope` is `{ graphs, subjects }` and scopes a READ, while those three strings are
// `doc_create`'s `store_type` and place a WRITE. One name over two questions. The read
// takes the SDK's `Scope` now (re-exported below, from the SDK itself), and a store is
// named by its reference. The three strings stay DEFINED in `model/types.ts` as
// `StoreType`, where the library's own modules still tell the three apart.
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } from "./model/types";
export { useShape } from "./surface/use-shape";
export { watchShape } from "./surface/watch-shape";
export type { ShapeQuery, ShapeObservable } from "./surface/watch-shape";
export { useShapeQuery } from "./surface/use-shape-query";
export type { ShapeQuery, ShapeObservable } from "./surface/use-shape-query";
export { init, initNg } from "./surface/lifecycle";
export * as inbox from "./surface/inbox";
export * as docs from "./surface/docs";
@@ -110,6 +118,11 @@ export * as storeRegistry from "./surface/placement";
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
// @provenance DeepSignalSet kind=passthrough level=3 ref=sdk/js/alien-deepsignals/src/types.ts:DeepSignalSet
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
// The scope `useShapeQuery` takes, re-exported from the SDK rather than restated here:
// restating it is how a type drifts, and the two axes it filters on (`graphs`,
// `subjects`) are upstream's to change.
// @provenance Scope kind=passthrough level=3 ref=sdk/js/orm/src/types.ts:Scope
export type { Scope } from "@ng-org/orm";
// @provenance NG kind=passthrough level=2 ref=sdk/js/web/src/index.ts:NG — 88 members, all forwarded; the proxy adds none and removes none
export type { NG } from "@ng-org/web";
+19 -6
View File
@@ -35,10 +35,23 @@ export type Nuri = `did:ng:${string}`;
// @provenance ReadCap kind=aligned level=1 ref=engine/repo/src/types.rs:readcap_nuri — the `r:` segment and its one-opaque-segment encoding are upstream's; the key material is a stand-in constant
export type ReadCap = `did:ng:${string}:r:${string}`;
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
* consumer's concern; this layer only knows the three scopes exist. */
// @provenance Scope kind=aligned level=1 ref=engine/repo/src/types.rs:from_type_and_repo — upstream's store types; `group` is omitted here and `dialog` is `unimplemented!()` upstream
export type Scope = "public" | "protected" | "private";
/**
* Which of a session's three stores a document is placed in. **Defined here, not
* published** - the standing `ReadCap` and {@link InboxScope} already have, and for
* the same reason: no published signature takes it. It was published as `Scope` until
* `1.0.0-dev.5`, where one name covered the scope of a READ and the placement of a
* write. The SDK's own `Scope` is an object filtering on `graphs` and `subjects`, and
* the two were never the same question.
*
* The three strings are upstream's own: `doc_create` takes a `store_type` of exactly
* `"public" | "protected" | "private"` (plus `"group"`, omitted here, and `"dialog"`,
* which is `unimplemented!()` upstream) and pairs it with a store id to build a
* `StoreRepo`. An application names a store by its REFERENCE
* (`storeRegistry.privateStore()` and its two neighbours); this type is how the
* library's own modules tell the three apart once one has been resolved.
*/
// @provenance StoreType kind=aligned level=1 ref=engine/repo/src/types.rs:from_type_and_repo — upstream's store types, the strings `doc_create`'s `store_type` accepts; `group` is omitted here and `dialog` is `unimplemented!()` upstream
export type StoreType = "public" | "protected" | "private";
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
* a chosen id, because everyone shares one wallet. */
@@ -60,7 +73,7 @@ export type NgLike = Record<string, any>;
export type UseShapeLike = (...args: any[]) => any;
/**
* The scopes that can carry an inbox. NOT `Scope`: upstream only the public and
* The stores that can carry an inbox. NOT `StoreType`: upstream only the public and
* protected store repos get one — `new_store_default` attaches an inbox solely
* `if !private` (`engine/verifier/src/verifier.rs:2994`), and the engine's only two
* `AddInboxCap` commits are for those two (`engine/verifier/src/site.rs:127-152`).
@@ -86,4 +99,4 @@ export type UseShapeLike = (...args: any[]) => any;
export type NuriLike = Nuri | string;
// @provenance InboxScope kind=aligned level=1 ref=engine/verifier/src/verifier.rs:new_store_default — an inbox is attached only `if !private`, so the private scope cannot carry one
export type InboxScope = Extract<Scope, "public" | "protected">;
export type InboxScope = Extract<StoreType, "public" | "protected">;
@@ -85,7 +85,7 @@ import { hasReadCap, isNuri } from "../model/nuri";
import { mintCap } from "../emulated-verifier/caps";
import { mustNotAttempt } from "../emulated-verifier/reach";
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { InboxScope, Nuri, Scope } from "../model/types";
import type { InboxScope, Nuri, StoreType } from "../model/types";
// --- sharedWalletShim model ----------------------------------------------
@@ -918,7 +918,7 @@ export async function isKnownInbox(nuri: Nuri): Promise<boolean> {
}
/** The index document NURI of an account for a scope (the store-container). */
export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
export function storeOf(record: VirtualUserRecord, scope: StoreType): Nuri {
return scope === "public"
? record.docPublic
: scope === "protected"
@@ -931,7 +931,7 @@ export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
* per-scope index document, for entities that need no per-entity document / no
* inbox). For per-entity scopes use {@link createEntityDoc} instead.
*/
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
export async function resolveWriteGraph(id: string, scope: StoreType): Promise<Nuri> {
const record = await ensureAccount(id);
return storeOf(record, scope);
}
@@ -952,7 +952,7 @@ export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>
/** The native store NURI backing `scope`, resolved from the injected session.
* Requires `protectedStoreId` on the session for the non-private scopes. */
async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
async function scopeStoreNuri(scope: StoreType): Promise<Nuri> {
const s = await session();
if (scope === "private") return `did:ng:${s.privateStoreId}`;
// public + protected → the protected native store (see note above). Falls
@@ -969,7 +969,7 @@ async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
* (`useShape(shape, nuri)`) and the `@graph` write target.
*/
// @provenance storeRegistry.resolveScopeGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — `did:ng:` + `private_store_id | protected_store_id | public_store_id`, straight off the session
export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
export async function resolveScopeGraph(scope: StoreType): Promise<Nuri> {
return scopeStoreNuri(scope);
}
@@ -1037,7 +1037,7 @@ const inboxCache = new Map<string, Nuri>();
* says anything about inboxes — `@ng-org/web` has no method containing "inbox" and the
* session exposes none — so the engine's model is what decides, and it says two.
*
* **The private store has none**, hence {@link InboxScope} rather than `Scope`: asking
* **The private store has none**, hence {@link InboxScope} rather than `StoreType`: asking
* for a private inbox is not a lookup that returns nothing, it is a question the model
* has no meaning for.
*
@@ -1140,7 +1140,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
* it does not carry its key. The key goes to what the creator holds (see
* {@link holdOwnCap}), which is where you look it up.
*/
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
export async function createEntityDoc(id: string, scope: StoreType): Promise<Nuri> {
const record = await ensureAccount(id);
const indexDoc = storeOf(record, scope);
const entityNuri = await createDoc();
@@ -1273,7 +1273,7 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
* user's real per-scope store NURI (the container the store itself provides).
*/
// @provenance userStoreDoc kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — the per-scope index document stands in for the store the session names directly
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
export async function userStoreDoc(id: string, scope: StoreType): Promise<Nuri> {
const record = await ensureAccount(id);
return storeOf(record, scope);
}
@@ -1300,7 +1300,7 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
* Nothing to list is NOT a failure and stays silent: a store that answers with zero
* documents means this account created none, which is a fact.
*/
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
export async function listMyEntityDocs(id: string, scope: StoreType): Promise<Nuri[]> {
const record = await ensureAccount(id);
const store = storeOf(record, scope);
const docs = await readUserStore(store);
+84 -17
View File
@@ -41,11 +41,12 @@
import {
createEntityDoc as registryCreateEntityDoc,
listMyEntityDocs as registryListMyEntityDocs,
resolveWriteGraph as registryResolveWriteGraph,
ensureAccount,
storeOf,
} from "../shared-wallet/account-registry";
import { getCurrentUser } from "../shared-wallet/bootstrap";
import type { Nuri, Scope } from "../model/types";
import { toNuri } from "../model/nuri";
import type { Nuri, NuriLike, StoreType } from "../model/types";
/** WHO is acting. Absent means the application has not signed in yet — a caller error,
* and one worth naming rather than turning into an empty result. */
@@ -60,26 +61,92 @@ function connectedIdentity(op: string): string {
return id;
}
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
// @provenance storeRegistry.createEntityDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_create — upstream `doc_create` performs the `ldp:contains` listing and the `AddRepo { read_cap }` natively; here they are two explicit writes
export async function createEntityDoc(scope: Scope): Promise<Nuri> {
return registryCreateEntityDoc(connectedIdentity("createEntityDoc"), scope);
/*
* How an application names a STORE, and why by reference rather than by a word.
*
* Until `1.0.0-dev.5` every call here took `Scope` - the three strings `"public" |
* "protected" | "private"` - and that type also named the scope of a READ. The two are
* not one question: the SDK's `Scope` is `{ graphs, subjects }` and filters documents
* and subjects, while a store is chosen when a document is CREATED. Upstream the two
* gestures are separate too, and the creation one takes a store the caller already
* holds: `doc_create(session_id, crdt, class_name, destination, store_repo)`, with the
* private store when `store_repo` is left undefined, and the three store ids handed to
* the application on the session itself (`Session.private_store_id`,
* `protected_store_id`, `public_store_id`).
*
* So the three accessors below answer what the session answers upstream - one reference
* per store - and `createEntityDoc` takes one of them, exactly where `doc_create` takes
* its store. Nothing published spells a store as a word any more, and nothing has to be
* unlearned: at migration these three read the session's own fields and the reference
* becomes the real store's.
*/
/** Resolve one of the connected identity's three stores to its reference. */
async function storeReference(op: string, type: StoreType): Promise<Nuri> {
return storeOf(await ensureAccount(connectedIdentity(op)), type);
}
/** The entity documents this user owns in `scope` — with their caps recovered. */
// @provenance storeRegistry.listMyEntityDocs kind=aligned level=1 ref=engine/verifier/src/request_processor.rs:doc_create — reads back the `ldp:contains` listing that `doc_create` writes on the store's Main branch
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]> {
return registryListMyEntityDocs(connectedIdentity("listMyEntityDocs"), scope);
/** The connected identity's PRIVATE store — where a document goes when none is named. */
// @provenance storeRegistry.privateStore kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — upstream the session carries `private_store_id`; here the per-store index document stands in for the store the session names directly
export async function privateStore(): Promise<Nuri> {
return storeReference("privateStore", "private");
}
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
// @provenance storeRegistry.resolveWriteGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — a scope resolves to the session's own store id; upstream the store IS the container and the per-scope index document disappears
export async function resolveWriteGraph(scope: Scope): Promise<Nuri> {
return registryResolveWriteGraph(connectedIdentity("resolveWriteGraph"), scope);
/** The connected identity's PROTECTED store. */
// @provenance storeRegistry.protectedStore kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — upstream the session carries `protected_store_id`; here the per-store index document stands in for the store the session names directly
export async function protectedStore(): Promise<Nuri> {
return storeReference("protectedStore", "protected");
}
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
export { resolveScopeGraph } from "../shared-wallet/account-registry";
/** The connected identity's PUBLIC store — a document placed here serves its read key
* to whoever asks for it. */
// @provenance storeRegistry.publicStore kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — upstream the session carries `public_store_id`; here the per-store index document stands in for the store the session names directly
export async function publicStore(): Promise<Nuri> {
return storeReference("publicStore", "public");
}
/**
* Create a document for ONE entity in `store`, and record it there.
*
* `store` is one of {@link privateStore}, {@link protectedStore}, {@link publicStore};
* omitting it places the document in the private store, which is what `doc_create` does
* when its `store_repo` is left undefined.
*/
// @provenance storeRegistry.createEntityDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_create — the same trailing store slot, private store when omitted; upstream `doc_create` performs the `ldp:contains` listing and the `AddRepo { read_cap }` natively, here they are two explicit writes
export async function createEntityDoc(store?: NuriLike): Promise<Nuri> {
const id = connectedIdentity("createEntityDoc");
const record = await ensureAccount(id);
if (store === undefined) return registryCreateEntityDoc(id, "private");
const named = toNuri(store, "storeRegistry.createEntityDoc");
for (const type of ["private", "protected", "public"] as const) {
if (storeOf(record, type) === named) return registryCreateEntityDoc(id, type);
}
// A reference that is not one of this identity's three stores. Upstream a store you
// are not a member of refuses the creation; here it would silently land in whichever
// store the fallback picked, which is the "recommendation enforced by the code"
// failure the doctrine names. Say it instead.
throw new Error(
`[ng-eventually] storeRegistry.createEntityDoc: ${named} is not one of this ` +
"identity's stores. Pass `privateStore()`, `protectedStore()` or `publicStore()`, " +
"or nothing at all for the private store.",
);
}
// `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` STOPPED being published
// in `1.0.0-dev.5`, and each for its own reason.
//
// - `listMyEntityDocs` has no counterpart at any level: there is no `doc_list`, no
// `list_docs`, nothing in the binding. Listing what one created is an ordinary query
// over the store's `ldp:contains` — the same shape `readUnion` is, sugar over
// something already expressible. It stays reachable at `../shared-wallet/account-registry`
// for this library's own read path, which still calls it until that path queries the
// store itself.
// - `resolveScopeGraph` handed out a value whose stated use was a READ scope, and a
// store's graph holds its containment triples and nothing else: pointed at a shape
// query upstream it matches nothing and says so with an empty set, silently. See
// `.project/concepts/app-contract/knowledge_what-a-read-scope-is-upstream.md`.
// - `resolveWriteGraph` returned the very reference the three accessors above return.
// One value, one published name.
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
@@ -1,36 +1,54 @@
/**
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
* one logical scope. This is the surface the consuming app will bind (phase B) with
* use-shape-query — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
* one SDK `Scope`. This is the surface the consuming app binds with
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
*
* ── Why an observable, and why this exact shape ────────────────────────────
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
* natively distinguish "sync in progress" from "synced but empty". That distinction
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
* ── The name, and the one difference from the SDK that is deliberate ───────
* It was `watchShape` until `1.0.0-dev.5`. That name has ZERO occurrences upstream, and
* it spent `watch`, a verb the ORM has already spent on signal observation
* (`watch as watchDeepSignal`). The target's own name is `useShapeQuery`, and what it
* returns is the snapshot below rather than the bare `DeepSignalSet` today's `useShape`
* hands back — announced by NextGraph's developers and on record in
* `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the annotation on
* the function cites. Deviating TOWARD an announced target is alignment; the rest of the
* surface is what will catch up:
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
*
* ── The scope is the SDK's, and translating it is EXPLICITLY TEMPORARY ─────
* `scope` is `@ng-org/orm`'s own `Scope` — `{ graphs?: string[] | string; subjects?:
* string[] }` — not the three strings this module took before. A `graphs` entry names
* ONE document's graph, `did:ng:i` (or `""`) discards the rest and means the whole local
* dataset, and `[]` or `undefined` returns no objects at all: all three are upstream's
* rules, reproduced by `normalizeScope` below.
*
* Only the whole-dataset reading still has to be TRANSLATED, and the translation is the
* tell that the mechanism under it is about to stop being ours: it is served by
* enumerating this identity's own documents across its three stores
* (`account-registry.listMyEntityDocs`, no longer published), because this pipeline has
* no other way to say "everything". When the read queries the store itself, the
* enumeration and this branch go together. A named `graphs` list needs no translation at
* all — it is already the doc set.
*
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
* documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
* else. There is no "everything public" to fold in — **you cannot discover,
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
* and a link reaches you through an inbox or through a document you already
* hold. A document whose cap you were given is read by NAMING it
* (`readUnion`), not by turning up in a scope you never put it in.
* 1. Resolve the scope → the doc set: the graphs named, or — for the whole-dataset
* reading — the CURRENT wallet's own per-entity documents, and nothing else. There
* is no "everything public" to fold in — **you cannot discover, you can only follow
* links** (see docs/readcap-and-nuri-model.md §4ter-bis), and a link reaches you
* through an inbox or through a document you already hold. A document whose cap you
* were given is read by NAMING it, not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
* doc set AND the first `readUnion` has rendered.
* doc set AND the first read has rendered.
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
* NOT double-filter), then FILTER the union by the requested shape's `@type`
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
* ShapeType, not from any application concept.
* NOT double-filter), then FILTER the union by the requested shape's `@type` and by
* the scope's `subjects` axis (a union spans multiple types; each `useShapeQuery`
* yields only the subjects of its shape). Non-domain: the type IRI is read from the
* SHEX ShapeType, not from any application concept.
*
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
@@ -38,12 +56,12 @@
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
* in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
* scope-index document: a push there re-RESOLVES the scope and re-keys the
* subscribed set. Subscriptions are idempotent — an already-followed
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
* parallel channel.
* in the current set re-runs `readUnion` on any push. Under the whole-dataset reading the
* set is DYNAMIC (creating an entity appends a NURI to its store's index doc), so we ALSO
* subscribe to those index documents: a push there re-RESOLVES and re-keys the subscribed
* set. A named `graphs` list has no container to follow — the caller fixed the set.
* Subscriptions are idempotent — an already-followed doc is not re-subscribed. Everything
* reuses `subscribe.ts` / `open-repo.ts`; no parallel channel.
*
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
@@ -51,14 +69,14 @@
* `isError` fires ONLY on a real thrown exception in the pipeline.
*
* ── A scope that did not answer is NOT an empty scope ──────────────────────
* The pipeline's first step ASKS a question — which documents are mine in this scope
* — and until 2026-08-17 a failure to answer it was logged and treated as "none":
* the empty set flowed on, the barrier over zero docs is trivially reached, and the
* surface published `{ data: [], isSuccess: true }`. That snapshot is BYTE-FOR-BYTE
* the synced-but-empty one this module exists to distinguish, so the one distinction
* it publishes was the one it destroyed — an application rendered "you have nothing"
* for "the store did not answer". `listMyEntityDocs` stopped handing out that reading
* one layer down (`90712e0`); catching it here put it straight back.
* The pipeline's first step ASKS a question — which documents are mine — and until
* 2026-08-17 a failure to answer it was logged and treated as "none": the empty set
* flowed on, the barrier over zero docs is trivially reached, and the surface published
* `{ data: [], isSuccess: true }`. That snapshot is BYTE-FOR-BYTE the synced-but-empty
* one this module exists to distinguish, so the one distinction it publishes was the one
* it destroyed — an application rendered "you have nothing" for "the store did not
* answer". `listMyEntityDocs` stopped handing out that reading one layer down
* (`90712e0`); catching it here put it straight back.
*
* A resolution failure now travels the LOAD-STATE channel, which is where "this is
* not an answer" already lives on this surface: `isError` with the thrown `error`,
@@ -80,7 +98,36 @@ import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo";
import { readUnion, type UnionSubject } from "./read-model";
import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry";
import type { Nuri, Scope } from "../model/types";
import { toNuri } from "../model/nuri";
import type { Nuri, StoreType } from "../model/types";
// The SDK's own scope type, imported as a TYPE (erased at build, so no runtime `@ng-org`
// import is added here). Its provenance is recorded where it is PUBLISHED, in `index.ts`.
import type { Scope } from "@ng-org/orm";
/** Upstream's whole-dataset marker. `normalizeScope` maps `""` onto it, and
* `orm_start_graph` lets an entry equal to it DISCARD every other entry. */
const ENTIRE_DATASET = "did:ng:i";
/** The three stores an identity's own documents can sit in. */
const STORE_TYPES: readonly StoreType[] = ["private", "protected", "public"];
/** What `normalizeScope` produces: two lists, `""` already mapped onto `did:ng:i`. */
type NormalizedScope = { graphs: string[]; subjects: string[] };
/**
* `normalizeScope`, reproduced rather than imported: this package never hard-imports the
* SDK at RUNTIME (see the module header of `use-shape.ts` and `configure`'s `NgLike`), and
* a type-only import is erased. Same three rules as upstream - a bare string is one graph,
* `""` becomes `did:ng:i`, an absent list is empty.
*/
// @provenance normalizeScope kind=aligned level=3 ref=sdk/js/orm/src/types.ts:normalizeScope — the same three rules; reproduced because the SDK is injected, never imported at runtime
function normalizeScope(scope: Scope | string | undefined): NormalizedScope {
if (typeof scope === "string") {
return { graphs: [scope === "" ? ENTIRE_DATASET : scope], subjects: [] };
}
const graphs = (!scope?.graphs ? [] : [scope.graphs]).flat().map((g) => (g === "" ? ENTIRE_DATASET : g));
return { graphs, subjects: scope?.subjects ?? [] };
}
/**
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
@@ -90,7 +137,7 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/**
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `useShapeQuery` yields
* the generic per-subject property bags of the read-model (NO application domain);
* the app maps them to its own entity types in phase B.
*/
@@ -170,6 +217,16 @@ function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): bool
return types.some((t) => typeIris.has(t));
}
/**
* Whether a subject passes the scope's `subjects` axis. An empty list is upstream's "no
* filtering" (`Scope.subjects`: *"Set to `[]` or leave it `undefined` for no filtering"*),
* and the filter is literal - a subject IRI is in the list or it is not, exactly as
* `FILTER(?s IN (…))` is emitted upstream.
*/
function matchesSubjects(subject: UnionSubject, wanted: string[]): boolean {
return wanted.length === 0 || wanted.includes(subject.subject);
}
/**
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
@@ -189,18 +246,26 @@ function barrierReached(docs: Nuri[]): boolean {
/**
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
* the full pipeline. The returned observable is inert until its first
* `scope` - the SDK's own `{ graphs, subjects }`. See the module header for the full
* pipeline and for what the `graphs` axis costs while the read path is still this
* library's. The returned observable is inert until its first
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
* what kicks off resolution, opening and the first read; before then `getSnapshot`
* reports the initial pending snapshot.
*/
// @provenance watchShape kind=invention level=none ref=none — nothing upstream distinguishes syncing from synced-empty at the hook, and nothing anywhere expresses the third state it now publishes: a failed `orm_start_graph` leaves `readyPromise` unsettled forever, so upstream's "could not find out" IS its "still pending"; the header's 'planned useShape upgrade' has NO upstream provenance
export function watchShape<T = UnionSubject>(
// @provenance useShapeQuery kind=announced-not-written level=3 ref=.project/rules/nextgraph/surface-mirrors-the-sdk.md:useShapeQuery announced-by=nextgraph-developers@2026-09-16 — the SDK ships `useShape` returning a bare `DeepSignalSet`; the announced direction is a `useShapeQuery` returning a TanStack-shaped snapshot
export function useShapeQuery<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeObservable<T> {
const typeIris = shapeTypeIris(shapeType);
const asked = normalizeScope(scope);
// `did:ng:i` (or `""`) discards every other entry upstream and means the whole local
// dataset. TEMPORARY: until the read queries the store itself, that is served
// by enumerating this identity's own documents across its three stores - the widest
// set this pipeline can reach. Every other `graphs` list names documents and is used
// as given, which is what the axis will mean once the enumeration is gone.
const wholeDataset = asked.graphs.includes(ENTIRE_DATASET);
// The current, STABLE snapshot (same reference until a transition rebuilds it).
let snapshot: ShapeQuery<UnionSubject> = {
@@ -234,7 +299,7 @@ export function watchShape<T = UnionSubject>(
try {
l();
} catch (error) {
console.error("[watch-shape] listener threw", error);
console.error("[use-shape-query] listener threw", error);
}
}
}
@@ -283,12 +348,20 @@ export function watchShape<T = UnionSubject>(
* the cap for is read by naming it (`readUnion`), not by appearing in
* a scope you did not put it in. */
async function resolveDocs(): Promise<Nuri[]> {
if (!wholeDataset) {
// The caller named the documents. `[]` or an absent `graphs` is upstream's "no
// objects are returned", and it reaches that answer here the same way: an empty
// doc set, a barrier trivially past it, and `isSuccess` over nothing.
return [...new Set(asked.graphs.filter(Boolean))].map((g) => toNuri(g, "useShapeQuery"));
}
const user = getCurrentUser();
const set = new Set<Nuri>();
resolving = true;
try {
if (user) {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
for (const type of STORE_TYPES) {
for (const d of await listMyEntityDocs(user, type)) set.add(d);
}
}
} finally {
resolving = false;
@@ -307,13 +380,19 @@ export function watchShape<T = UnionSubject>(
* reactivity to a later change of the SET, which is a different (and much quieter)
* problem than the one this module was fixed for. */
async function ensureContainerSubs(): Promise<void> {
// A named `graphs` list IS the set; nothing a store records can change it, so there
// is no container to follow. Only the whole-dataset reading has one - three, in fact,
// one per store this identity places documents in.
if (!wholeDataset) return;
const containers: Nuri[] = [];
const user = getCurrentUser();
if (user) {
try {
containers.push(await userStoreDoc(user, scope));
} catch (error) {
console.error("[watch-shape] userStoreDoc failed", error);
for (const type of STORE_TYPES) {
try {
containers.push(await userStoreDoc(user, type));
} catch (error) {
console.error("[use-shape-query] userStoreDoc failed", error);
}
}
}
for (const c of containers) {
@@ -355,7 +434,7 @@ export function watchShape<T = UnionSubject>(
return;
}
if (token !== refreshToken) return; // superseded by a newer refresh/reread
const data = subjects.filter((s) => matchesShape(s, typeIris));
const data = subjects.filter((s) => matchesShape(s, typeIris) && matchesSubjects(s, asked.subjects));
const past = barrierReached(docs);
setSnapshot({
data,
+28 -12
View File
@@ -15,7 +15,11 @@ import {
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { createEntityDoc as registryCreateEntityDoc, resetRegistryCache } from "../src/shared-wallet/account-registry";
import {
createEntityDoc as registryCreateEntityDoc,
listMyEntityDocs as registryListMyEntityDocs,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
import { share } from "../src/surface/inbox";
const SHIM = "urn:ng-eventually:shim";
@@ -126,18 +130,30 @@ test("ensureIdentity returns the identity it settled", async () => {
test("a placement call before signing in names the mistake", async () => {
inject();
setCurrentUser(null);
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/ensureIdentity/i);
await expect(storeRegistry.listMyEntityDocs("protected")).rejects.toThrow(/no identity/i);
await expect(storeRegistry.createEntityDoc()).rejects.toThrow(/ensureIdentity/i);
await expect(storeRegistry.protectedStore()).rejects.toThrow(/no identity/i);
});
test("placement acts as the connected user, with nothing passed", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
expect(await storeRegistry.listMyEntityDocs("protected")).toContain(doc);
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
expect(await registryListMyEntityDocs("alice", "protected")).toContain(doc);
setCurrentUser("bob");
expect(await storeRegistry.listMyEntityDocs("protected")).not.toContain(doc);
expect(await registryListMyEntityDocs("bob", "protected")).not.toContain(doc);
});
// A store reference that is not one of the connected identity's three is a caller error,
// and silence here would be the worst answer available: the document would be created,
// successfully, somewhere its author did not ask for — and only a later listing that comes
// back short would say so.
test("createEntityDoc refuses a store that is not one of this identity's", async () => {
inject();
setCurrentUser("alice");
const hers = await storeRegistry.protectedStore();
setCurrentUser("bob");
await expect(storeRegistry.createEntityDoc(hers)).rejects.toThrow(/not one of this identity/i);
});
// ── sharing names someone who exists ───────────────────────────────────────
@@ -145,7 +161,7 @@ test("placement acts as the connected user, with nothing passed", async () => {
test("share refuses a recipient nobody has signed in as, instead of creating them", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
// "bpb" is a typo for "bob". It used to mint that name's three stores and an inbox,
// and the cap landed where nobody will ever look — with no error at all.
await expect(share(doc, "bpb")).rejects.toThrow(/no such recipient/i);
@@ -157,7 +173,7 @@ test("share refuses a recipient nobody has signed in as, instead of creating the
test("a failed lookup surfaces as a failure, not as 'no such recipient'", async () => {
const { quads } = inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
setCurrentUser("bob");
await registryCreateEntityDoc("bob", "private"); // bob genuinely exists
setCurrentUser("alice");
@@ -178,7 +194,7 @@ test("a failed lookup surfaces as a failure, not as 'no such recipient'", async
test("createEntityDoc reports a half-written document instead of returning its reference", async () => {
inject(/shim:contains/); // the LISTING write fails
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/not listed in its store/i);
await expect(storeRegistry.createEntityDoc(await storeRegistry.protectedStore())).rejects.toThrow(/not listed in its store/i);
});
// Both writes are attempted before the failure is raised — the cap must land even when
@@ -187,14 +203,14 @@ test("createEntityDoc reports a half-written document instead of returning its r
test("a failed listing does not cost the document its key", async () => {
const { quads } = inject(/shim:contains/);
setCurrentUser("alice");
await storeRegistry.createEntityDoc("protected").catch(() => {});
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore()).catch(() => {});
expect(quads.some((q) => q.p === `${SHIM}:readCap`)).toBe(true);
});
test("a failed key write is reported too, and names that half", async () => {
inject(/shim:readCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i);
await expect(storeRegistry.createEntityDoc(await storeRegistry.protectedStore())).rejects.toThrow(/key is not recorded/i);
});
// The third write a PUBLIC creation makes, and the one that was still silent: exposing the
@@ -206,6 +222,6 @@ test("a failed key write is reported too, and names that half", async () => {
test("a public document whose cap could not be exposed is reported, not returned", async () => {
inject(/shim:exposedReadCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("public")).rejects.toThrow(/broker refused/i);
await expect(storeRegistry.createEntityDoc(await storeRegistry.publicStore())).rejects.toThrow(/broker refused/i);
});
@@ -37,6 +37,7 @@ import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { docs, storeRegistry } from "../src/index";
import { adoptCurrentUser } from "../src/shared-wallet/bootstrap";
import { getSyncState } from "../src/emulated-verifier/open-repo";
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
import type { Nuri, PrincipalId } from "../src/model/types";
@@ -59,7 +60,7 @@ function settledButNotConnected(id: PrincipalId): void {
async function aNoteOpenedForMessages(quads: Quad[]): Promise<Nuri> {
bootPage(quads, COLD);
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
@@ -92,9 +93,14 @@ function herPrivateStore(quads: Quad[]): Nuri {
return record.o as Nuri;
}
/** Alice's note, found the way her application finds it: by listing her own store. */
/** Alice's note, found by listing her own store.
*
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
* this reaches it where it lives. What is under test is unchanged - the cold store still
* has to answer, and it still has to refile the caps while it does. */
async function myNote(): Promise<Nuri> {
const mine = await storeRegistry.listMyEntityDocs("public");
const mine = await listMyEntityDocs("alice", "public");
const note = mine[0];
if (!note) throw new Error("the note Alice wrote is not in her store");
return note;
@@ -154,7 +160,7 @@ describe("opening my note for messages again, on a page that has just loaded", (
const quads: Quad[] = [];
bootPage(quads, COLD);
await signIn("alice");
const bare = await storeRegistry.createEntityDoc("public");
const bare = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${bare}> <${TITLE}> "Vierge" }`,
@@ -29,6 +29,7 @@
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
import { getSyncState } from "../src/emulated-verifier/open-repo";
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
@@ -47,7 +48,7 @@ const COLD = { unsyncedUntilSubscribed: true } as const;
async function aNoteWithAMessageOnIt(quads: Quad[]): Promise<Nuri> {
bootPage(quads, COLD);
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
@@ -75,9 +76,14 @@ function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
return record.o.split(" ")[1] as Nuri;
}
/** Alice's note, found the way her application finds it: by listing her own store. */
/** Alice's note, found by listing her own store.
*
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
* this reaches it where it lives. What is under test is unchanged - the cold store still
* has to answer, and it still has to refile the caps while it does. */
async function myNote(): Promise<Nuri> {
const mine = await storeRegistry.listMyEntityDocs("public");
const mine = await listMyEntityDocs("alice", "public");
const note = mine[0];
if (!note) throw new Error("the note Alice wrote is not in her store");
return note;
@@ -141,7 +147,7 @@ describe("reading the messages left on my note, on a page that has just loaded",
const quads: Quad[] = [];
bootPage(quads, COLD);
await signIn("alice");
const bare = await storeRegistry.createEntityDoc("public");
const bare = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
reloadPage(quads, COLD);
await signIn("alice");
@@ -151,9 +151,9 @@ async function whileWatchingTheLog(body: () => Promise<void>): Promise<string[]>
*/
async function bobSharesWithAlice(): Promise<{ doc: Nuri; inTransit: Quad[] }> {
await signIn("alice");
await storeRegistry.createEntityDoc("protected"); // her first visit — now she exists
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore()); // her first visit — now she exists
await signIn("bob");
const doc = await storeRegistry.createEntityDoc("protected");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await inboxSurface.share(doc, "alice");
// Test-side inspection only — the address is read off the wallet to say WHICH document
// is in transit, and is never handed to an actor.
@@ -217,7 +217,7 @@ describe("an inbox opened in the middle of a session", () => {
await signIn("alice");
// The note and its inbox come into existence AFTER connecting, so nothing the
// connection enumerated could have included them.
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await storeRegistry.openDocumentInbox(note);
await converge();
@@ -237,7 +237,7 @@ describe("an inbox opened in the middle of a session", () => {
test("the messages left on it are readable, on the document its owner named", async () => {
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await storeRegistry.openDocumentInbox(note);
await converge();
@@ -411,7 +411,7 @@ describe("a deposit that cannot be applied", () => {
// subscribes again as if it had never been attempted, and the initial push of that new
// subscription finds the deposit still waiting.
refusing = false;
await storeRegistry.createEntityDoc("protected");
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
expect(await documentsGivenTo("alice")).toContain(doc);
@@ -513,7 +513,7 @@ describe("listing the inboxes when one of the two registers cannot be read", ()
await signIn("alice");
// A document inbox: a record on the User branch of the private store, which is the
// register the broker is about to stop answering for.
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await storeRegistry.openDocumentInbox(note);
await converge();
const store = (await resolveAccount("alice"))?.docPrivate;
@@ -574,7 +574,7 @@ describe("listing the inboxes when one of the two registers cannot be read", ()
test("still REJECTS for the caller that cannot use a partial list", async () => {
await signIn("alice");
await storeRegistry.openDocumentInbox(await storeRegistry.createEntityDoc("public"));
await storeRegistry.openDocumentInbox(await storeRegistry.createEntityDoc(await storeRegistry.publicStore()));
await converge();
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
@@ -597,12 +597,12 @@ describe("listing the inboxes when one of the two registers cannot be read", ()
*/
async function bobSharesTwiceWithAlice(): Promise<Quad[][]> {
await signIn("alice");
await storeRegistry.createEntityDoc("protected"); // her first visit — now she exists
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore()); // her first visit — now she exists
await signIn("bob");
const aliceInbox = await userInbox("alice", "protected");
const batches: Quad[][] = [];
for (let round = 0; round < 2; round += 1) {
const doc = await storeRegistry.createEntityDoc("protected");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await inboxSurface.share(doc, "alice");
batches.push(heldByTheBroker(aliceInbox));
}
@@ -680,7 +680,7 @@ describe("a register that stays unreadable", () => {
// the observation look again (`CapRegistry.onChange`).
fake._failReadsOn.delete(shim);
fake._failReadsOn.add(store);
await storeRegistry.createEntityDoc("protected");
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await converge();
});
fake._failReadsOn.delete(store);
@@ -525,7 +525,7 @@ test("nothing about the deferred service reaches the published surface", () => {
"subscribeDoc",
"subscribeDocs",
"useShape",
"watchShape",
"useShapeQuery",
]);
expect(Object.keys(polyfill.inbox).sort()).toEqual([
"post",
@@ -547,11 +547,14 @@ test("nothing about the deferred service reaches the published surface", () => {
"sparqlQuery",
"sparqlUpdate",
]);
// `1.0.0-dev.5`: `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` left,
// and the three store accessors arrived in their place — an application names a store by
// the reference one of them hands it, never by a word this package made up.
expect(Object.keys(polyfill.storeRegistry).sort()).toEqual([
"createEntityDoc",
"listMyEntityDocs",
"openDocumentInbox",
"resolveScopeGraph",
"resolveWriteGraph",
"privateStore",
"protectedStore",
"publicStore",
]);
});
@@ -87,13 +87,13 @@ async function reportedDuring(body: () => Promise<void>): Promise<string[]> {
*/
async function aliceHasBeenHere(): Promise<void> {
await signIn("alice");
await storeRegistry.createEntityDoc("protected");
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
}
/** Bob shares a document of his with Alice, by name. Answers the document. */
async function bobSharesWithAlice(): Promise<Nuri> {
await signIn("bob");
const doc = await storeRegistry.createEntityDoc("protected");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await inboxSurface.share(doc, "alice");
return doc;
}
@@ -139,7 +139,7 @@ describe("a deposit no arm can apply", () => {
await signIn("alice");
// PUBLIC, so Bob can read it — the address rides on the document, so a depositor who
// cannot read it cannot reach its owner. That is the model, not a fixture detail.
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await openDocumentInbox(note);
await signIn("bob");
await inboxSurface.postToDocument(note, { payload: { kind: UNARMED_KIND } });
@@ -364,9 +364,9 @@ test("an identity that moves DURING the pop does not get the deposit applied for
// Alice opens an index and Bob deposits a bare reference into it. That arm is the one
// that reads AMBIENT state, so it is the one an identity switch can mis-file.
await signIn("alice");
const index = await storeRegistry.createEntityDoc("public");
const index = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await openDocumentInbox(index);
const object = await storeRegistry.createEntityDoc("public");
const object = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await signIn("bob");
await inboxSurface.postToDocument(index, { payload: object });
@@ -32,8 +32,9 @@ import { test, expect, describe, mock, beforeEach } from "bun:test";
import { configure, storeRegistry, readUnion } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read — "which documents are in this store". */
@@ -74,12 +75,12 @@ function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
* Returns the reference she keeps, and the quads the wallet now holds — the durable half
* that outlives the page.
*/
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(scope);
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <urn:test:note> <${TITLE}> "the note" }`,
@@ -101,7 +102,7 @@ async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Pro
armed = true;
}
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
beforeEach(() => {
forgetEverything();
@@ -119,7 +120,7 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
bootPage(quads, () => false);
await signIn("alice");
expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note);
expect(await listMyEntityDocs("alice", scope)).toContain(note);
const subjects = await readUnion([note]);
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
});
@@ -132,10 +133,10 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, KEY_READ);
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
// And the point of the refusal, stated as the caller sees it: nothing came back that
// could be mistaken for "here are your documents".
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([note]);
});
@@ -149,8 +150,8 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
const { quads } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ);
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([]);
});
+5 -5
View File
@@ -331,10 +331,10 @@ test("every announced-not-written annotation cites a record that is really here"
});
test("announced-not-written accepts a statement on record and refuses every weaker form", () => {
// The kind exists for a target ANNOUNCED and not yet written, so its first real
// annotation cannot be in the tree to exercise it. This is that annotation written out
// — the form `useShapeQuery` will carry — checked against the document that holds the
// statement today.
// The kind exists for a target ANNOUNCED and not yet written. The form below is the one
// `useShapeQuery` carries in `surface/use-shape-query.ts` since `1.0.0-dev.5`, restated
// here so the REFUSALS beneath it have something well-formed to depart from — and
// checked against the document that holds the statement.
const onRecord: Provenance = {
name: "useShapeQuery",
kind: ANNOUNCED,
@@ -357,7 +357,7 @@ test("announced-not-written accepts a statement on record and refuses every weak
// `ref=none` is what `invention` carries, and this kind is the opposite of invention.
refused({ ref: "none" }, "must cite the record of the statement");
// Our own source standing as the evidence for its own shape.
refused({ ref: "packages/polyfill/src/surface/watch-shape.ts:useShapeQuery" }, "never a source file");
refused({ ref: "packages/polyfill/src/surface/use-shape-query.ts:useShapeQuery" }, "never a source file");
// An announcement names the layer that will carry the thing.
refused({ level: "none" }, "level must be 1, 2 or 3");
// And the evidence cannot be lifted onto a weaker claim to dress it up.
@@ -33,6 +33,7 @@ import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
import { getCaps } from "../src/shared-wallet/bootstrap";
import { runScheduledInboxProcessingNow } from "../src/emulated-verifier/inbox-processor";
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
@@ -49,7 +50,7 @@ const MESSAGE = "j'apporte le café";
async function aNoteOpenForMessages(quads: Quad[]): Promise<Nuri> {
bootPage(quads);
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
@@ -74,9 +75,14 @@ function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
return record.o.split(" ")[1] as Nuri;
}
/** Alice's note, found the way her application finds it: by listing her own store. */
/** Alice's note, found by listing her own store.
*
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
* this reaches it where it lives. What is under test is unchanged - the cold store still
* has to answer, and it still has to refile the caps while it does. */
async function myNote(): Promise<Nuri> {
const mine = await storeRegistry.listMyEntityDocs("public");
const mine = await listMyEntityDocs("alice", "public");
const note = mine[0];
if (!note) throw new Error("the note Alice wrote is not in her store");
return note;
@@ -40,12 +40,13 @@ import {
resetStoreRegistry,
getCaps,
} from "../src/shared-wallet/bootstrap";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
import { listMyEntityDocs, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { connectedUser } from "../src/emulated-verifier/connect";
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
import { storeOfType } from "./wallet-fake";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
const SESSION: RegistrySession = { sessionId: "sid-reload", privateStoreId: "PRIV-RELOAD" };
@@ -55,7 +56,7 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** The application's own predicate — the note's title, in the document it created. */
const TITLE = "urn:test:title";
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
// --- the fake broker -------------------------------------------------------
@@ -303,10 +304,10 @@ async function signIn(id: string): Promise<void> {
}
/** The first visit: sign in, create the note, write its title into it. */
async function firstVisit(quads: Quad[], scope: Scope): Promise<Nuri> {
async function firstVisit(quads: Quad[], scope: StoreType): Promise<Nuri> {
boot(quads);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(scope);
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await docs.sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <${note}> <${TITLE}> "the note" }`,
@@ -349,7 +350,7 @@ describe("after a reload, the creator comes back to its own document", () => {
reload(quads);
await signIn("alice");
expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note);
expect(await listMyEntityDocs("alice", scope)).toContain(note);
});
}
@@ -1,13 +1,13 @@
/**
* `watchShape` says "there is nothing" or "I could not find out" — never the first for the second.
* `useShapeQuery` says "there is nothing" or "I could not find out" — never the first for the second.
*
* The whole reason this surface exists is one distinction: an application must be able to
* tell a scope that is EMPTY from a scope it has not heard back about. `useShape` upstream
* cannot — it returns "an empty set, if still loading" — and `watchShape` publishes
* cannot — it returns "an empty set, if still loading" — and `useShapeQuery` publishes
* `isPending` / `isSuccess` / `isError` precisely to answer the question three ways.
*
* Until 2026-08-17 it destroyed that distinction on the one input that mattered. Step 1 of
* its pipeline asks `listMyEntityDocs` which documents are mine in this scope; the failure
* its pipeline asks `listMyEntityDocs` which documents are mine; the failure
* was caught, logged, and the empty set flowed on. A barrier over zero documents is
* trivially reached, so the surface published `{ data: [], isPending: false, isSuccess:
* true }` — the exact snapshot a genuinely empty scope produces, byte for byte. An
@@ -32,12 +32,12 @@
*/
import { test, expect, describe, mock, beforeEach, afterAll } from "bun:test";
import { configure, watchShape, storeRegistry } from "../src/index";
import type { ShapeObservable, ShapeQuery } from "../src/surface/watch-shape";
import { configure, useShapeQuery, storeRegistry } from "../src/index";
import type { ShapeObservable, ShapeQuery } from "../src/surface/use-shape-query";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read of a store — "which documents are in here". Step 1 of the pipeline. */
@@ -104,12 +104,12 @@ function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
* broker that works. Returns the quads the wallet now holds — the durable half that
* outlives the page.
*/
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(scope);
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <urn:test:evt> <${RDF_TYPE}> <${EVENT}> ; <${TITLE}> "the note" }`,
@@ -160,7 +160,11 @@ async function subscribeAndSettle(
return { renders: () => renders, stop };
}
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
/** The whole local dataset - upstream's own marker, and the reading that still has to
* enumerate this identity's documents, which is the step these tests make fail. */
const EVERYTHING = { graphs: "did:ng:i" };
beforeEach(() => {
forgetEverything();
@@ -171,11 +175,11 @@ afterAll(() => {
// put the possession gate in force for a suite that declares none, and `subscribe.test.ts`
// is then refused at the door for documents it legitimately owns; a config left behind
// hands `access-log.test.ts` this file's `ng`. Both failed exactly that way before this
// ran, and neither names watch-shape anywhere. `forgetEverything` is the whole teardown.
// ran, and neither names use-shape-query anywhere. `forgetEverything` is the whole teardown.
forgetEverything();
});
describe("watchShape tells 'there is nothing' from 'I could not find out'", () => {
describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", () => {
// The NORMAL cases first, and they are not a formality: a surface that reported `isError`
// unconditionally would pass every failure case below. These two are what those are a
// departure from — and the first of them is the reading that must never be reused for a
@@ -188,7 +192,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
bootPage(quads, () => false);
await signIn("alice");
const obs = watchShape(EventShape, scope) as ShapeObservable;
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs);
const snap = obs.getSnapshot();
@@ -208,7 +212,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
bootPage(quads, () => false);
await signIn("alice");
const obs = watchShape(EventShape, scope) as ShapeObservable;
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs);
const snap = obs.getSnapshot();
@@ -232,7 +236,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
await signIn("alice");
armed = true;
const obs = watchShape(EventShape, scope) as ShapeObservable;
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs);
const snap = obs.getSnapshot();
@@ -263,7 +267,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
await signIn("alice");
const obs = watchShape(EventShape, scope) as ShapeObservable;
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
const { renders, stop } = await subscribeAndSettle(obs);
expect(whatTheAppSees(obs.getSnapshot())).toBe("some");
const rendersBefore = renders();
@@ -1,5 +1,6 @@
/**
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
* use-shape-query.test.ts — behavioural tests for `useShapeQuery`
* (src/surface/use-shape-query.ts),
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
*
* The fake emulates just enough of the broker:
@@ -20,11 +21,13 @@
* (a) isPending at first, isSuccess after the first State (barrier);
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
* (c) a write then push → data updates (reactivity, no polling);
* (d) timed-out → isSuccess (best-effort), NOT isError.
* (d) timed-out → isSuccess (best-effort), NOT isError;
* plus the three readings of the SDK `Scope` the surface now takes: the whole dataset,
* a named `graphs` list, and the empty list that upstream answers with no objects.
*/
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { watchShape } from "../src/surface/watch-shape";
import { useShapeQuery } from "../src/surface/use-shape-query";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -32,6 +35,8 @@ import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/accoun
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo";
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
const EVERYTHING = { graphs: "did:ng:i" };
const FP = "http://festipod.org/";
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
@@ -230,6 +235,18 @@ function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string
ng._quads.push({ g: doc, s: doc, p, o });
}
// The same, under a subject of its own rather than the document's reference: one document
// may hold several subjects, which is what the scope's `subjects` axis filters on.
function seedSubject(
ng: ReturnType<typeof makeFake>,
doc: string,
subject: string,
p: string,
o: string,
): void {
ng._quads.push({ g: doc, s: subject, p, o });
}
const tick = () => new Promise((r) => setTimeout(r, 5));
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
@@ -256,7 +273,7 @@ afterAll(() => {
resetCaps();
});
describe("watchShape", () => {
describe("useShapeQuery", () => {
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
const ng = makeFake({ holdState: true });
inject(ng);
@@ -266,7 +283,7 @@ describe("watchShape", () => {
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Alpha");
const obs = watchShape(EventShape, "protected");
const obs = useShapeQuery(EventShape, EVERYTHING);
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
@@ -300,7 +317,7 @@ describe("watchShape", () => {
setCurrentUser("bob");
// bob has NO entity docs in this scope — the scope is genuinely empty.
const obs = watchShape(EventShape, "protected");
const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {});
await tick();
await tick();
@@ -321,7 +338,7 @@ describe("watchShape", () => {
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "One");
const obs = watchShape(EventShape, "protected");
const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {});
await tick();
await tick();
@@ -360,14 +377,17 @@ describe("watchShape", () => {
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Timed");
const obs = watchShape(EventShape, "protected");
const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {});
await tick();
// Before the fallback fires: still pending (subscribed, no State).
expect(obs.getSnapshot().isPending).toBe(true);
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
await new Promise((r) => setTimeout(r, 60));
// Long enough for FOUR of them in a row: the whole-dataset reading opens the three
// store index documents as well as the entity document, and with the `State` held
// back each one waits out the full fallback before the next begins.
await new Promise((r) => setTimeout(r, 200));
await tick();
await tick();
@@ -382,4 +402,99 @@ describe("watchShape", () => {
unsub();
});
// ── the scope axis: the three readings upstream gives it ──────────────────
//
// These are not variants of one case. `graphs` names ONE document's graph per entry;
// `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or
// `undefined` returns no objects at all. The first two answer differently over the SAME
// wallet, and the third is the one a caller reaches by accident - so each is pinned.
it("(e) a named `graphs` list reads those documents and no others", async () => {
const ng = makeFake();
inject(ng);
setCurrentUser("erin");
const kept = await createEntityDoc("erin", "protected");
seed(ng, kept, TYPE, `${FP}Event`);
seed(ng, kept, `${FP}title`, "Named");
const other = await createEntityDoc("erin", "protected");
seed(ng, other, TYPE, `${FP}Event`);
seed(ng, other, `${FP}title`, "Unnamed");
// The whole dataset sees both - that is the reading the named list departs from.
const all = useShapeQuery(EventShape, EVERYTHING);
const unsubAll = all.subscribe(() => {});
await tick();
await tick();
expect(all.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([
"Named",
"Unnamed",
]);
unsubAll();
const obs = useShapeQuery(EventShape, { graphs: [kept] });
const unsub = obs.subscribe(() => {});
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(true);
expect(snap.data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Named"]);
unsub();
});
it("(f) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
const ng = makeFake();
inject(ng);
setCurrentUser("frank");
const doc = await createEntityDoc("frank", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Unreachable");
// Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned."
// It is an ANSWER, not a failure - and not the pending state either, which is what an
// application would otherwise be left staring at.
const obs = useShapeQuery(EventShape, {});
const unsub = obs.subscribe(() => {});
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(snap.isError).toBe(false);
expect(snap.data).toEqual([]);
unsub();
});
it("(g) `subjects` narrows to the subject IRIs named, literally", async () => {
const ng = makeFake();
inject(ng);
setCurrentUser("gina");
const doc = await createEntityDoc("gina", "protected");
// Two subjects in ONE document: the `subjects` axis is what tells them apart, and
// `graphs` cannot - both carry the same `@graph`.
seedSubject(ng, doc, `${FP}one`, TYPE, `${FP}Event`);
seedSubject(ng, doc, `${FP}one`, `${FP}title`, "First");
seedSubject(ng, doc, `${FP}two`, TYPE, `${FP}Event`);
seedSubject(ng, doc, `${FP}two`, `${FP}title`, "Second");
const both = useShapeQuery(EventShape, EVERYTHING);
const unsubBoth = both.subscribe(() => {});
await tick();
await tick();
expect(both.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([
"First",
"Second",
]);
unsubBoth();
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
const unsub = obs.subscribe(() => {});
await tick();
await tick();
expect(obs.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Second"]);
unsub();
});
});
+19 -2
View File
@@ -19,7 +19,7 @@
*/
import { mock } from "bun:test";
import { configure } from "../src/index";
import { configure, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
setCurrentUser,
@@ -32,7 +32,7 @@ import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { connectedUser } from "../src/emulated-verifier/connect";
import type { NgLike, UseShapeLike } from "../src/model/types";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
export const SESSION: RegistrySession = { sessionId: "sid-wallet", privateStoreId: "PRIV-WALLET" };
@@ -438,3 +438,20 @@ export async function signIn(id: string): Promise<void> {
setCurrentUser(id);
await connectedUser();
}
/**
* 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 the reference `privateStore()`, `protectedStore()` or
* `publicStore()` hands it, and passes that to `createEntityDoc`. A suite that walks the
* three still needs to go from one to the other, and this is that step - through the
* published calls, never around them.
*/
export function storeOfType(type: StoreType): Promise<Nuri> {
return type === "public"
? storeRegistry.publicStore()
: type === "protected"
? storeRegistry.protectedStore()
: storeRegistry.privateStore();
}