diff --git a/modules/rbooks/components/folk-book-shelf.ts b/modules/rbooks/components/folk-book-shelf.ts index cf27388..e5d6129 100644 --- a/modules/rbooks/components/folk-book-shelf.ts +++ b/modules/rbooks/components/folk-book-shelf.ts @@ -5,6 +5,9 @@ * navigates to the flipbook reader. Authenticated users can upload. */ +import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + interface BookData { id: string; slug: string; @@ -27,6 +30,7 @@ export class FolkBookShelf extends HTMLElement { private _spaceSlug = "personal"; private _searchTerm = ""; private _selectedTag: string | null = null; + private _offlineUnsub: (() => void) | null = null; static get observedAttributes() { return ["space-slug"]; @@ -48,8 +52,49 @@ export class FolkBookShelf extends HTMLElement { connectedCallback() { this.attachShadow({ mode: "open" }); + this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug; this.render(); - if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { this.loadDemoBooks(); } + if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { + this.loadDemoBooks(); + } else { + this.subscribeOffline(); + } + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = booksCatalogDocId(this._spaceSlug) as DocumentId; + const doc = await runtime.subscribe(docId, booksCatalogSchema); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + // Runtime unavailable — server-side hydration handles data + } + } + + private renderFromDoc(doc: BooksCatalogDoc) { + if (!doc?.items || Object.keys(doc.items).length === 0) return; + // Only populate from doc if not already hydrated by server + if (this._books.length > 0) return; + this.books = Object.values(doc.items).map(b => ({ + id: b.id, slug: b.slug, title: b.title, author: b.author, + description: b.description, pdf_size_bytes: b.pdfSizeBytes, + page_count: b.pageCount, tags: b.tags || [], + cover_color: b.coverColor || '#333', + contributor_name: b.contributorName, featured: b.featured, + view_count: b.viewCount, created_at: new Date(b.createdAt).toISOString(), + })); } private loadDemoBooks() { diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index b7b1a3d..fb52192 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -100,6 +100,10 @@ function leafletZoomToSpatial(zoom: number): number { if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8; } +// ── Offline-first imports ── +import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + // ── Component ── class FolkCalendarView extends HTMLElement { @@ -117,6 +121,7 @@ class FolkCalendarView extends HTMLElement { private error = ""; private filteredSources = new Set(); private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; + private _offlineUnsub: (() => void) | null = null; // Spatio-temporal state private temporalGranularity = 4; // MONTH @@ -150,11 +155,14 @@ class FolkCalendarView extends HTMLElement { this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); document.addEventListener("keydown", this.boundKeyHandler); if (this.space === "demo") { this.loadDemoData(); return; } + this.subscribeOffline(); this.loadMonth(); this.render(); } disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; if (this.boundKeyHandler) { document.removeEventListener("keydown", this.boundKeyHandler); this.boundKeyHandler = null; @@ -167,6 +175,55 @@ class FolkCalendarView extends HTMLElement { } } + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = calendarDocId(this.space) as DocumentId; + const doc = await runtime.subscribe(docId, calendarSchema); + this.renderFromCalDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromCalDoc(updated); + }); + } catch { + // Runtime unavailable — REST fallback handles data loading + } + } + + private renderFromCalDoc(doc: CalendarDoc) { + if (!doc) return; + + // Populate sources from Automerge doc + if (doc.sources && Object.keys(doc.sources).length > 0) { + this.sources = Object.values(doc.sources).map(s => ({ + id: s.id, name: s.name, source_type: s.sourceType, + url: s.url, color: s.color, is_active: s.isActive, + is_visible: s.isVisible, owner_id: s.ownerId, + })); + } + + // Populate events from Automerge doc (supplement REST data) + if (doc.events && Object.keys(doc.events).length > 0) { + const docEvents = Object.values(doc.events).map(e => ({ + id: e.id, title: e.title, description: e.description, + start_time: new Date(e.startTime).toISOString(), + end_time: new Date(e.endTime).toISOString(), + all_day: e.allDay, source_id: e.sourceId, + source_name: e.sourceName, source_color: e.sourceColor, + location_name: e.locationName, + location_lat: e.locationLat, location_lng: e.locationLng, + })); + // Only use doc events if REST hasn't loaded yet + if (this.events.length === 0 && docEvents.length > 0) { + this.events = docEvents; + } + } + + this.render(); + } + // ── Keyboard ── private handleKeydown(e: KeyboardEvent) { diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index d3b95c1..2163b01 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -3,6 +3,9 @@ * Shows catalog items, order creation flow, and order status tracking. */ +import { catalogSchema, catalogDocId, type CatalogDoc, orderSchema, type OrderDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + class FolkCartShop extends HTMLElement { private shadow: ShadowRoot; private space = "default"; @@ -10,6 +13,7 @@ class FolkCartShop extends HTMLElement { private orders: any[] = []; private view: "catalog" | "orders" = "catalog"; private loading = true; + private _offlineUnsubs: (() => void)[] = []; constructor() { super(); @@ -32,9 +36,67 @@ class FolkCartShop extends HTMLElement { } this.render(); + this.subscribeOffline(); this.loadData(); } + disconnectedCallback() { + for (const unsub of this._offlineUnsubs) unsub(); + this._offlineUnsubs = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + // Subscribe to catalog (single doc per space) + const catDocId = catalogDocId(this.space) as DocumentId; + const catDoc = await runtime.subscribe(catDocId, catalogSchema); + if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) { + this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({ + id: item.id, title: item.title, description: '', + product_type: item.productType, status: item.status, + tags: item.tags || [], + created_at: new Date(item.createdAt).toISOString(), + })); + this.loading = false; + this.render(); + } + this._offlineUnsubs.push(runtime.onChange(catDocId, (doc: CatalogDoc) => { + if (doc?.items) { + this.catalog = Object.values(doc.items).map(item => ({ + id: item.id, title: item.title, description: '', + product_type: item.productType, status: item.status, + tags: item.tags || [], + created_at: new Date(item.createdAt).toISOString(), + })); + this.render(); + } + })); + + // Subscribe to orders (multi-doc) + const orderDocs = await runtime.subscribeModule('cart', 'orders', orderSchema); + if (orderDocs.size > 0 && this.orders.length === 0) { + const fromDocs: any[] = []; + for (const [docId, doc] of orderDocs) { + const d = doc as OrderDoc; + if (!d?.order) continue; + fromDocs.push({ + id: d.order.id, status: d.order.status, + quantity: d.order.quantity, total_price: d.order.totalPrice, + currency: d.order.currency, + created_at: new Date(d.order.createdAt).toISOString(), + }); + } + if (fromDocs.length > 0) { + this.orders = fromDocs; + this.render(); + } + } + } catch { /* runtime unavailable */ } + } + private loadDemoData() { const now = Date.now(); this.catalog = [ diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts index 11c6ebc..87889b4 100644 --- a/modules/rfiles/components/folk-file-browser.ts +++ b/modules/rfiles/components/folk-file-browser.ts @@ -5,6 +5,9 @@ * space="slug" — shared space to browse (default: "default") */ +import { filesSchema, type FilesDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + class FolkFileBrowser extends HTMLElement { private shadow: ShadowRoot; private space = "default"; @@ -12,6 +15,7 @@ class FolkFileBrowser extends HTMLElement { private cards: any[] = []; private tab: "files" | "cards" = "files"; private loading = false; + private _offlineUnsubs: (() => void)[] = []; constructor() { super(); @@ -27,10 +31,47 @@ class FolkFileBrowser extends HTMLElement { } this.render(); + this.subscribeOffline(); this.loadFiles(); this.loadCards(); } + disconnectedCallback() { + for (const unsub of this._offlineUnsubs) unsub(); + this._offlineUnsubs = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docs = await runtime.subscribeModule('files', 'cards', filesSchema); + for (const [docId, doc] of docs) { + const d = doc as FilesDoc; + if (!d) continue; + + // Populate files metadata from Automerge if REST hasn't loaded yet + if (d.files && Object.keys(d.files).length > 0 && this.files.length === 0) { + this.files = Object.values(d.files).map(f => ({ + id: f.id, name: f.originalFilename, original_filename: f.originalFilename, + title: f.title || f.originalFilename, size: f.fileSize, file_size: f.fileSize, + mime_type: f.mimeType, created_at: new Date(f.createdAt).toISOString(), + space: this.space, + })); + } + + // Populate memory cards + if (d.memoryCards && Object.keys(d.memoryCards).length > 0 && this.cards.length === 0) { + this.cards = Object.values(d.memoryCards); + } + + this._offlineUnsubs.push(runtime.onChange(docId, () => this.render())); + } + if (this.files.length > 0 || this.cards.length > 0) this.render(); + } catch { /* runtime unavailable */ } + } + private loadDemoData() { const now = Date.now(); this.files = [ diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 3f025b0..00559ef 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -16,6 +16,8 @@ import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { mapFlowToNodes } from "../lib/map-flow"; +import { flowsSchema, flowsDocId, type FlowsDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; interface FlowSummary { id: string; @@ -83,6 +85,7 @@ class FolkFlowsApp extends HTMLElement { private loading = false; private error = ""; + private _offlineUnsub: (() => void) | null = null; // Canvas state private canvasZoom = 1; @@ -160,6 +163,50 @@ class FolkFlowsApp extends HTMLElement { this.view = "landing"; this.loadFlows(); } + + // Subscribe to offline-first Automerge doc for flow associations + if (!this.isDemo) this.subscribeOffline(); + } + + disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = flowsDocId(this.space) as DocumentId; + const doc = await runtime.subscribe(docId, flowsSchema); + + // Render cached flow associations immediately + this.renderFlowsFromDoc(doc); + + // Listen for remote changes + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFlowsFromDoc(updated); + }); + } catch { + // Offline runtime unavailable — REST fallback already running + } + } + + private renderFlowsFromDoc(doc: FlowsDoc) { + if (!doc?.spaceFlows) return; + const entries = Object.values(doc.spaceFlows); + if (entries.length === 0 && this.flows.length > 0) return; // Don't clobber REST data with empty doc + // Merge Automerge flow associations as summaries + const fromDoc: FlowSummary[] = entries.map(sf => ({ + id: sf.flowId, + name: sf.flowId, + status: 'active', + })); + if (fromDoc.length > 0 && this.flows.length === 0) { + this.flows = fromDoc; + this.render(); + } } private getApiBase(): string { diff --git a/modules/rforum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts index 778f2a7..bb869f8 100644 --- a/modules/rforum/components/folk-forum-dashboard.ts +++ b/modules/rforum/components/folk-forum-dashboard.ts @@ -5,6 +5,9 @@ * creating new instances. */ +import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + class FolkForumDashboard extends HTMLElement { private shadow: ShadowRoot; private instances: any[] = []; @@ -14,6 +17,7 @@ class FolkForumDashboard extends HTMLElement { private loading = false; private pollTimer: number | null = null; private space = ""; + private _offlineUnsub: (() => void) | null = null; constructor() { super(); @@ -23,6 +27,7 @@ class FolkForumDashboard extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || ""; if (this.space === "demo") { this.loadDemoData(); return; } + this.subscribeOffline(); this.render(); this.loadInstances(); } @@ -39,6 +44,41 @@ class FolkForumDashboard extends HTMLElement { disconnectedCallback() { if (this.pollTimer) clearInterval(this.pollTimer); + this._offlineUnsub?.(); + this._offlineUnsub = null; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + // Forum uses a global doc (not space-scoped) + const docId = FORUM_DOC_ID as DocumentId; + const doc = await runtime.subscribe(docId, forumSchema); + + if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) { + this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({ + id: inst.id, name: inst.name, domain: inst.domain, + status: inst.status, region: inst.region, size: inst.size, + adminEmail: inst.adminEmail, vpsIp: inst.vpsIp, + sslProvisioned: inst.sslProvisioned, + })); + this.render(); + } + + this._offlineUnsub = runtime.onChange(docId, (updated: ForumDoc) => { + if (updated?.instances) { + this.instances = Object.values(updated.instances).map(inst => ({ + id: inst.id, name: inst.name, domain: inst.domain, + status: inst.status, region: inst.region, size: inst.size, + adminEmail: inst.adminEmail, vpsIp: inst.vpsIp, + sslProvisioned: inst.sslProvisioned, + })); + this.render(); + } + }); + } catch { /* runtime unavailable */ } } private getApiBase(): string { diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index 1e88d94..bcf9acb 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -5,9 +5,13 @@ * and approval workflow interface. Includes a help/guide popout. */ +import { mailboxSchema, type MailboxDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + class FolkInboxClient extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; + private _offlineUnsubs: (() => void)[] = []; private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes"; private mailboxes: any[] = []; private threads: any[] = []; @@ -73,9 +77,41 @@ class FolkInboxClient extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } + this.subscribeOffline(); this.loadMailboxes(); } + disconnectedCallback() { + for (const unsub of this._offlineUnsubs) unsub(); + this._offlineUnsubs = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docs = await runtime.subscribeModule('inbox', 'mailboxes', mailboxSchema); + if (docs.size > 0 && this.mailboxes.length === 0) { + const fromDocs: any[] = []; + for (const [docId, doc] of docs) { + const d = doc as MailboxDoc; + if (!d?.mailbox) continue; + fromDocs.push({ + slug: d.mailbox.slug, name: d.mailbox.name, + email: d.mailbox.email, description: d.mailbox.description, + thread_count: Object.keys(d.threads || {}).length, + }); + this._offlineUnsubs.push(runtime.onChange(docId, () => {})); + } + if (fromDocs.length > 0) { + this.mailboxes = fromDocs; + this.render(); + } + } + } catch { /* runtime unavailable */ } + } + private loadDemoData() { this.mailboxes = [ { slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 }, diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index a45c239..cee4a1b 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -10,6 +10,8 @@ */ import * as Automerge from '@automerge/automerge'; +import { notebookSchema } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; @@ -104,13 +106,12 @@ class FolkNotesApp extends HTMLElement { private isRemoteUpdate = false; private editorUpdateTimer: ReturnType | null = null; - // Automerge sync state - private ws: WebSocket | null = null; + // Automerge sync state (via shared runtime) private doc: Automerge.Doc | null = null; - private syncState: Automerge.SyncState = Automerge.initSyncState(); private subscribedDocId: string | null = null; private syncConnected = false; - private pingInterval: ReturnType | null = null; + private _offlineUnsub: (() => void) | null = null; + private _offlineNotebookUnsubs: (() => void)[] = []; // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; @@ -124,10 +125,48 @@ class FolkNotesApp extends HTMLElement { this.space = this.getAttribute("space") || "demo"; this.setupShadow(); if (this.space === "demo") { this.loadDemoData(); return; } - this.connectSync(); + this.subscribeOfflineRuntime(); this.loadNotebooks(); } + private async subscribeOfflineRuntime() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + // Discover all cached notebooks for this space + const docs = await runtime.subscribeModule('notes', 'notebooks', notebookSchema); + this.syncConnected = runtime.isOnline; + + // Listen for connection state changes + this._offlineUnsub = runtime.onStatusChange((status: string) => { + this.syncConnected = status === 'online'; + }); + + // Populate notebook list from cached docs if REST hasn't loaded + if (docs.size > 0 && this.notebooks.length === 0) { + const fromDocs: Notebook[] = []; + for (const [, doc] of docs) { + const d = doc as NotebookDoc; + if (!d?.notebook?.id) continue; + fromDocs.push({ + id: d.notebook.id, title: d.notebook.title, + description: d.notebook.description || '', + cover_color: d.notebook.coverColor || '#3b82f6', + note_count: String(Object.keys(d.items || {}).length), + updated_at: d.notebook.updatedAt ? new Date(d.notebook.updatedAt).toISOString() : new Date().toISOString(), + }); + } + if (fromDocs.length > 0) { + this.notebooks = fromDocs; + this.renderNav(); + } + } + } catch { + // Runtime unavailable — REST fallback handles data + } + } + private setupShadow() { const style = document.createElement('style'); style.textContent = this.getStyles(); @@ -360,107 +399,51 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF disconnectedCallback() { this.destroyEditor(); - this.disconnectSync(); + this._offlineUnsub?.(); + this._offlineUnsub = null; + for (const unsub of this._offlineNotebookUnsubs) unsub(); + this._offlineNotebookUnsubs = []; } - // ── WebSocket Sync ── + // ── Sync (via shared runtime) ── - 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; - this.pingInterval = setInterval(() => { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); - } - }, 30000); - 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)); - } - } catch { - // ignore parse errors - } - }; - - this.ws.onclose = () => { - this.syncConnected = false; - if (this.pingInterval) clearInterval(this.pingInterval); - setTimeout(() => { - if (this.isConnected) this.connectSync(); - }, 3000); - }; - - this.ws.onerror = () => {}; - } - - private disconnectSync() { - if (this.pingInterval) clearInterval(this.pingInterval); - if (this.ws) { - this.ws.onclose = null; - 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; - - 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) { + private async subscribeNotebook(notebookId: string) { this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`; - this.doc = Automerge.init(); - this.syncState = Automerge.initSyncState(); + const runtime = (window as any).__rspaceOfflineRuntime; - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + if (runtime?.isInitialized) { + try { + const docId = this.subscribedDocId as DocumentId; + const doc = await runtime.subscribe(docId, notebookSchema); + this.doc = doc; + this.renderFromDoc(); - this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] })); - - 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), - })); + const unsub = runtime.onChange(docId, (updated: any) => { + this.doc = updated; + this.renderFromDoc(); + }); + this._offlineNotebookUnsubs.push(unsub); + } catch { + // Fallback: initialize empty doc for fresh notebook + this.doc = Automerge.init(); + } + } else { + // No runtime — initialize empty doc + this.doc = Automerge.init(); } } private unsubscribeNotebook() { - if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] })); + if (this.subscribedDocId) { + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime?.isInitialized) { + runtime.unsubscribe(this.subscribedDocId as DocumentId); + } } this.subscribedDocId = null; this.doc = null; - this.syncState = Automerge.initSyncState(); + for (const unsub of this._offlineNotebookUnsubs) unsub(); + this._offlineNotebookUnsubs = []; } /** Extract notebook + notes from Automerge doc into component state */ @@ -586,30 +569,36 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF // ── Automerge mutations ── private createNoteViaSync() { - if (!this.doc || !this.selectedNotebook) return; + if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return; const noteId = crypto.randomUUID(); const now = Date.now(); + const notebookId = this.selectedNotebook.id; - 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: "", - contentFormat: "tiptap-json", - type: "NOTE", - tags: [], - isPinned: false, - sortOrder: 0, - createdAt: now, - updatedAt: now, - }; - }); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime?.isInitialized) { + runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => { + if (!d.items) (d as any).items = {}; + d.items[noteId] = { + id: noteId, notebookId, title: "Untitled Note", + content: "", contentPlain: "", contentFormat: "tiptap-json", + type: "NOTE", tags: [], isPinned: false, sortOrder: 0, + createdAt: now, updatedAt: now, + }; + }); + this.doc = runtime.get(this.subscribedDocId as DocumentId); + } else { + this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { + if (!d.items) (d as any).items = {}; + d.items[noteId] = { + id: noteId, notebookId, title: "Untitled Note", + content: "", contentPlain: "", contentFormat: "tiptap-json", + type: "NOTE", tags: [], isPinned: false, sortOrder: 0, + createdAt: now, updatedAt: now, + }; + }); + } - this.sendSyncAfterChange(); this.renderFromDoc(); // Open the new note @@ -626,26 +615,20 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } private updateNoteField(noteId: string, field: string, value: string) { - if (!this.doc || !this.doc.items?.[noteId]) return; + if (!this.doc || !this.doc.items?.[noteId] || !this.subscribedDocId) 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), - })); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime?.isInitialized) { + runtime.change(this.subscribedDocId as DocumentId, `Update ${field}`, (d: NotebookDoc) => { + (d.items[noteId] as any)[field] = value; + d.items[noteId].updatedAt = Date.now(); + }); + this.doc = runtime.get(this.subscribedDocId as DocumentId); + } else { + this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { + (d.items[noteId] as any)[field] = value; + d.items[noteId].updatedAt = Date.now(); + }); } } diff --git a/modules/rschedule/components/folk-schedule-app.ts b/modules/rschedule/components/folk-schedule-app.ts index 8ae0940..37a842d 100644 --- a/modules/rschedule/components/folk-schedule-app.ts +++ b/modules/rschedule/components/folk-schedule-app.ts @@ -2,9 +2,12 @@ * — schedule management UI. * * Job list with create/edit forms, execution log viewer, - * and manual run triggers. REST-based (no Automerge client sync). + * and manual run triggers. REST-based with offline-first Automerge sync. */ +import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + interface JobData { id: string; name: string; @@ -83,6 +86,7 @@ class FolkScheduleApp extends HTMLElement { private editingReminder: ReminderData | null = null; private loading = false; private runningJobId: string | null = null; + private _offlineUnsub: (() => void) | null = null; // Reminder form state private rFormTitle = ""; @@ -109,9 +113,89 @@ class FolkScheduleApp extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + this.subscribeOffline(); this.loadJobs(); } + disconnectedCallback() { + if (this._offlineUnsub) { + this._offlineUnsub(); + this._offlineUnsub = null; + } + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = scheduleDocId(this.space) as DocumentId; + const doc = await runtime.subscribe(docId, scheduleSchema); + if (doc) this.renderFromDoc(doc as ScheduleDoc); + + this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => { + if (doc) this.renderFromDoc(doc); + }); + } catch { /* runtime unavailable */ } + } + + private renderFromDoc(doc: ScheduleDoc) { + if (doc.jobs && Object.keys(doc.jobs).length > 0) { + this.jobs = Object.values(doc.jobs).map((j) => ({ + id: j.id, + name: j.name, + description: j.description, + enabled: j.enabled, + cronExpression: j.cronExpression, + timezone: j.timezone, + actionType: j.actionType, + actionConfig: j.actionConfig as Record, + lastRunAt: j.lastRunAt, + lastRunStatus: j.lastRunStatus, + lastRunMessage: j.lastRunMessage, + nextRunAt: j.nextRunAt, + runCount: j.runCount, + createdBy: j.createdBy, + createdAt: j.createdAt, + updatedAt: j.updatedAt, + })); + } + + if (doc.reminders && Object.keys(doc.reminders).length > 0) { + this.reminders = Object.values(doc.reminders).map((r) => ({ + id: r.id, + title: r.title, + description: r.description, + remindAt: r.remindAt, + allDay: r.allDay, + timezone: r.timezone, + notifyEmail: r.notifyEmail, + notified: r.notified, + completed: r.completed, + sourceModule: r.sourceModule, + sourceLabel: r.sourceLabel, + sourceColor: r.sourceColor, + cronExpression: r.cronExpression, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + } + + if (doc.log && doc.log.length > 0) { + this.log = doc.log.map((e) => ({ + id: e.id, + jobId: e.jobId, + status: e.status, + message: e.message, + durationMs: e.durationMs, + timestamp: e.timestamp, + })); + } + + this.loading = false; + this.render(); + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rschedule/); diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts index 260a6b2..af5bcf5 100644 --- a/modules/rsplat/components/folk-splat-viewer.ts +++ b/modules/rsplat/components/folk-splat-viewer.ts @@ -7,6 +7,9 @@ * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). */ +import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + interface SplatItem { id: string; slug: string; @@ -30,6 +33,7 @@ export class FolkSplatViewer extends HTMLElement { private _splatDesc = ""; private _viewer: any = null; private _uploadMode: "splat" | "media" = "splat"; + private _offlineUnsub: (() => void) | null = null; static get observedAttributes() { return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; @@ -54,11 +58,46 @@ export class FolkSplatViewer extends HTMLElement { if (this._mode === "viewer") { this.renderViewer(); } else { - if (this._spaceSlug === "demo") this.loadDemoData(); + if (this._spaceSlug === "demo") { + this.loadDemoData(); + } else { + this.subscribeOffline(); + } this.renderGallery(); } } + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = splatScenesDocId(this._spaceSlug) as DocumentId; + const doc = await runtime.subscribe(docId, splatScenesSchema); + this.renderFromDoc(doc); + + this._offlineUnsub = runtime.onChange(docId, (updated: any) => { + this.renderFromDoc(updated); + }); + } catch { + // Runtime unavailable — server-side hydration handles data + } + } + + private renderFromDoc(doc: SplatScenesDoc) { + if (!doc?.items || Object.keys(doc.items).length === 0) return; + if (this._splats.length > 0) return; // Don't clobber server-hydrated data + this._splats = Object.values(doc.items).map(s => ({ + id: s.id, slug: s.slug, title: s.title, description: s.description, + file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes, + view_count: s.viewCount, contributor_name: s.contributorName ?? undefined, + processing_status: s.processingStatus ?? undefined, + source_file_count: s.sourceFileCount, + created_at: new Date(s.createdAt).toISOString(), + })); + if (this._mode === "gallery") this.renderGallery(); + } + private loadDemoData() { this._splats = [ { id: "s1", slug: "matterhorn-scan", title: "Matterhorn Summit", description: "Photogrammetry capture of the Matterhorn peak from drone footage, 42 source images.", file_format: "splat", file_size_bytes: 18_874_368, view_count: 284, contributor_name: "Alpine Explorer Team", processing_status: "ready", created_at: "2026-02-10" }, @@ -71,6 +110,8 @@ export class FolkSplatViewer extends HTMLElement { } disconnectedCallback() { + this._offlineUnsub?.(); + this._offlineUnsub = null; if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts index 97c5da4..cede160 100644 --- a/modules/rtrips/components/folk-trips-planner.ts +++ b/modules/rtrips/components/folk-trips-planner.ts @@ -6,6 +6,9 @@ * Demo: 4 trips with varied statuses and rich destination chains. */ +import { tripSchema, type TripDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + class FolkTripsPlanner extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -14,6 +17,7 @@ class FolkTripsPlanner extends HTMLElement { private trip: any = null; private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview"; private error = ""; + private _offlineUnsubs: (() => void)[] = []; constructor() { super(); @@ -23,10 +27,43 @@ class FolkTripsPlanner extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } + this.subscribeOffline(); this.loadTrips(); this.render(); } + disconnectedCallback() { + for (const unsub of this._offlineUnsubs) unsub(); + this._offlineUnsubs = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docs = await runtime.subscribeModule('trips', 'trips', tripSchema); + if (docs.size > 0 && this.trips.length === 0) { + const fromDocs: any[] = []; + for (const [docId, doc] of docs) { + const d = doc as TripDoc; + if (!d?.trip) continue; + fromDocs.push({ + id: d.trip.id, title: d.trip.title, status: d.trip.status, + start_date: d.trip.startDate, end_date: d.trip.endDate, + budget_total: d.trip.budgetTotal, description: d.trip.description, + destination_count: Object.keys(d.destinations || {}).length, + }); + this._offlineUnsubs.push(runtime.onChange(docId, () => {})); + } + if (fromDocs.length > 0) { + this.trips = fromDocs; + this.render(); + } + } + } catch { /* runtime unavailable */ } + } + private loadDemoData() { this.trips = [ { diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts index 60ce32d..edbfab5 100644 --- a/modules/rvote/components/folk-vote-dashboard.ts +++ b/modules/rvote/components/folk-vote-dashboard.ts @@ -4,6 +4,9 @@ * Browse spaces, create/view proposals, cast votes (ranking + final). */ +import { proposalSchema, type ProposalDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + interface VoteSpace { slug: string; name: string; @@ -40,6 +43,7 @@ class FolkVoteDashboard extends HTMLElement { private selectedProposal: Proposal | null = null; private loading = false; private error = ""; + private _offlineUnsubs: (() => void)[] = []; constructor() { super(); @@ -49,9 +53,46 @@ class FolkVoteDashboard extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } + this.subscribeOffline(); this.loadSpaces(); } + disconnectedCallback() { + for (const unsub of this._offlineUnsubs) unsub(); + this._offlineUnsubs = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docs = await runtime.subscribeModule('vote', 'proposals', proposalSchema); + if (docs.size > 0 && this.proposals.length === 0) { + const fromDocs: Proposal[] = []; + for (const [docId, doc] of docs) { + const d = doc as ProposalDoc; + if (!d?.proposal) continue; + fromDocs.push({ + id: d.proposal.id, title: d.proposal.title, + description: d.proposal.description, status: d.proposal.status, + score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length), + final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo, + final_abstain: d.proposal.finalAbstain, + created_at: new Date(d.proposal.createdAt).toISOString(), + voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null, + }); + this._offlineUnsubs.push(runtime.onChange(docId, () => {})); + } + if (fromDocs.length > 0) { + this.proposals = fromDocs; + this.view = "proposals"; + this.render(); + } + } + } catch { /* runtime unavailable */ } + } + private loadDemoData() { this.spaces = [ { diff --git a/modules/rwork/components/folk-work-board.ts b/modules/rwork/components/folk-work-board.ts index 6a466a4..936084e 100644 --- a/modules/rwork/components/folk-work-board.ts +++ b/modules/rwork/components/folk-work-board.ts @@ -5,6 +5,9 @@ * Supports task creation, status changes, and priority labels. */ +import { boardSchema, type BoardDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; + class FolkWorkBoard extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -20,6 +23,7 @@ class FolkWorkBoard extends HTMLElement { private editingTaskId: string | null = null; private showCreateForm = false; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; + private _offlineUnsubs: (() => void)[] = []; constructor() { super(); @@ -29,10 +33,55 @@ class FolkWorkBoard extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } + this.subscribeOffline(); this.loadWorkspaces(); this.render(); } + disconnectedCallback() { + for (const unsub of this._offlineUnsubs) unsub(); + this._offlineUnsubs = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docs = await runtime.subscribeModule('work', 'boards', boardSchema); + // Build workspace list from cached boards + if (docs.size > 0 && this.workspaces.length === 0) { + const boards: any[] = []; + for (const [docId, doc] of docs) { + const d = doc as BoardDoc; + if (!d?.board) continue; + boards.push({ slug: d.board.slug, name: d.board.name, icon: null, task_count: Object.keys(d.tasks || {}).length }); + this._offlineUnsubs.push(runtime.onChange(docId, () => this.refreshFromDocs())); + } + if (boards.length > 0) { + this.workspaces = boards; + this.render(); + } + } + } catch { /* runtime unavailable */ } + } + + private refreshFromDocs() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime || !this.workspaceSlug) return; + // Reload tasks for current board from runtime + const docId = runtime.makeDocId('work', 'boards', this.workspaceSlug); + const doc = runtime.get(docId) as BoardDoc | undefined; + if (doc?.tasks && Object.keys(doc.tasks).length > 0) { + this.tasks = Object.values(doc.tasks).map(t => ({ + id: t.id, title: t.title, description: t.description, + status: t.status, priority: t.priority, labels: t.labels, + assignee: t.assigneeId, + })); + this.render(); + } + } + private loadDemoData() { this.isDemo = true; this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 2 }]; diff --git a/server/local-first/sync-server.ts b/server/local-first/sync-server.ts index 5091ba4..d6125d9 100644 --- a/server/local-first/sync-server.ts +++ b/server/local-first/sync-server.ts @@ -62,6 +62,19 @@ interface RelayRestoreMessage { data: number[]; } +interface DocListRequestMessage { + type: 'doc-list-request'; + prefix: string; + requestId: string; +} + +interface DocListResponseMessage { + type: 'doc-list-response'; + prefix: string; + docIds: string[]; + requestId: string; +} + type WireMessage = | SyncMessage | SubscribeMessage @@ -69,6 +82,8 @@ type WireMessage = | AwarenessMessage | RelayBackupMessage | RelayRestoreMessage + | DocListRequestMessage + | DocListResponseMessage | { type: 'ping' } | { type: 'pong' }; @@ -199,6 +214,9 @@ export class SyncServer { case 'relay-backup': this.#handleRelayBackup(peer, msg as RelayBackupMessage); break; + case 'doc-list-request': + this.#handleDocListRequest(peer, msg as DocListRequestMessage); + break; case 'ping': this.#sendToPeer(peer, { type: 'pong' }); break; @@ -400,6 +418,16 @@ export class SyncServer { } } + #handleDocListRequest(peer: Peer, msg: DocListRequestMessage): void { + const docIds = this.getDocIds().filter(id => id.startsWith(msg.prefix)); + this.#sendToPeer(peer, { + type: 'doc-list-response', + prefix: msg.prefix, + docIds, + requestId: msg.requestId, + }); + } + #sendSyncToPeer(peer: Peer, docId: string): void { const doc = this.#docs.get(docId); if (!doc) return; diff --git a/server/shell.ts b/server/shell.ts index d75b91a..efc741b 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -100,7 +100,7 @@ export function renderShell(opts: ShellOptions): string { - +

@@ -112,6 +112,7 @@ export function renderShell(opts: ShellOptions): string {
diff --git a/shared/components/rstack-offline-indicator.ts b/shared/components/rstack-offline-indicator.ts new file mode 100644 index 0000000..88c6673 --- /dev/null +++ b/shared/components/rstack-offline-indicator.ts @@ -0,0 +1,107 @@ +/** + * — shows connection status in the shell header. + * + * States: + * - online: hidden (no visual noise) + * - syncing: brief spinner (shown during init) + * - offline: orange dot + "Offline" + * - error: red dot + tooltip with error info + */ + +import type { RuntimeStatus } from '../local-first/runtime'; + +export class RStackOfflineIndicator extends HTMLElement { + #shadow: ShadowRoot; + #status: RuntimeStatus = 'idle'; + #unsub: (() => void) | null = null; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#render(); + + // Connect to runtime when available + const tryConnect = () => { + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + this.#status = runtime.status; + this.#render(); + this.#unsub = runtime.onStatusChange((s: RuntimeStatus) => { + this.#status = s; + this.#render(); + }); + } else { + // Runtime not ready yet, try again shortly + setTimeout(tryConnect, 500); + } + }; + tryConnect(); + } + + disconnectedCallback() { + this.#unsub?.(); + this.#unsub = null; + } + + #render() { + const hidden = this.#status === 'online' || this.#status === 'idle'; + const isOffline = this.#status === 'offline'; + const isError = this.#status === 'error'; + const isSyncing = this.#status === 'initializing'; + + const dotColor = isError ? '#ef4444' : isOffline ? '#f59e0b' : '#3b82f6'; + const label = isError ? 'Sync Error' : isOffline ? 'Offline' : isSyncing ? 'Syncing' : ''; + const tooltip = isError + ? 'Unable to connect to sync server. Changes saved locally.' + : isOffline + ? 'Working offline. Changes will sync when reconnected.' + : isSyncing + ? 'Connecting to sync server...' + : ''; + + this.#shadow.innerHTML = ` + +
+ + ${label} +
+ `; + } + + static define() { + if (!customElements.get('rstack-offline-indicator')) { + customElements.define('rstack-offline-indicator', RStackOfflineIndicator); + } + } +} diff --git a/shared/local-first/index.ts b/shared/local-first/index.ts index e5b2f7e..2d43986 100644 --- a/shared/local-first/index.ts +++ b/shared/local-first/index.ts @@ -43,6 +43,23 @@ export { type WireMessage, } from './sync'; +// Runtime (singleton coordinator for offline-first) +export { + RSpaceOfflineRuntime, + type RuntimeStatus, + type StatusCallback, +} from './runtime'; + +// Storage Quota +export { + getStorageInfo, + requestPersistentStorage, + evictStaleDocs, + isQuotaWarning, + QUOTA_WARNING_PERCENT, + type StorageInfo, +} from './storage-quota'; + // Backup export { BackupSyncManager, diff --git a/shared/local-first/runtime.ts b/shared/local-first/runtime.ts new file mode 100644 index 0000000..67da95b --- /dev/null +++ b/shared/local-first/runtime.ts @@ -0,0 +1,272 @@ +/** + * Offline Runtime — singleton coordinator for all modules. + * + * Composes DocumentManager, EncryptedDocStore, and DocSyncManager into a + * single entry point that web components use via `window.__rspaceOfflineRuntime`. + * + * Lifecycle: + * 1. Shell creates runtime with space slug + * 2. runtime.init() opens IndexedDB, connects WebSocket + * 3. Components call runtime.subscribe(docId, schema) to get a live doc + * 4. Components call runtime.change(docId, msg, fn) for mutations + * 5. Shell calls runtime.flush() on beforeunload + */ + +import * as Automerge from '@automerge/automerge'; +import { + type DocumentId, + type DocSchema, + makeDocumentId, + DocumentManager, +} from './document'; +import { EncryptedDocStore } from './storage'; +import { DocSyncManager } from './sync'; +import { DocCrypto } from './crypto'; +import { + getStorageInfo, + evictStaleDocs, + requestPersistentStorage, + QUOTA_WARNING_PERCENT, +} from './storage-quota'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type RuntimeStatus = 'idle' | 'initializing' | 'online' | 'offline' | 'error'; + +export type StatusCallback = (status: RuntimeStatus) => void; + +// ============================================================================ +// RSpaceOfflineRuntime +// ============================================================================ + +export class RSpaceOfflineRuntime { + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #crypto: DocCrypto; + #space: string; + #status: RuntimeStatus = 'idle'; + #statusListeners = new Set(); + #initialized = false; + + constructor(space: string) { + this.#space = space; + this.#crypto = new DocCrypto(); + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + } + + // ── Getters ── + + get space(): string { return this.#space; } + get isInitialized(): boolean { return this.#initialized; } + get isOnline(): boolean { return this.#sync.isConnected; } + get status(): RuntimeStatus { return this.#status; } + + // ── Lifecycle ── + + /** + * Open IndexedDB and connect to the sync server. + * Safe to call multiple times — no-ops if already initialized. + */ + async init(): Promise { + if (this.#initialized) return; + this.#setStatus('initializing'); + + try { + // 1. Open IndexedDB + await this.#store.open(); + + // 2. Connect WebSocket (non-blocking — works offline) + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + + this.#sync.onConnect(() => this.#setStatus('online')); + this.#sync.onDisconnect(() => this.#setStatus('offline')); + + try { + await this.#sync.connect(wsUrl, this.#space); + this.#setStatus('online'); + } catch { + // WebSocket failed — still usable offline + this.#setStatus('offline'); + } + + this.#initialized = true; + + // 3. Storage housekeeping (non-blocking) + this.#runStorageHousekeeping(); + } catch (e) { + this.#setStatus('error'); + console.error('[OfflineRuntime] Init failed:', e); + throw e; + } + } + + /** + * Subscribe to a document. Loads from IndexedDB (instant), syncs via WebSocket. + * Returns the current Automerge doc. + */ + async subscribe>( + docId: DocumentId, + schema: DocSchema, + ): Promise> { + // Register schema for future use + this.#documents.registerSchema(schema); + + // Try loading from IndexedDB first (instant cache hit) + const cached = await this.#store.load(docId); + const doc = this.#documents.open(docId, schema, cached ?? undefined); + + // Subscribe for sync (sends subscribe + initial sync to server) + await this.#sync.subscribe([docId]); + + return doc; + } + + /** + * Unsubscribe from a document. Persists current state, stops syncing. + */ + unsubscribe(docId: DocumentId): void { + // Save before unsubscribing + const binary = this.#documents.save(docId); + if (binary) { + const meta = this.#documents.getMeta(docId); + this.#store.save(docId, binary, meta ? { + module: meta.module, + collection: meta.collection, + version: meta.version, + } : undefined); + } + + this.#sync.unsubscribe([docId]); + this.#documents.close(docId); + } + + /** + * Mutate a document. Persists to IndexedDB + syncs to server. + */ + change(docId: DocumentId, message: string, fn: (doc: T) => void): void { + this.#sync.change(docId, message, fn); + } + + /** + * Read the current state of a document. + */ + get(docId: DocumentId): Automerge.Doc | undefined { + return this.#documents.get(docId); + } + + /** + * Listen for changes (local or remote) on a document. + * Returns an unsubscribe function. + */ + onChange(docId: DocumentId, cb: (doc: Automerge.Doc) => void): () => void { + // Listen to both DocumentManager (local changes) and DocSyncManager (remote) + const unsub1 = this.#documents.onChange(docId, cb); + const unsub2 = this.#sync.onChange(docId, cb as any); + return () => { unsub1(); unsub2(); }; + } + + /** + * Build a DocumentId for a module using the current space. + */ + makeDocId(module: string, collection: string, itemId?: string): DocumentId { + return makeDocumentId(this.#space, module, collection, itemId); + } + + /** + * List all cached doc IDs for a module/collection from IndexedDB. + */ + async listByModule(module: string, collection?: string): Promise { + return this.#store.listByModule(module, collection); + } + + /** + * Subscribe to all documents for a multi-doc module. + * Discovers doc IDs from both IndexedDB cache and the server, + * then subscribes to each. Returns all discovered docs. + */ + async subscribeModule>( + module: string, + collection: string, + schema: DocSchema, + ): Promise>> { + this.#documents.registerSchema(schema); + + const prefix = `${this.#space}:${module}:${collection}`; + const docIds = await this.#sync.requestDocList(prefix); + + const results = new Map>(); + for (const id of docIds) { + const docId = id as DocumentId; + const doc = await this.subscribe(docId, schema); + results.set(docId, doc); + } + return results; + } + + /** + * Listen for runtime status changes (online/offline/syncing/error). + */ + onStatusChange(cb: StatusCallback): () => void { + this.#statusListeners.add(cb); + return () => { this.#statusListeners.delete(cb); }; + } + + /** + * Flush all pending writes to IndexedDB. Call on beforeunload. + */ + async flush(): Promise { + await Promise.all([ + this.#sync.flush(), + this.#store.flush(), + ]); + } + + /** + * Tear down: flush, disconnect, clear listeners. + */ + destroy(): void { + this.#sync.disconnect(); + this.#statusListeners.clear(); + this.#initialized = false; + this.#setStatus('idle'); + } + + // ── Private ── + + async #runStorageHousekeeping(): Promise { + try { + // Request persistent storage (browser may grant silently) + await requestPersistentStorage(); + + // Check quota and evict stale docs if needed + const info = await getStorageInfo(); + if (info.percent >= QUOTA_WARNING_PERCENT) { + console.warn( + `[OfflineRuntime] Storage usage at ${info.percent}% ` + + `(${(info.usage / 1e6).toFixed(1)}MB / ${(info.quota / 1e6).toFixed(1)}MB). ` + + `Evicting stale documents...` + ); + await evictStaleDocs(); + } + } catch (e) { + console.warn('[OfflineRuntime] Storage housekeeping failed:', e); + } + } + + #setStatus(status: RuntimeStatus): void { + if (this.#status === status) return; + this.#status = status; + for (const cb of this.#statusListeners) { + try { cb(status); } catch { /* ignore */ } + } + } +} diff --git a/shared/local-first/storage-quota.ts b/shared/local-first/storage-quota.ts new file mode 100644 index 0000000..591c398 --- /dev/null +++ b/shared/local-first/storage-quota.ts @@ -0,0 +1,166 @@ +/** + * Storage Quota Management — monitors IndexedDB usage and evicts stale docs. + * + * Uses the Storage API (navigator.storage.estimate) to check quota, + * and evicts documents not accessed in 30+ days via LRU policy. + */ + +import type { DocumentId } from './document'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface StorageInfo { + /** Bytes used */ + usage: number; + /** Total quota in bytes */ + quota: number; + /** Usage as a percentage (0–100) */ + percent: number; + /** Whether persistent storage is granted */ + persisted: boolean; +} + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** Warn the user when storage usage exceeds this percentage */ +export const QUOTA_WARNING_PERCENT = 70; + +/** Evict docs not accessed in this many milliseconds (30 days) */ +const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; + +// ============================================================================ +// PUBLIC API +// ============================================================================ + +/** + * Get current storage usage info. + */ +export async function getStorageInfo(): Promise { + if (!navigator.storage?.estimate) { + return { usage: 0, quota: 0, percent: 0, persisted: false }; + } + + const [estimate, persisted] = await Promise.all([ + navigator.storage.estimate(), + navigator.storage.persisted?.() ?? Promise.resolve(false), + ]); + + const usage = estimate.usage ?? 0; + const quota = estimate.quota ?? 0; + const percent = quota > 0 ? Math.round((usage / quota) * 100) : 0; + + return { usage, quota, percent, persisted }; +} + +/** + * Request persistent storage so the browser won't evict our data under pressure. + * Returns true if granted. + */ +export async function requestPersistentStorage(): Promise { + if (!navigator.storage?.persist) return false; + return navigator.storage.persist(); +} + +/** + * Evict stale documents from IndexedDB. + * Removes docs whose `updatedAt` is older than 30 days. + * Returns the list of evicted document IDs. + */ +export async function evictStaleDocs(dbName = 'rspace-docs'): Promise { + const evicted: DocumentId[] = []; + const cutoff = Date.now() - STALE_THRESHOLD_MS; + + let db: IDBDatabase; + try { + db = await openDb(dbName); + } catch { + return evicted; + } + + try { + // Scan meta store for stale entries + const staleIds = await new Promise((resolve, reject) => { + const tx = db.transaction('meta', 'readonly'); + const request = tx.objectStore('meta').openCursor(); + const ids: string[] = []; + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { resolve(ids); return; } + + const meta = cursor.value as { docId: string; updatedAt: number }; + if (meta.updatedAt < cutoff) { + ids.push(meta.docId); + } + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + + // Delete stale docs + meta + sync states + if (staleIds.length > 0) { + const tx = db.transaction(['docs', 'meta', 'sync'], 'readwrite'); + const docsStore = tx.objectStore('docs'); + const metaStore = tx.objectStore('meta'); + + for (const id of staleIds) { + docsStore.delete(id); + metaStore.delete(id); + evicted.push(id as DocumentId); + } + + // Clean sync states for evicted docs + const syncStore = tx.objectStore('sync'); + const syncCursor = syncStore.openCursor(); + await new Promise((resolve, reject) => { + syncCursor.onsuccess = () => { + const cursor = syncCursor.result; + if (!cursor) { resolve(); return; } + const key = cursor.key as string; + if (staleIds.some(id => key.startsWith(`${id}\0`))) { + cursor.delete(); + } + cursor.continue(); + }; + syncCursor.onerror = () => reject(syncCursor.error); + }); + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + } finally { + db.close(); + } + + if (evicted.length > 0) { + console.log(`[StorageQuota] Evicted ${evicted.length} stale documents`); + } + + return evicted; +} + +/** + * Check if storage usage is above the warning threshold. + */ +export async function isQuotaWarning(): Promise { + const info = await getStorageInfo(); + return info.percent >= QUOTA_WARNING_PERCENT; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +function openDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} diff --git a/shared/local-first/sync.ts b/shared/local-first/sync.ts index 8a04abc..e616603 100644 --- a/shared/local-first/sync.ts +++ b/shared/local-first/sync.ts @@ -65,6 +65,21 @@ export interface RelayRestoreMessage { data: number[]; } +/** Client requests doc IDs matching a prefix (for multi-doc modules). */ +export interface DocListRequestMessage { + type: 'doc-list-request'; + prefix: string; + requestId: string; +} + +/** Server responds with matching doc IDs. */ +export interface DocListResponseMessage { + type: 'doc-list-response'; + prefix: string; + docIds: string[]; + requestId: string; +} + export type WireMessage = | SyncMessage | SubscribeMessage @@ -72,6 +87,8 @@ export type WireMessage = | AwarenessMessage | RelayBackupMessage | RelayRestoreMessage + | DocListRequestMessage + | DocListResponseMessage | { type: 'ping' } | { type: 'pong' }; @@ -89,6 +106,7 @@ export interface DocSyncManagerOptions { type DocChangeCallback = (doc: any) => void; type AwarenessCallback = (msg: AwarenessMessage) => void; type ConnectionCallback = () => void; +type DocListCallback = (docIds: string[]) => void; // ============================================================================ // DocSyncManager (Client) @@ -121,6 +139,9 @@ export class DocSyncManager { // Keep-alive #pingInterval: ReturnType | null = null; + // Pending doc-list requests + #docListCallbacks = new Map(); + constructor(opts: DocSyncManagerOptions) { this.#documents = opts.documents; this.#store = opts.store ?? null; @@ -299,6 +320,46 @@ export class DocSyncManager { } as AwarenessMessage); } + /** + * Request a list of doc IDs matching a prefix from the server. + * Used by multi-doc modules to discover which documents exist. + * Falls back to IndexedDB listing if offline. + */ + async requestDocList(prefix: string): Promise { + // Try IndexedDB first for instant offline results + const parts = prefix.split(':'); + let localIds: string[] = []; + if (this.#store && parts.length >= 2) { + const module = parts[1]; + const collection = parts[2]; + localIds = await this.#store.listByModule(module, collection) as string[]; + localIds = localIds.filter(id => id.startsWith(prefix)); + } + + // If connected, also ask the server + if (this.isConnected) { + const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const serverIds = await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.#docListCallbacks.delete(requestId); + resolve([]); + }, 5000); + this.#docListCallbacks.set(requestId, (ids) => { + clearTimeout(timeout); + this.#docListCallbacks.delete(requestId); + resolve(ids); + }); + this.#send({ type: 'doc-list-request', prefix, requestId }); + }); + + // Merge local + server (unique) + const all = new Set([...localIds, ...serverIds]); + return Array.from(all); + } + + return localIds; + } + /** * Listen for changes on a specific document. */ @@ -416,6 +477,9 @@ export class DocSyncManager { case 'relay-restore': this.#handleRelayRestore(msg as RelayRestoreMessage); break; + case 'doc-list-response': + this.#handleDocListResponse(msg as DocListResponseMessage); + break; case 'pong': // Keep-alive acknowledged break; @@ -489,6 +553,11 @@ export class DocSyncManager { } } + #handleDocListResponse(msg: DocListResponseMessage): void { + const cb = this.#docListCallbacks.get(msg.requestId); + if (cb) cb(msg.docIds); + } + #sendSyncMessage(docId: DocumentId): void { const doc = this.#documents.get(docId); if (!doc) return; diff --git a/website/shell.ts b/website/shell.ts index de1a877..a122744 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -14,8 +14,10 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher" import { RStackTabBar } from "../shared/components/rstack-tab-bar"; import { RStackMi } from "../shared/components/rstack-mi"; import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"; +import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; import { rspaceNavUrl } from "../shared/url-helpers"; import { TabCache } from "../shared/tab-cache"; +import { RSpaceOfflineRuntime } from "../shared/local-first/runtime"; // Expose URL helper globally (used by shell inline scripts + components) (window as any).__rspaceNavUrl = rspaceNavUrl; @@ -31,6 +33,24 @@ RStackSpaceSwitcher.define(); RStackTabBar.define(); RStackMi.define(); RStackSpaceSettings.define(); +RStackOfflineIndicator.define(); + +// ── Offline Runtime ── +// Instantiate the shared runtime from the space slug on the tag. +// Components access it via window.__rspaceOfflineRuntime. +const spaceSlug = document.body?.getAttribute("data-space-slug"); +if (spaceSlug && spaceSlug !== "demo") { + const runtime = new RSpaceOfflineRuntime(spaceSlug); + (window as any).__rspaceOfflineRuntime = runtime; + runtime.init().catch((e: unknown) => { + console.warn("[shell] Offline runtime init failed — REST fallback only:", e); + }); + + // Flush pending writes before the page unloads + window.addEventListener("beforeunload", () => { + runtime.flush(); + }); +} // Reload space list when user signs in/out (to show/hide private spaces) document.addEventListener("auth-change", () => { diff --git a/website/sw.ts b/website/sw.ts index d0f3eaa..dd60d23 100644 --- a/website/sw.ts +++ b/website/sw.ts @@ -4,6 +4,7 @@ declare const self: ServiceWorkerGlobalScope; const CACHE_VERSION = "rspace-v1"; const STATIC_CACHE = `${CACHE_VERSION}-static`; const HTML_CACHE = `${CACHE_VERSION}-html`; +const API_CACHE = `${CACHE_VERSION}-api`; // Vite-hashed assets are immutable (content hash in filename) const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; @@ -11,6 +12,9 @@ const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; // App shell to precache on install const PRECACHE_URLS = ["/", "/canvas.html"]; +// Max age for cached API GET responses (5 minutes) +const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000; + self.addEventListener("install", (event) => { event.waitUntil( caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) @@ -40,16 +44,65 @@ self.addEventListener("fetch", (event) => { // Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached if (!url.protocol.startsWith("http")) return; - // Skip WebSocket and API requests entirely (including module APIs like /space/module/api/...) + // Skip WebSocket requests entirely if ( event.request.url.startsWith("ws://") || event.request.url.startsWith("wss://") || - url.pathname.startsWith("/ws/") || - url.pathname.includes("/api/") + url.pathname.startsWith("/ws/") ) { return; } + // API GET requests: stale-while-revalidate for offline browsing + // Skip POST/PUT/DELETE — those must go to the server + if (url.pathname.includes("/api/")) { + if (event.request.method !== "GET") return; + + event.respondWith( + caches.open(API_CACHE).then(async (cache) => { + const cached = await cache.match(event.request); + + const fetchPromise = fetch(event.request) + .then((response) => { + if (response.ok) { + // Store with a timestamp header for staleness checking + const headers = new Headers(response.headers); + headers.set("x-sw-cached-at", String(Date.now())); + const timedResponse = new Response(response.clone().body, { + status: response.status, + statusText: response.statusText, + headers, + }); + cache.put(event.request, timedResponse); + } + return response; + }) + .catch(() => { + // Offline — return cached if available + if (cached) return cached; + return new Response( + JSON.stringify({ error: "offline", message: "You are offline and this data is not cached." }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + }); + + // Return cached immediately if fresh enough, revalidate in background + if (cached) { + const cachedAt = Number(cached.headers.get("x-sw-cached-at") || 0); + if (Date.now() - cachedAt < API_CACHE_MAX_AGE_MS) { + // Fresh cache — serve immediately, revalidate in background + event.waitUntil(fetchPromise); + return cached; + } + } + + // No cache or stale — wait for network + return fetchPromise; + }) + ); + return; + } + // Immutable hashed assets: cache-first (they never change) if (IMMUTABLE_PATTERN.test(url.pathname)) { event.respondWith( @@ -67,7 +120,7 @@ self.addEventListener("fetch", (event) => { return; } - // HTML pages: network-first with cache fallback + // HTML pages: network-first with cache fallback + offline page if ( event.request.mode === "navigate" || event.request.headers.get("accept")?.includes("text/html") @@ -84,7 +137,7 @@ self.addEventListener("fetch", (event) => { .catch(() => { return caches .match(event.request) - .then((cached) => cached || caches.match("/canvas.html")) as Promise; + .then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise; }) ); return; @@ -106,3 +159,39 @@ self.addEventListener("fetch", (event) => { }) ); }); + +/** Minimal offline fallback page when nothing is cached. */ +function offlineFallbackPage(): Response { + const html = ` + + + + + Offline - rSpace + + + +
+

You're offline

+

rSpace can't reach the server right now. Previously visited pages and + locally cached data are still available.

+ +
+ +`; + return new Response(html, { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +}