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