Files
ng-eventually/examples/notebook/app.ts
T
Sylvain Duchesne 49b046268e docs(contract): le contrat décrit l'API, et rien d'autre
Récriture complète de `contract_sdk-surface.md` sur le cadrage du propriétaire du projet :
**le contrat dit ce que le SDK met à disposition, point.**

Ce qui en sort, et pourquoi ça n'y avait pas sa place :

- **tout état d'implémentation** — chiffrement, confidentialité, valeur de remplacement,
  ce qui est émulé, ce qui n'est pas encore fait. Le lecteur est un agent qui développe
  une application appelante : s'il lit qu'une chose est provisoire, il conçoit des
  compensations — sa propre couche de chiffrement, un choix de ne pas stocker telle
  donnée, un avertissement d'interface — toutes fausses et toutes à retirer. Il doit
  pouvoir considérer que ce SDK **est** celui de NextGraph ;
- **la fabrique** — « polyfill », « portefeuille partagé », « multi-utilisateurs », la
  migration, ce que l'application supprimera un jour, les écarts par rapport à la cible ;
- **l'argumentaire** — ce que le modèle « permet », ce que telle règle « achète », la
  confidentialité composable. Un appelant a besoin de savoir qu'une référence rendue ne
  porte pas de clé, pas de savoir ce que ça lui apporte.

Ce qui entre : les **trois obligations de déploiement**, vérifiées dans le code — servir un
`.ngw` depuis son bundle et le passer à `configure`, être ouverte via la redirection du
broker, appeler `ensureIdentity()` dans un contexte navigateur avant de rendre — et trois
clauses contraignantes qui manquaient : l'identifiant rendu est **opaque**, le préfixe
`urn:ng-eventually:` est **réservé sur les sujets**, et le placement recommandé est un
document par entité métier, plusieurs objets dans un document restant permis.

`## Guarantees` devient une suite d'énoncés plats. `## Non-guarantees` ne liste que des
**absences de capacité** — pas de nom d'affichage, pas de révocation, rien par lecteur sur
un document en store public, pas d'écriture déléguée — jamais un manque par rapport à
autre chose.

L'application d'exemple n'affiche plus l'identifiant comme un nom : elle le montre pour ce
qu'il est, un identifiant technique. C'était exactement ce que la clause « opaque »
interdit, dans le fichier censé montrer le bon geste.

202 tests, typechecks propres, `lint` sans erreur. 148 → 135 lignes.
2026-08-10 15:03:08 +02:00

277 lines
11 KiB
TypeScript

/**
* Notebook — a minimal application written against `@ng-eventually/sdk`.
*
* 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/sdk/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,
readUnion,
storeRegistry,
subscribeDoc,
type Nuri,
type Scope,
// Polyfill-era — ONE call, and it is the whole of what goes away.
configure,
} from "@ng-eventually/sdk";
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.
let session: { session_id: string } | null = null;
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
realInit((event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) {
session = event.session;
resolve(event.session);
}
}, true, []);
});
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 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 };