359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
/**
|
|
* Notebook — a minimal application written against `@ng-eventually/polyfill`.
|
|
*
|
|
* 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/polyfill/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 store, 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,
|
|
init,
|
|
readUnion,
|
|
storeRegistry,
|
|
subscribeDoc,
|
|
useShapeQuery,
|
|
type Nuri,
|
|
type ShapeObservable,
|
|
// Polyfill-era — ONE call, and it is the whole of what goes away.
|
|
configure,
|
|
} from "@ng-eventually/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";
|
|
const NOTE = "urn:notebook:Note";
|
|
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
|
|
interface Note {
|
|
doc: Nuri;
|
|
title: string;
|
|
body: string;
|
|
}
|
|
|
|
/**
|
|
* Which store a note goes in, as the interface offers it - and the only thing that word
|
|
* still decides.
|
|
*
|
|
* A store is chosen when a note is CREATED, and it is named by its reference: the library
|
|
* hands out one per store and `createEntityDoc` takes it, exactly where `doc_create` takes
|
|
* its store. There is no matching question on the way back - **a read cannot be scoped by
|
|
* store**, upstream or here, so the list below is every note this identity holds, whatever
|
|
* shelf it was written to. That is not a shortcoming of this example; it is what the
|
|
* target can express.
|
|
*/
|
|
function shelf(name: string): Promise<Nuri> {
|
|
return name === "public"
|
|
? storeRegistry.publicStore()
|
|
: name === "protected"
|
|
? storeRegistry.protectedStore()
|
|
: storeRegistry.privateStore();
|
|
}
|
|
|
|
/** The shape of a note: a subject carrying `rdf:type urn:notebook:Note`. */
|
|
const NoteShape = {
|
|
shape: "urn:notebook:NoteShape",
|
|
schema: {
|
|
"urn:notebook:NoteShape": {
|
|
iri: "urn:notebook:NoteShape",
|
|
predicates: [
|
|
{
|
|
iri: RDF_TYPE,
|
|
readablePredicate: "type",
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
dataTypes: [{ literals: [NOTE], valType: "iri" }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
// --- 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.
|
|
|
|
configure({
|
|
ng: realNg,
|
|
useShape: (() => {}) as never, // `useShapeQuery` and `readUnion` do not go through the ORM hook
|
|
init: realInit,
|
|
sharedWallet: {
|
|
fileUrl: "/shared-wallet.ngw",
|
|
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
|
|
},
|
|
});
|
|
|
|
// The library's `init`, not the injected one — and this line is SDK-shaped, kept at
|
|
// migration. It settles the identity before handing the page to the broker, so the
|
|
// round-trip leaves with the identifier in the URL it carries. `realInit` called here
|
|
// would navigate away first, and the barrier would never show.
|
|
//
|
|
// The callback is this application's own business, and only its own: it keeps the session
|
|
// because the SPARQL primitives below take a `session_id`, exactly as the real SDK's do.
|
|
// It used to ALSO hand the library a thunk unwrapping this event into a session shape —
|
|
// plumbing every consumer wrote identically, and this one wrote wrong twice. The library
|
|
// catches the same event on its way through `init` now (2026-08-12), so what is left here
|
|
// is only what this app itself reads.
|
|
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
|
init((event: { status: string; session?: { session_id: string } }) => {
|
|
if (event.status === "loggedin" && event.session) resolve(event.session);
|
|
}, true, []);
|
|
});
|
|
|
|
// --- the acts ---------------------------------------------------------------
|
|
|
|
/** Write a new note on `shelfName`'s shelf. The document is created, then filled. */
|
|
async function writeNote(shelfName: string, title: string, body: string): Promise<Nuri> {
|
|
const doc = await storeRegistry.createEntityDoc(await shelf(shelfName));
|
|
const s = await sessionReady;
|
|
// The type triple is what makes the note findable afterwards: the reactive read asks
|
|
// for a SHAPE, and a shape is pinned on `rdf:type`. Nothing indexes it for us.
|
|
await docs.sparqlUpdate(
|
|
s.session_id,
|
|
`INSERT DATA { <${doc}> <${RDF_TYPE}> <${NOTE}> ; <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
|
|
doc,
|
|
);
|
|
return doc;
|
|
}
|
|
|
|
/**
|
|
* My notes, read the way the library offers them: ONE reactive query over everything this
|
|
* identity holds, shaped as a note.
|
|
*
|
|
* `did:ng:i` is the scope that means the whole local dataset - the caller's own documents
|
|
* are what it can reach. The application does not list its documents and then read them:
|
|
* there is no call to list them with, and there is no question "what is in this store" to
|
|
* ask. It names a SHAPE, and what it holds of that shape comes back.
|
|
*/
|
|
let notes: ShapeObservable | null = null;
|
|
|
|
function noteQuery(): ShapeObservable {
|
|
if (!notes) {
|
|
notes = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
|
|
// One subscription for the life of the page: a note that arrives - written here, or
|
|
// shared by somebody else - re-renders the list with no call from this code.
|
|
notes.subscribe(() => renderNotes());
|
|
}
|
|
return notes;
|
|
}
|
|
|
|
/** Wait until the query has ANSWERED - pending is not an answer, and neither is a failure
|
|
* read as an empty list. */
|
|
function answered(q: ShapeObservable): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
let off: (() => void) | null = null;
|
|
const look = (): void => {
|
|
if (q.getSnapshot().isPending) return;
|
|
off?.();
|
|
resolve();
|
|
};
|
|
off = q.subscribe(look);
|
|
look();
|
|
});
|
|
}
|
|
|
|
function currentNotes(): Note[] {
|
|
return noteQuery()
|
|
.getSnapshot()
|
|
.data.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 q = noteQuery();
|
|
q.refetch();
|
|
await answered(q);
|
|
renderNotes();
|
|
// 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 renderNotes(): void {
|
|
el("notes").innerHTML = currentNotes()
|
|
.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("");
|
|
}
|
|
|
|
function wire(): void {
|
|
// The shelf chooses where the NEXT note is written, and nothing else: a read cannot be
|
|
// scoped by store, so the list below always shows every note this identity holds.
|
|
// Re-rendering on a change is still worth it — a note shared in the meantime shows up.
|
|
el("scope").addEventListener("change", () => void refresh());
|
|
el("write").addEventListener("click", async () => {
|
|
await writeNote((el("scope") as HTMLSelectElement).value, 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 };
|