feat: wire offline-first Automerge sync to all 13 rSpace modules

Add shared RSpaceOfflineRuntime singleton that coordinates IndexedDB
persistence (EncryptedDocStore), WebSocket sync (DocSyncManager), and
in-memory Automerge docs (DocumentManager) for all module web components.

- Phase 0: runtime.ts singleton, shell integration, beforeunload flush
- Phase 1: rstack-offline-indicator status dot in shell header
- Phase 2: service worker stale-while-revalidate for API GETs + offline fallback
- Phase 3: storage-quota.ts with LRU eviction (30d) and quota warnings (70%)
- Phase 4: Tier 1 single-doc modules (rFlows, rCal, rBooks, rSplat)
- Phase 5: Tier 2 multi-doc modules (rNotes, rWork, rInbox, rVote, rTrips, rFiles)
  with doc-list-request/response wire protocol for document discovery
- Phase 6: Tier 3 special cases (rCart hybrid, rForum global doc, rSchedule)

Data now loads instantly from IndexedDB, syncs via WebSocket when online,
and remains browsable offline. rNotes migrated from inline Automerge WS
to shared runtime. All modules fall back to REST when runtime unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 19:07:59 -08:00
parent f62da0841c
commit 4cc420d0f6
22 changed files with 1472 additions and 140 deletions

View File

@ -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() {

View File

@ -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<string>();
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) {

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 {

View File

@ -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 {

View File

@ -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 },

View File

@ -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<typeof setTimeout> | null = null;
// Automerge sync state
private ws: WebSocket | null = null;
// Automerge sync state (via shared runtime)
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;
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%)</code></pre><p><em>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<NotebookDoc>();
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<NotebookDoc>();
}
} else {
// No runtime — initialize empty doc
this.doc = Automerge.init<NotebookDoc>();
}
}
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%)</code></pre><p><em>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%)</code></pre><p><em>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();
});
}
}

View File

@ -2,9 +2,12 @@
* <folk-schedule-app> 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<string, unknown>,
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/);

View File

@ -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;

View File

@ -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 = [
{

View File

@ -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 = [
{

View File

@ -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 }];

View File

@ -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;

View File

@ -100,7 +100,7 @@ export function renderShell(opts: ShellOptions): string {
<style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style>
</head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}">
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}">
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
@ -112,6 +112,7 @@ export function renderShell(opts: ShellOptions): string {
</div>
<div class="rstack-header__right">
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
<rstack-offline-indicator></rstack-offline-indicator>
<rstack-notification-bell></rstack-notification-bell>
<rstack-identity></rstack-identity>
</div>

View File

@ -0,0 +1,107 @@
/**
* <rstack-offline-indicator> 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 = `
<style>
:host { display: inline-flex; align-items: center; gap: 6px; }
.indicator {
display: ${hidden ? 'none' : 'inline-flex'};
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 12px;
background: var(--rs-bg-secondary, rgba(255,255,255,0.06));
font-size: 11px;
color: var(--rs-text-secondary, #999);
cursor: default;
user-select: none;
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: ${dotColor};
flex-shrink: 0;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.dot.syncing {
animation: pulse 1.2s ease-in-out infinite;
background: #3b82f6;
}
</style>
<div class="indicator" title="${tooltip}">
<span class="dot${isSyncing ? ' syncing' : ''}"></span>
<span>${label}</span>
</div>
`;
}
static define() {
if (!customElements.get('rstack-offline-indicator')) {
customElements.define('rstack-offline-indicator', RStackOfflineIndicator);
}
}
}

View File

@ -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,

View File

@ -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<StatusCallback>();
#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<void> {
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<T extends Record<string, any>>(
docId: DocumentId,
schema: DocSchema<T>,
): Promise<Automerge.Doc<T>> {
// 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<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void {
this.#sync.change<T>(docId, message, fn);
}
/**
* Read the current state of a document.
*/
get<T>(docId: DocumentId): Automerge.Doc<T> | undefined {
return this.#documents.get<T>(docId);
}
/**
* Listen for changes (local or remote) on a document.
* Returns an unsubscribe function.
*/
onChange<T>(docId: DocumentId, cb: (doc: Automerge.Doc<T>) => void): () => void {
// Listen to both DocumentManager (local changes) and DocSyncManager (remote)
const unsub1 = this.#documents.onChange<T>(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<DocumentId[]> {
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<T extends Record<string, any>>(
module: string,
collection: string,
schema: DocSchema<T>,
): Promise<Map<DocumentId, Automerge.Doc<T>>> {
this.#documents.registerSchema(schema);
const prefix = `${this.#space}:${module}:${collection}`;
const docIds = await this.#sync.requestDocList(prefix);
const results = new Map<DocumentId, Automerge.Doc<T>>();
for (const id of docIds) {
const docId = id as DocumentId;
const doc = await this.subscribe<T>(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<void> {
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<void> {
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 */ }
}
}
}

View File

@ -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 (0100) */
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<StorageInfo> {
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<boolean> {
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<DocumentId[]> {
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<string[]>((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<void>((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<void>((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<boolean> {
const info = await getStorageInfo();
return info.percent >= QUOTA_WARNING_PERCENT;
}
// ============================================================================
// HELPERS
// ============================================================================
function openDb(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

View File

@ -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<typeof setInterval> | null = null;
// Pending doc-list requests
#docListCallbacks = new Map<string, DocListCallback>();
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<string[]> {
// 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<string[]>((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;

View File

@ -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 <body> 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", () => {

View File

@ -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<Response>;
.then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise<Response>;
})
);
return;
@ -106,3 +159,39 @@ self.addEventListener("fetch", (event) => {
})
);
});
/** Minimal offline fallback page when nothing is cached. */
function offlineFallbackPage(): Response {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - rSpace</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0;
background: #0a0a0f; color: #e0e0e0; }
.offline { text-align: center; max-width: 400px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #999; line-height: 1.5; }
button { margin-top: 1rem; padding: 8px 20px; border-radius: 6px;
border: 1px solid #333; background: #1a1a2e; color: #e0e0e0;
cursor: pointer; font-size: 0.9rem; }
button:hover { background: #2a2a3e; }
</style>
</head>
<body>
<div class="offline">
<h1>You're offline</h1>
<p>rSpace can't reach the server right now. Previously visited pages and
locally cached data are still available.</p>
<button onclick="location.reload()">Try Again</button>
</div>
</body>
</html>`;
return new Response(html, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}