Files
ng-eventually/examples/notebook/app.ts
T
Sylvain Duchesne d35e735c8b feat(example): une app d'exemple, écrite comme un consommateur
Le harnais e2e parlait à un sac de méthodes posé sur `window.__sdk`. Il prouvait
que les fonctions s'exécutaient, jamais qu'on pouvait écrire une application avec
— et cet écart a livré un vrai défaut : l'inbox d'un document était verte en test
et inutilisable en vrai, parce que le harnais faisait traverser une adresse d'une
identité à l'autre par une variable, ce qu'aucune application ne peut faire.

`examples/notebook` est une application minimale en DOM natif, qui résout
`@ng-eventually/client` comme un consommateur externe (workspace, dépendance
déclarée, aucun import privilégié). Elle ne peut faire que ce qu'une application
peut faire.

Elle s'est déjà payée deux fois pendant son écriture :

- `UnionSubject.subject` et `.graph` étaient typés `string` alors que ce sont
  toujours des références de document. Un consommateur devait donc caster ce
  qu'il venait de lire avant de le repasser — un cast à cet endroit précis
  rouvre la confusion que les types template literal existent pour fermer.
- l'écran d'accès normalisait ce que l'utilisateur SAISIT mais pas ce que l'URL
  porte, si bien qu'un lien `?ng-id=@Erin` ouvrait un espace différent de celui
  de la même personne tapant `erin`. Une seule normalisation désormais, celle
  du registre.

Le domaine est volontairement mince — des notes — mais suffit à exercer le
placement par scope, la possession de caps, le partage dirigé, les inbox par
document et la lecture réactive.

170 tests unitaires, typecheck vert sur la lib, l'exemple et le harnais.
2026-08-05 18:25:47 +02:00

256 lines
9.6 KiB
TypeScript

/**
* Notebook — a minimal application written against `@ng-eventually/client`.
*
* 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 e2e suite drives.** The suite used to talk to a bag of methods on
* `window.__sdk`, which proved the functions ran 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 {
docs,
ensureIdentity,
inbox,
isNuri,
readUnion,
storeRegistry,
subscribeDoc,
type Nuri,
type Scope,
} from "@ng-eventually/client";
import { capFor, configure, configureStoreRegistry, setCurrentUser } from "@ng-eventually/client/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: the two polyfill-era calls ---------------------------------
//
// Everything else an application calls is SDK surface, preserved at migration. These
// two are the scaffolding: `configure` becomes inert (the app will import the real SDK)
// and the identity will come 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__ ?? "",
},
});
configureStoreRegistry({
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 me = currentIdentity();
const doc = await storeRegistry.createEntityDoc(me, 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(currentIdentity(), scope);
const subjects = await readUnion(docsOfScope);
return subjects.map((s) => ({
doc: s.subject,
title: s.props[TITLE]?.[0] ?? "(sans titre)",
body: s.props[BODY]?.[0] ?? "",
}));
}
/**
* Read someone else's note from its link.
*
* The link is what circulates in this model — you do not discover a note, you are given
* its link. `hasReadCap` is the door an untrusted string goes through.
*/
async function readSharedNote(link: string): Promise<Note | null> {
if (!isNuri(link)) return null;
const [note] = await readUnion([link]);
if (!note) return null;
return {
doc: note.subject,
title: note.props[TITLE]?.[0] ?? "(sans titre)",
body: note.props[BODY]?.[0] ?? "",
};
}
/** Hand a reader the key to one of my notes, addressed to their inbox. */
async function shareNote(doc: Nuri, withUser: string): Promise<void> {
const cap = capFor(doc);
if (!cap) throw new Error("this note is not mine to share");
const theirInbox = await storeRegistry.userInbox(withUser, "protected");
await inbox.shareCap(cap, theirInbox);
}
/** 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. */
async function messagesOn(doc: Nuri): Promise<string[]> {
const address = await storeRegistry.documentInboxAddress(doc);
if (!address) return [];
const deposits = await inbox.read(address);
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 = "";
function currentIdentity(): string {
if (!identity) throw new Error("not signed in yet");
return identity;
}
/**
* Sign in. The library shows its access barrier when it needs one; the day the wallet
* supplies the identity, this resolves silently and nothing here changes.
*/
async function signIn(): Promise<void> {
await ensureIdentity();
await sessionReady;
identity = readIdentityBack();
}
/** The library owns the identity; the app asks for it rather than remembering it. */
function readIdentityBack(): string {
return new URLSearchParams(location.search).get("ng-id") ?? localStorage.getItem("ng-eventually:identity") ?? "";
}
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>
</li>`,
)
.join("");
el("who").textContent = identity;
}
function wire(): void {
el("write").addEventListener("click", async () => {
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
await refresh();
});
el("openLink").addEventListener("click", async () => {
const note = await readSharedNote(val("link"));
el("shared").textContent = note ? `${note.title}${note.body}` : "(illisible)";
});
el("leave").addEventListener("click", async () => {
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;
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.
(globalThis as { __notebook?: unknown }).__notebook = { watchNote, setCurrentUser };