Files
ng-eventually/examples/notebook/app.ts
Sylvain Duchesne cc8a95d303 feat: le polyfill possède la session et la normalisation des identités
Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.

En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.

Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.

getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.

Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.

Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
2026-08-12 17:39:12 +02:00

275 lines
12 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 scope, 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,
readUnion,
storeRegistry,
subscribeDoc,
type Nuri,
type Scope,
// 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 domain, such as it is ---------------------------------------------
const TITLE = "urn:notebook:title";
const BODY = "urn:notebook:body";
interface Note {
doc: Nuri;
title: string;
body: 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 example reads through `readUnion`, not the ORM
init: realInit,
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) resolve(event.session);
}, true, []);
});
// --- 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);
const s = await sessionReady;
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${doc}> <${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] ?? "",
}));
}
/**
* 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 [note] = await readUnion([reference]);
if (!note) return null;
return {
doc: note.graph,
title: note.props[TITLE]?.[0] ?? "(sans titre)",
body: note.props[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 scope = (el("scope") as HTMLSelectElement).value as Scope;
const notes = await myNotes(scope);
el("notes").innerHTML = notes
.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("");
// 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".
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 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 };