ebf866b1f2
Un polyfill ne doit rien faire de plus que ce qui est prévu. `isNuri` / `hasReadCap` et les utilitaires SPARQL `escapeLiteral` / `escapeIri` / `assertNuri` n'ont de pendant à aucun niveau et n'en auront pas : le binding prend `nuri: String`, le moteur est fortement typé en Rust et n'a besoin d'aucun prédicat, l'ORM n'expose rien de tel. Le contrat les justifiait parce qu'ils « restent utiles à n'importe quelle app » — c'est exactement le raisonnement à refuser : utile n'est pas prévu, et chacun serait un appel à réécrire le jour du SDK. Le besoin d'un guard venait de notre propre signature : les entrées publiques exigeaient `Nuri`, donc un consommateur devait narrower ce qu'il lisait d'une URL ou du stockage. Elles prennent désormais `NuriLike` — n'importe quelle chaîne — et valident à l'intérieur (`toNuri`). Ce que la bibliothèque REND reste typé `Nuri` : l'app en profite gratuitement, et un type plus large ne cassera rien quand le SDK rendra des chaînes. Les guards et les utilitaires restent, internes, là où la validation se fait. Un défaut introduit puis corrigé en chemin, qui valait le test qu'il a produit : `readUnion` a toujours toléré les trous dans sa liste — un index de scope peut porter une entrée blanche, et un appelant qui assemble depuis des valeurs optionnelles n'a pas à compacter. Valider AVANT de filtrer a transformé cette tolérance en exception. Vide est une absence, pas une référence malformée ; les deux sont désormais distingués par un test. 170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la bibliothèque, l'exemple et le harnais.
259 lines
9.8 KiB
TypeScript
259 lines
9.8 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,
|
|
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. 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(link: string): Promise<Note | 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.
|
|
*
|
|
* Names the PERSON. Where their inbox is, and whether they have one yet, is the
|
|
* library's business — an application will never handle an inbox address once this is
|
|
* native, so it does not handle one now.
|
|
*/
|
|
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");
|
|
await inbox.shareCap(cap, 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.
|
|
*/
|
|
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 };
|