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.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Notebook — the library's example application
|
||||
|
||||
A minimal application written against `@ng-eventually/client`, in plain DOM.
|
||||
|
||||
It exists for two reasons, and the second is the one that matters.
|
||||
|
||||
**It shows how to use the library.** Every call in `app.ts` is what a real consumer writes. There is no test scaffolding, no privileged import, no reaching into the library's internals — it resolves `@ng-eventually/client` as an external consumer does. If something reads awkwardly here, it reads awkwardly for everyone.
|
||||
|
||||
**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. That gap shipped a real defect: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable — something no application can do. This app can only do what an application can do.
|
||||
|
||||
It has already paid for itself twice: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried.
|
||||
|
||||
## What it exercises
|
||||
|
||||
Signing in, writing notes by scope, listing one's own, reading a note received as a link, handing a reader the key to a private note, opening a note for messages, leaving a message on someone else's note, and reacting to changes.
|
||||
|
||||
## Running it
|
||||
|
||||
The e2e suite builds and serves it (`packages/client/e2e/`). To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`.
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Notebook — exemple ng-eventually</title>
|
||||
<style>
|
||||
body { font: 15px/1.5 system-ui, sans-serif; max-width: 640px; margin: 24px auto; padding: 0 16px; color: #222; }
|
||||
fieldset { border: 1px solid #ddd; border-radius: 8px; margin: 0 0 16px; }
|
||||
legend { padding: 0 6px; color: #666; font-size: 13px; }
|
||||
input, select, button { font: inherit; padding: 6px 8px; }
|
||||
input { border: 1px solid #bbb; border-radius: 5px; }
|
||||
button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { padding: 6px 0; border-bottom: 1px solid #eee; }
|
||||
.out { color: #555; font-size: 13px; min-height: 1.2em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Connecté : <b id="who" data-testid="who"></b></p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Écrire une note</legend>
|
||||
<input id="title" data-testid="title" placeholder="titre" />
|
||||
<input id="body" data-testid="body" placeholder="contenu" />
|
||||
<select id="scope" data-testid="scope">
|
||||
<option value="protected">protégée</option>
|
||||
<option value="public">publique</option>
|
||||
<option value="private">privée</option>
|
||||
</select>
|
||||
<button id="write" data-testid="write">écrire</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Mes notes</legend>
|
||||
<input id="shareWith" data-testid="share-with" placeholder="partager avec (identifiant)" />
|
||||
<ul id="notes" data-testid="notes"></ul>
|
||||
<p class="out" id="shareResult" data-testid="share-result"></p>
|
||||
<p class="out" id="messages" data-testid="messages"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Ouvrir une note reçue</legend>
|
||||
<input id="link" data-testid="link" placeholder="lien de la note" size="46" />
|
||||
<button id="openLink" data-testid="open-link">ouvrir</button>
|
||||
<p class="out" id="shared" data-testid="shared"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Laisser un message sur la note d'un autre</legend>
|
||||
<input id="onNote" data-testid="on-note" placeholder="note visée" size="46" />
|
||||
<input id="message" data-testid="message" placeholder="message" />
|
||||
<button id="leave" data-testid="leave">déposer</button>
|
||||
<p class="out" id="left" data-testid="left"></p>
|
||||
</fieldset>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@ng-eventually/example-notebook",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "A minimal application written against @ng-eventually/client — the library's usage example, and what the e2e suite drives.",
|
||||
"dependencies": {
|
||||
"@ng-eventually/client": "workspace:*",
|
||||
"@ng-org/web": "0.1.2-alpha.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"bun"
|
||||
],
|
||||
"noEmit": true,
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"paths": {
|
||||
"@ng-eventually/client": [
|
||||
"../../packages/client/src/index.ts"
|
||||
],
|
||||
"@ng-eventually/client/polyfill": [
|
||||
"../../packages/client/src/polyfill.ts"
|
||||
],
|
||||
"@ng-org/web": [
|
||||
"../../node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user