0455a408b6
L'objectif acté était deux appels spécifiques au polyfill, voire un. Il en publiait
quatre. Chacun des trois de trop était une raison que la BIBLIOTHÈQUE a, pas un besoin
qu'une application a :
- **`configureStoreRegistry`** existait parce qu'il y a deux internes à câbler — le SDK
injecté d'un côté, la session de l'autre. Vu de l'appelant, les deux disent « voici ce
qu'il te faut pour tourner ». Replié dans `configure`, qui prend désormais
`getSession` / `normalizeId` / `pointerGuard`.
- **`setCurrentUser`** n'a plus lieu d'être publié depuis que le portail d'accès est
passé dans le polyfill : c'est lui qui pose l'identité. Et une application qui nomme
sa propre identité est exactement le geste qui inverse le modèle — il ne doit pas
exister d'appel publié vers lequel se tourner. Le harnais e2e, lui, joue plusieurs
identités sur une même page ; il y accède par le chemin interne, ce qu'un harnais a
le droit de faire et une application non.
- **`connectedUser`** est maintenant attendu DANS `ensureIdentity`. Ce n'était pas une
commodité : la suite applicative avait montré qu'une app devait l'attendre elle-même,
sinon une note qu'on venait de lui partager se lisait comme illisible. J'avais traité
le symptôme dans l'app d'exemple ; le défaut était côté bibliothèque. En amont, ouvrir
la session EST la connexion — aucune application n'attend un second appel.
Reste donc `configure({ … })`, plus `await ensureIdentity()` dont le site d'appel
survit à la migration : une application attendra toujours une session avant de rendre.
Le test étendu hier a fait son travail : les deux contrôles de contrat sont passés au
rouge sur `configureStoreRegistry`, `connectedUser` et `StoreRegistryDeps` dès que la
surface a bougé.
180 tests unitaires, e2e 40/40 (3,4 min) et applicatif 10/10 (0,8 min).
282 lines
11 KiB
TypeScript
282 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 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 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.subject,
|
|
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 = "";
|
|
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.
|
|
*
|
|
* One await, and it covers everything: the gate resolves the identity AND waits for the
|
|
* connection work it fires (restoring what others shared with you, draining your
|
|
* inboxes). The application used to have to await that second part itself — the
|
|
* applicative e2e is what found it out, because a note someone had just shared read as
|
|
* unreadable, which looks like a permission problem and is a timing one. The library
|
|
* absorbed it: upstream, opening the session IS the connection, and no application
|
|
* awaits a second call.
|
|
*/
|
|
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>
|
|
<div><code class="ref" data-testid="ref">${n.doc}</code></div>
|
|
</li>`,
|
|
)
|
|
.join("");
|
|
el("who").textContent = identity;
|
|
}
|
|
|
|
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 };
|