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
+112 -28
View File
@@ -19,7 +19,7 @@
* ── The domain is deliberately thin ───────────────────────────────────────
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
* private one, and may leave a message on someone else's note. That is enough to
* exercise placement by scope, capability possession, directed sharing, per-document
* exercise placement by store, capability possession, directed sharing, per-document
* inboxes and reactive reads — without inventing a product.
*
* ── Plain DOM, on purpose ─────────────────────────────────────────────────
@@ -37,8 +37,9 @@ import {
readUnion,
storeRegistry,
subscribeDoc,
useShapeQuery,
type Nuri,
type Scope,
type ShapeObservable,
// Polyfill-era — ONE call, and it is the whole of what goes away.
configure,
} from "@ng-eventually/polyfill";
@@ -48,6 +49,8 @@ import { ng as realNg, init as realInit } from "@ng-org/web";
const TITLE = "urn:notebook:title";
const BODY = "urn:notebook:body";
const NOTE = "urn:notebook:Note";
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
interface Note {
doc: Nuri;
@@ -55,6 +58,44 @@ interface Note {
body: string;
}
/**
* Which store a note goes in, as the interface offers it - and the only thing that word
* still decides.
*
* A store is chosen when a note is CREATED, and it is named by its reference: the library
* hands out one per store and `createEntityDoc` takes it, exactly where `doc_create` takes
* its store. There is no matching question on the way back - **a read cannot be scoped by
* store**, upstream or here, so the list below is every note this identity holds, whatever
* shelf it was written to. That is not a shortcoming of this example; it is what the
* target can express.
*/
function shelf(name: string): Promise<Nuri> {
return name === "public"
? storeRegistry.publicStore()
: name === "protected"
? storeRegistry.protectedStore()
: storeRegistry.privateStore();
}
/** The shape of a note: a subject carrying `rdf:type urn:notebook:Note`. */
const NoteShape = {
shape: "urn:notebook:NoteShape",
schema: {
"urn:notebook:NoteShape": {
iri: "urn:notebook:NoteShape",
predicates: [
{
iri: RDF_TYPE,
readablePredicate: "type",
minCardinality: 1,
maxCardinality: 1,
dataTypes: [{ literals: [NOTE], valType: "iri" }],
},
],
},
},
};
// --- bootstrap: ONE polyfill-era call --------------------------------------
//
// Everything else an application calls is SDK surface, preserved at migration. This one
@@ -63,7 +104,7 @@ interface Note {
configure({
ng: realNg,
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
useShape: (() => {}) as never, // `useShapeQuery` and `readUnion` do not go through the ORM hook
init: realInit,
sharedWallet: {
fileUrl: "/shared-wallet.ngw",
@@ -90,27 +131,64 @@ const sessionReady = new Promise<{ session_id: string }>((resolve) => {
// --- the acts ---------------------------------------------------------------
/** Write a new note in `scope`. The document is created, then filled. */
async function writeNote(scope: Scope, title: string, body: string): Promise<Nuri> {
const doc = await storeRegistry.createEntityDoc(scope);
/** Write a new note on `shelfName`'s shelf. The document is created, then filled. */
async function writeNote(shelfName: string, title: string, body: string): Promise<Nuri> {
const doc = await storeRegistry.createEntityDoc(await shelf(shelfName));
const s = await sessionReady;
// The type triple is what makes the note findable afterwards: the reactive read asks
// for a SHAPE, and a shape is pinned on `rdf:type`. Nothing indexes it for us.
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${doc}> <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
`INSERT DATA { <${doc}> <${RDF_TYPE}> <${NOTE}> ; <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
doc,
);
return doc;
}
/** My notes in `scope`, read the way the library intends: list, then read. */
async function myNotes(scope: Scope): Promise<Note[]> {
const docsOfScope = await storeRegistry.listMyEntityDocs(scope);
const subjects = await readUnion(docsOfScope);
return subjects.map((s) => ({
doc: s.graph,
title: s.props[TITLE]?.[0] ?? "(sans titre)",
body: s.props[BODY]?.[0] ?? "",
}));
/**
* My notes, read the way the library offers them: ONE reactive query over everything this
* identity holds, shaped as a note.
*
* `did:ng:i` is the scope that means the whole local dataset - the caller's own documents
* are what it can reach. The application does not list its documents and then read them:
* there is no call to list them with, and there is no question "what is in this store" to
* ask. It names a SHAPE, and what it holds of that shape comes back.
*/
let notes: ShapeObservable | null = null;
function noteQuery(): ShapeObservable {
if (!notes) {
notes = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
// One subscription for the life of the page: a note that arrives - written here, or
// shared by somebody else - re-renders the list with no call from this code.
notes.subscribe(() => renderNotes());
}
return notes;
}
/** Wait until the query has ANSWERED - pending is not an answer, and neither is a failure
* read as an empty list. */
function answered(q: ShapeObservable): Promise<void> {
return new Promise((resolve) => {
let off: (() => void) | null = null;
const look = (): void => {
if (q.getSnapshot().isPending) return;
off?.();
resolve();
};
off = q.subscribe(look);
look();
});
}
function currentNotes(): Note[] {
return noteQuery()
.getSnapshot()
.data.map((s) => ({
doc: s.graph,
title: s.props[TITLE]?.[0] ?? "(sans titre)",
body: s.props[BODY]?.[0] ?? "",
}));
}
/**
@@ -201,9 +279,20 @@ const el = (id: string): HTMLElement => document.getElementById(id)!;
const val = (id: string): string => (el(id) as HTMLInputElement).value.trim();
async function refresh(): Promise<void> {
const scope = (el("scope") as HTMLSelectElement).value as Scope;
const notes = await myNotes(scope);
el("notes").innerHTML = notes
const q = noteQuery();
q.refetch();
await answered(q);
renderNotes();
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
// never split, never dressed up as a person's name.
const idTag = document.createElement("code");
idTag.textContent = identity;
el("who").replaceChildren("id ", idTag);
}
function renderNotes(): void {
el("notes").innerHTML = currentNotes()
.map(
(n) => `<li data-doc="${n.doc}">
<b class="t">${n.title}</b> — <span class="b">${n.body}</span>
@@ -214,20 +303,15 @@ async function refresh(): Promise<void> {
</li>`,
)
.join("");
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
// never split, never dressed up as a person's name.
const idTag = document.createElement("code");
idTag.textContent = identity;
el("who").replaceChildren("id ", idTag);
}
function wire(): void {
// Changing the scope changes which notes are listed — without this the list keeps
// showing the previous scope's notes, which reads as "my note disappeared".
// The shelf chooses where the NEXT note is written, and nothing else: a read cannot be
// scoped by store, so the list below always shows every note this identity holds.
// Re-rendering on a change is still worth it — a note shared in the meantime shows up.
el("scope").addEventListener("change", () => void refresh());
el("write").addEventListener("click", async () => {
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
await writeNote((el("scope") as HTMLSelectElement).value, val("title"), val("body"));
await refresh();
});
el("openRef").addEventListener("click", async () => {