feat: notes module reads from Automerge docs via WebSocket sync (Tier 1)

Notebook detail view now subscribes to Automerge docs instead of REST,
enabling real-time sync across tabs. Note creation and editing use
Automerge.change() with debounced sync. REST fallback after 5s timeout.
Notebook list and search remain REST-based.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 08:32:30 +00:00
parent 5b3c41c559
commit 900eac2e21
2 changed files with 382 additions and 15 deletions

View File

@ -3,8 +3,14 @@
*
* Browse notebooks, create/edit notes with rich text,
* search, tag management.
*
* Notebook list: REST (GET /api/notebooks)
* Notebook detail + notes: Automerge sync via WebSocket
* Search: REST (GET /api/notes?q=...)
*/
import * as Automerge from '@automerge/automerge';
interface Notebook {
id: string;
title: string;
@ -26,6 +32,20 @@ interface Note {
updated_at: string;
}
/** Shape of Automerge notebook doc (matches PG→Automerge migration) */
interface NotebookDoc {
meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
notebook: {
id: string; title: string; slug: string; description: string;
coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number;
};
items: Record<string, {
id: string; notebookId: string; title: string; content: string;
contentPlain: string; type: string; tags: string[]; isPinned: boolean;
sortOrder: number; createdAt: number; updatedAt: number;
}>;
}
class FolkNotesApp extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
@ -38,6 +58,14 @@ class FolkNotesApp extends HTMLElement {
private loading = false;
private error = "";
// Automerge sync state
private ws: WebSocket | null = null;
private doc: Automerge.Doc<NotebookDoc> | null = null;
private syncState: Automerge.SyncState = Automerge.initSyncState();
private subscribedDocId: string | null = null;
private syncConnected = false;
private pingInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
@ -45,9 +73,252 @@ class FolkNotesApp extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.connectSync();
this.loadNotebooks();
}
disconnectedCallback() {
this.disconnectSync();
}
// ── WebSocket Sync ──
private connectSync() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${proto}//${location.host}/ws/${this.space}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.syncConnected = true;
// Keepalive ping every 30s
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
}
}, 30000);
// If we had a pending subscription, re-subscribe
if (this.subscribedDocId && this.doc) {
this.subscribeNotebook(this.subscribedDocId.split(":").pop()!);
}
};
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "sync" && msg.docId === this.subscribedDocId) {
this.handleSyncMessage(new Uint8Array(msg.data));
}
// pong and other messages are ignored
} catch {
// ignore parse errors
}
};
this.ws.onclose = () => {
this.syncConnected = false;
if (this.pingInterval) clearInterval(this.pingInterval);
// Reconnect after 3s
setTimeout(() => {
if (this.isConnected) this.connectSync();
}, 3000);
};
this.ws.onerror = () => {
// onclose will fire after this
};
}
private disconnectSync() {
if (this.pingInterval) clearInterval(this.pingInterval);
if (this.ws) {
this.ws.onclose = null; // prevent reconnect
this.ws.close();
this.ws = null;
}
this.syncConnected = false;
}
private handleSyncMessage(syncMsg: Uint8Array) {
if (!this.doc) return;
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
this.doc, this.syncState, syncMsg
);
this.doc = newDoc;
this.syncState = newSyncState;
// Send reply if needed
const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState);
this.syncState = nextState;
if (reply && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: "sync", docId: this.subscribedDocId,
data: Array.from(reply),
}));
}
this.renderFromDoc();
}
private subscribeNotebook(notebookId: string) {
this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
this.doc = Automerge.init<NotebookDoc>();
this.syncState = Automerge.initSyncState();
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
// Send subscribe
this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] }));
// Send initial sync message to kick off handshake
const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState);
this.syncState = s;
if (m) {
this.ws.send(JSON.stringify({
type: "sync", docId: this.subscribedDocId,
data: Array.from(m),
}));
}
}
private unsubscribeNotebook() {
if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] }));
}
this.subscribedDocId = null;
this.doc = null;
this.syncState = Automerge.initSyncState();
}
/** Extract notebook + notes from Automerge doc into component state */
private renderFromDoc() {
if (!this.doc) return;
const nb = this.doc.notebook;
const items = this.doc.items;
if (!nb) return; // doc not yet synced
// Build notebook data from doc
const notes: Note[] = [];
if (items) {
for (const [, item] of Object.entries(items)) {
notes.push({
id: item.id,
title: item.title || "Untitled",
content: item.content || "",
content_plain: item.contentPlain || "",
type: item.type || "NOTE",
tags: item.tags?.length ? Array.from(item.tags) : null,
is_pinned: item.isPinned || false,
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
});
}
}
// Sort: pinned first, then by sort order, then by updated_at desc
notes.sort((a, b) => {
if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
});
this.selectedNotebook = {
id: nb.id,
title: nb.title,
description: nb.description || "",
cover_color: nb.coverColor || "#3b82f6",
note_count: String(notes.length),
updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(),
notes,
};
// If viewing a specific note, update it from doc too
if (this.view === "note" && this.selectedNote) {
const noteItem = items?.[this.selectedNote.id];
if (noteItem) {
this.selectedNote = {
id: noteItem.id,
title: noteItem.title || "Untitled",
content: noteItem.content || "",
content_plain: noteItem.contentPlain || "",
type: noteItem.type || "NOTE",
tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
is_pinned: noteItem.isPinned || false,
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
};
}
}
this.loading = false;
this.render();
}
// ── Automerge mutations ──
private createNoteViaSync() {
if (!this.doc || !this.selectedNotebook) return;
const noteId = crypto.randomUUID();
const now = Date.now();
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
if (!d.items) (d as any).items = {};
d.items[noteId] = {
id: noteId,
notebookId: this.selectedNotebook!.id,
title: "Untitled Note",
content: "",
contentPlain: "",
type: "NOTE",
tags: [],
isPinned: false,
sortOrder: 0,
createdAt: now,
updatedAt: now,
};
});
this.sendSyncAfterChange();
this.renderFromDoc();
// Open the new note for editing
this.selectedNote = {
id: noteId, title: "Untitled Note", content: "", content_plain: "",
type: "NOTE", tags: null, is_pinned: false,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
this.view = "note";
this.render();
}
private updateNoteField(noteId: string, field: string, value: string) {
if (!this.doc || !this.doc.items?.[noteId]) return;
this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
(d.items[noteId] as any)[field] = value;
d.items[noteId].updatedAt = Date.now();
});
this.sendSyncAfterChange();
}
private sendSyncAfterChange() {
if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState);
this.syncState = newState;
if (msg) {
this.ws.send(JSON.stringify({
type: "sync", docId: this.subscribedDocId,
data: Array.from(msg),
}));
}
}
// ── REST (notebook list + search) ──
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/notes/);
@ -72,6 +343,23 @@ class FolkNotesApp extends HTMLElement {
private async loadNotebook(id: string) {
this.loading = true;
this.render();
// Unsubscribe from any previous notebook
this.unsubscribeNotebook();
// Subscribe to the new notebook via Automerge
this.subscribeNotebook(id);
// Set a timeout — if doc doesn't arrive in 5s, fall back to REST
setTimeout(() => {
if (this.loading && this.view === "notebook") {
this.loadNotebookREST(id);
}
}, 5000);
}
/** REST fallback for notebook detail */
private async loadNotebookREST(id: string) {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/notebooks/${id}`);
@ -83,17 +371,25 @@ class FolkNotesApp extends HTMLElement {
this.render();
}
private async loadNote(id: string) {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/notes/${id}`);
this.selectedNote = await res.json();
} catch {
this.error = "Failed to load note";
private loadNote(id: string) {
// Note is already in the Automerge doc — just select it
if (this.doc?.items?.[id]) {
const item = this.doc.items[id];
this.selectedNote = {
id: item.id,
title: item.title || "Untitled",
content: item.content || "",
content_plain: item.contentPlain || "",
type: item.type || "NOTE",
tags: item.tags?.length ? Array.from(item.tags) : null,
is_pinned: item.isPinned || false,
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
};
} else if (this.selectedNotebook?.notes) {
// Fallback: find in REST-loaded data
this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null;
}
this.loading = false;
this.render();
}
@ -209,6 +505,22 @@ class FolkNotesApp extends HTMLElement {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 20px; font-size: 14px; line-height: 1.6;
}
.note-content[contenteditable] { outline: none; min-height: 100px; cursor: text; }
.note-content[contenteditable]:focus { border-color: #6366f1; }
.editable-title {
background: transparent; border: none; color: inherit; font: inherit;
font-size: 18px; font-weight: 600; width: 100%; outline: none;
padding: 0; flex: 1;
}
.editable-title:focus { border-bottom: 1px solid #6366f1; }
.sync-badge {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
margin-left: 8px; vertical-align: middle;
}
.sync-badge.connected { background: #10b981; }
.sync-badge.disconnected { background: #ef4444; }
.empty { text-align: center; color: #666; padding: 40px; }
.loading { text-align: center; color: #888; padding: 40px; }
@ -262,10 +574,14 @@ class FolkNotesApp extends HTMLElement {
private renderNotebook(): string {
const nb = this.selectedNotebook!;
const syncBadge = this.subscribedDocId
? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
: "";
return `
<div class="header">
<button class="nav-btn" data-back="notebooks">Back</button>
<span class="header-title" style="color:${nb.cover_color}">${this.esc(nb.title)}</span>
<span class="header-title" style="color:${nb.cover_color}">${this.esc(nb.title)}${syncBadge}</span>
<button class="create-btn" id="create-note">+ New Note</button>
</div>
${nb.notes && nb.notes.length > 0
? nb.notes.map((n) => this.renderNoteItem(n)).join("")
@ -293,17 +609,22 @@ class FolkNotesApp extends HTMLElement {
private renderNote(): string {
const n = this.selectedNote!;
const isAutomerge = !!(this.doc?.items?.[n.id]);
return `
<div class="header">
<button class="nav-btn" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">Back</button>
<span class="header-title">${this.getNoteIcon(n.type)} ${this.esc(n.title)}</span>
${isAutomerge
? `<input class="editable-title" id="note-title-input" value="${this.esc(n.title)}" placeholder="Note title...">`
: `<span class="header-title">${this.getNoteIcon(n.type)} ${this.esc(n.title)}</span>`
}
</div>
<div class="note-content">${n.content || '<em style="color:#666">Empty note</em>'}</div>
<div class="note-content" ${isAutomerge ? 'contenteditable="true" id="note-content-editable"' : ""}>${n.content || '<em style="color:#666">Empty note</em>'}</div>
<div style="margin-top:12px;font-size:12px;color:#666;display:flex;gap:12px">
<span>Type: ${n.type}</span>
<span>Created: ${this.formatDate(n.created_at)}</span>
<span>Updated: ${this.formatDate(n.updated_at)}</span>
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
${isAutomerge ? '<span style="color:#10b981">Live</span>' : ""}
</div>
`;
}
@ -312,6 +633,9 @@ class FolkNotesApp extends HTMLElement {
// Create notebook
this.shadow.getElementById("create-notebook")?.addEventListener("click", () => this.createNotebook());
// Create note (Automerge)
this.shadow.getElementById("create-note")?.addEventListener("click", () => this.createNoteViaSync());
// Search
const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement;
let searchTimeout: any;
@ -344,10 +668,46 @@ class FolkNotesApp extends HTMLElement {
el.addEventListener("click", (e) => {
e.stopPropagation();
const target = (el as HTMLElement).dataset.back;
if (target === "notebooks") { this.view = "notebooks"; this.render(); }
if (target === "notebooks") {
this.view = "notebooks";
this.unsubscribeNotebook();
this.selectedNotebook = null;
this.selectedNote = null;
this.render();
}
else if (target === "notebook") { this.view = "notebook"; this.render(); }
});
});
// Editable note title (debounced)
const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
if (titleInput && this.selectedNote) {
let titleTimeout: any;
const noteId = this.selectedNote.id;
titleInput.addEventListener("input", () => {
clearTimeout(titleTimeout);
titleTimeout = setTimeout(() => {
this.updateNoteField(noteId, "title", titleInput.value);
}, 500);
});
}
// Editable note content (debounced)
const contentEl = this.shadow.getElementById("note-content-editable");
if (contentEl && this.selectedNote) {
let contentTimeout: any;
const noteId = this.selectedNote.id;
contentEl.addEventListener("input", () => {
clearTimeout(contentTimeout);
contentTimeout = setTimeout(() => {
const html = contentEl.innerHTML;
this.updateNoteField(noteId, "content", html);
// Also update plain text
const plain = contentEl.textContent?.trim() || "";
this.updateNoteField(noteId, "contentPlain", plain);
}, 800);
});
}
}
private esc(s: string): string {

View File

@ -396,11 +396,18 @@ export default defineConfig({
resolve(__dirname, "dist/modules/vote/vote.css"),
);
// Build notes module component
// Build notes module component (with Automerge WASM support)
await build({
configFile: false,
root: resolve(__dirname, "modules/notes/components"),
plugins: [wasm()],
resolve: {
alias: {
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
},
},
build: {
target: "esnext",
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/notes"),
lib: {