Files
ng-eventually/examples/notebook/app.ts
T
Sylvain Duchesne 3547de202c fix: régler l'identité ne demande pas de session, se connecter oui
init() de @ng-org/web redirige vers le broker en première instruction, dès qu'on
est en tête. L'application appelait donc init() au chargement du module, la page
partait, et ensureIdentity() ne s'exécutait jamais : la barrière n'apparaissait
pas, ?ng-id= restait absent de l'URL remise au broker, et un primo-arrivant se
retrouvait devant la page de connexion sans portefeuille et sans moyen d'en
obtenir un — sans la moindre erreur.

Appeler ensureIdentity() avant init() ne marchait pas non plus : il attend la
session, que seul le callback d'init() résout. Cycle vérifié empiriquement.

La cause n'était ni l'ordre ni la redirection, mais une confusion dans
ensureIdentity() entre deux actes de nature différente — régler qui est
l'utilisateur (barrière, URL, stockage : aucune session) et se connecter
(session requise). settleIdentity() porte le premier ; le wrapper init() du
polyfill l'attend avant de déléguer. L'invariant d'ordre est ainsi porté par la
composition, pas par une consigne d'ordre d'appel que personne ne lit.

Piège trouvé et épinglé en écrivant les tests : init() et ensureIdentity() dans
le même tick montaient deux barrières, l'utilisateur répondait à l'une et
l'autre ne se résolvait jamais. Le règlement en vol est désormais partagé.
2026-08-11 12:52:49 +02:00

282 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__ ?? "",
},
getSession: async () => {
const s = session ?? (await sessionReady);
return {
sessionId: s.session_id,
privateStoreId: (s as Record<string, string>).private_store_id!,
protectedStoreId: (s as Record<string, string>).protected_store_id,
publicStoreId: (s as Record<string, string>).public_store_id,
};
},
normalizeId: (id) => id.trim().replace(/^@/, "").toLowerCase(),
});
// 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.
let session: { session_id: string } | null = null;
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init((event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) {
session = 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 };