feat!: useShapeQuery prend le Scope du SDK, et un store se nomme par sa reference
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+125
-46
@@ -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,
|
||||
Reference in New Issue
Block a user