Files
ng-eventually/examples/notebook/app.ts
T
Sylvain Duchesne 0c21adebf2 feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree
readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
2026-09-21 14:08:05 +02:00

408 lines
17 KiB
TypeScript

/**
* Notebook — a minimal application written against `@ng-eventually/polyfill`.
*
* It exists for two reasons, and the second is the one that matters:
*
* 1. **It shows how to use the library.** Every call here is what a real consumer
* writes; there is no test scaffolding, no privileged import, no reaching into the
* library's internals. If something is awkward here, it is awkward for everyone.
*
* 2. **It is what the applicative e2e suite drives** (`packages/polyfill/e2e/notebook.ts`).
* The other suite talks to a bag of methods on `window.__sdk`, which proves the
* functions run but never that an application could be written with them — and that
* gap shipped a real defect: a document's inbox was green in tests and unusable in
* practice, because the harness handed the address across an identity boundary
* through a variable. No application can do that. This app can only do what an
* application can do, so a test that passes here means the surface is usable, not
* merely callable.
*
* ── 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 store, capability possession, directed sharing, per-document
* inboxes and reactive reads — without inventing a product.
*
* ── Plain DOM, on purpose ─────────────────────────────────────────────────
* The library imposes no framework, so its example must not adopt one: a consumer
* reading this should see the SDK calls, not a component tree. The UI here is the
* shortest thing that makes each act reachable.
*/
import {
// SDK-shaped — these survive migration, the real SDK replaces them in place.
docs,
ensureIdentity,
inbox,
init,
storeRegistry,
subscribeDoc,
initNg as initNgSignals,
useShapeQuery,
type Nuri,
type ShapeObservable,
// Polyfill-era — ONE call, and it is the whole of what goes away.
configure,
} from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web";
// The SDK's shape query, which `useShapeQuery` reads through since `1.0.0-dev.6`, and
// the call that hands the ORM its session. Both are handed to `configure` below, the
// same way the `ng` is: this package never imports the SDK itself.
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
// --- the domain, such as it is ---------------------------------------------
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;
title: string;
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" }],
},
// The two fields a note actually carries. A shape declares what comes BACK as
// well as what matches: a predicate the shape does not name is not projected onto
// the object, so leaving these out would return notes with no title and no body.
{
iri: TITLE,
readablePredicate: "title",
minCardinality: 1,
maxCardinality: 1,
dataTypes: [{ valType: "string" }],
},
{
iri: BODY,
readablePredicate: "body",
minCardinality: 1,
maxCardinality: 1,
dataTypes: [{ valType: "string" }],
},
],
},
},
};
// --- bootstrap: ONE polyfill-era call --------------------------------------
//
// Everything else an application calls is SDK surface, preserved at migration. This one
// is the scaffolding, and at migration it goes: the app imports the real SDK, and the
// identity comes from the wallet instead of a barrier.
configure({
ng: realNg,
useShape: (() => {}) as never, // this app reads through `useShapeQuery`, never the hook
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit,
initNg: ormInitNg,
sharedWallet: {
fileUrl: "/shared-wallet.ngw",
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
},
});
// The library's `init`, not the injected one — and this line is SDK-shaped, kept at
// migration. It settles the identity before handing the page to the broker, so the
// round-trip leaves with the identifier in the URL it carries. `realInit` called here
// would navigate away first, and the barrier would never show.
//
// The callback is this application's own business, and only its own: it keeps the session
// because the SPARQL primitives below take a `session_id`, exactly as the real SDK's do.
// It used to ALSO hand the library a thunk unwrapping this event into a session shape —
// plumbing every consumer wrote identically, and this one wrote wrong twice. The library
// catches the same event on its way through `init` now (2026-08-12), so what is left here
// is only what this app itself reads.
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init((event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) {
// The ORM reaches the SDK through a module-level promise of its own; nothing it
// opens starts before this. Through the library's `initNg`, which is the SDK-shaped
// call and survives migration.
initNgSignals(realNg, event.session);
resolve(event.session);
}
}, true, []);
});
// --- the acts ---------------------------------------------------------------
/** 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}> <${RDF_TYPE}> <${NOTE}> ; <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
doc,
);
return doc;
}
/**
* 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) => ({
// The read hands back the SHAPE's own objects now, not property bags: `@graph` is
// the document a note lives in, and each field is the predicate's readable name.
doc: s["@graph"] as Nuri,
title: typeof s.title === "string" ? s.title : "(sans titre)",
body: typeof s.body === "string" ? s.body : "",
}));
}
/**
* Read someone else's note from its REFERENCE.
*
* A reference is what circulates — you do not discover a note, someone gives you its
* reference (a message, a URL, a QR code). It carries no key, and that is the point:
* if the note is in a PUBLIC store the store hands its key to whoever asks, so the
* reference is enough; if it is protected, the reference names the note and opens
* nothing, until its owner shares it (see `shareNote`).
*
* It arrives as a plain string, from a field or a URL, and goes straight in: the
* library validates it. Nothing to narrow, nothing to cast, and nothing that will have
* to change when the real SDK takes that same string.
*/
async function readSharedNote(reference: string): Promise<Note | null> {
const s = await sessionReady;
// ONE anchored query, naming the note. The anchor scopes the read to that document's
// own graph, so the body carries no `GRAPH <...>` wrapper; the library asks the public
// store for the key on the way in, which is what makes a bare reference enough.
//
// This was `readUnion([reference])` until `1.0.0-dev.8`. What an application composes
// itself now is the projection below - the SELECT and the grouping - because the
// primitive that did it was a batch nothing upstream answers.
const result = await docs.sparqlQuery(
s.session_id,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
reference,
);
const rows =
(result as { results?: { bindings?: Array<Record<string, { value: string }>> } })
.results?.bindings ?? [];
const values = (predicate: string): string[] =>
rows.filter((r) => r["p"]?.value === predicate).map((r) => r["o"]!.value);
if (rows.length === 0) return null;
return {
doc: reference as Nuri,
title: values(TITLE)[0] ?? "(sans titre)",
body: values(BODY)[0] ?? "",
};
}
/**
* Hand a reader access to one of my notes.
*
* Names the NOTE and the PERSON — the two things this app has. Neither the key nor the
* recipient's inbox appears: an application will handle neither once this is native
* (upstream the verifier fills `ContactDetails.read_cap` itself), so it handles neither
* now. Refuses if the note is not mine to share.
*/
async function shareNote(doc: Nuri, withUser: string): Promise<void> {
await inbox.share(doc, withUser);
}
/** Open a note for messages — only its owner can, and only they will read them. */
async function openNoteForMessages(doc: Nuri): Promise<void> {
await storeRegistry.openDocumentInbox(doc);
}
/** Leave a message on someone else's note. One call, naming the NOTE. */
async function leaveMessage(doc: Nuri, text: string): Promise<void> {
await inbox.postToDocument(doc, { payload: { text } });
}
/** The messages left on one of my notes — named by the note, like leaving one. */
async function messagesOn(doc: Nuri): Promise<string[]> {
const deposits = await inbox.readForDocument(doc);
return deposits.map((d) => String((d.payload as { text?: string })?.text ?? ""));
}
/** Re-render whenever a note changes — locally or from a peer. */
function watchNote(doc: Nuri, onChange: () => void): () => void {
return subscribeDoc(doc, onChange);
}
// --- identity ---------------------------------------------------------------
let identity = "";
/**
* Sign in, and learn who you are.
*
* One await, and it covers everything: the library settles the identity, waits for the
* connection work it fires (restoring what others shared with you, draining your
* inboxes), and **returns the identity**. The application keeps it only to display it —
* no call takes it, because a session belongs to one user and the target's own
* `doc_create` carries no user at all.
*
* This used to read the library's private storage key to find out who it was, which is a
* boundary no consumer should be able to see. Writing this application is what made that
* visible.
*/
async function signIn(): Promise<void> {
identity = await ensureIdentity();
await sessionReady;
}
function escape(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
}
// --- the thinnest UI that makes each act reachable --------------------------
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 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>
<button class="share" data-doc="${n.doc}">partager</button>
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
<button class="msgs" data-doc="${n.doc}">messages</button>
<div><code class="ref" data-testid="ref">${n.doc}</code></div>
</li>`,
)
.join("");
}
function wire(): void {
// 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, val("title"), val("body"));
await refresh();
});
el("openRef").addEventListener("click", async () => {
el("shared").textContent = ""; // never show a previous answer beside a new question
const note = await readSharedNote(val("reference"));
el("shared").textContent = note ? `${note.title} — ${note.body}` : "(illisible)";
});
el("leave").addEventListener("click", async () => {
el("left").textContent = "";
await leaveMessage(val("onNote") as Nuri, val("message"));
el("left").textContent = "déposé";
});
el("notes").addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
const doc = target.dataset.doc as Nuri | undefined;
if (!doc) return;
el("shareResult").textContent = "";
el("messages").textContent = "";
if (target.classList.contains("share")) {
await shareNote(doc, val("shareWith"));
el("shareResult").textContent = "partagé";
} else if (target.classList.contains("open")) {
await openNoteForMessages(doc);
el("shareResult").textContent = "ouverte aux messages";
} else if (target.classList.contains("msgs")) {
el("messages").textContent = (await messagesOn(doc)).join(" | ") || "(aucun)";
}
});
}
async function main(): Promise<void> {
wire();
await signIn();
await refresh();
}
void main();
// The e2e suite drives this app through the DOM. It exposes nothing else: a test that
// needed a back door would be testing something an application cannot do. `watchNote`
// is here because reactivity has no visible surface in this UI yet — not as an escape
// hatch, and it takes no identity: switching user means reloading with another `?ng-id=`,
// exactly as switching upstream means opening another wallet.
(globalThis as { __notebook?: unknown }).__notebook = { watchNote };