diff --git a/server/index.ts b/server/index.ts index 2a8708a0..1bac120c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -92,12 +92,13 @@ import { sheetsModule } from "../modules/rsheets/mod"; import { exchangeModule } from "../modules/rexchange/mod"; import { auctionsModule } from "../modules/rauctions/mod"; import { credModule } from "../modules/rcred/mod"; +import { feedsModule } from "../modules/rfeeds/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell"; import { renderOutputListPage } from "./output-list"; import { renderMainLanding, renderSpaceDashboard } from "./landing"; -import { syncServer } from "./sync-instance"; +import { syncServer, flushAndShutdown } from "./sync-instance"; import { loadAllDocs } from "./local-first/doc-persistence"; import { backupRouter } from "./local-first/backup-routes"; import { ipfsRouter } from "./ipfs-routes"; @@ -123,6 +124,27 @@ process.on('unhandledRejection', (reason) => { console.error('[FATAL] Unhandled rejection (swallowed):', reason); }); +// ── Graceful shutdown — flush all in-memory docs to disk ── +let shuttingDown = false; +async function gracefulShutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[Server] ${signal} received — flushing docs to disk...`); + const timeout = setTimeout(() => { + console.error("[Server] Flush timed out after 10s — forcing exit"); + process.exit(1); + }, 10_000); + try { + await flushAndShutdown(); + } catch (e) { + console.error("[Server] Flush error during shutdown:", e); + } + clearTimeout(timeout); + process.exit(0); +} +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + // Register modules (order determines app-switcher menu position) registerModule(canvasModule); registerModule(pubsModule); @@ -155,6 +177,7 @@ registerModule(govModule); // Governance decision circuits registerModule(exchangeModule); // P2P crypto/fiat exchange registerModule(auctionsModule); // Community auctions with USDC registerModule(credModule); // Contribution recognition via CredRank +registerModule(feedsModule); // Community RSS dashboard registerModule(designModule); // Scribus DTP + AI design agent // De-emphasized modules (bottom of menu) registerModule(forumModule); diff --git a/server/local-first/doc-persistence.ts b/server/local-first/doc-persistence.ts index 3cdd13ac..5c8273cd 100644 --- a/server/local-first/doc-persistence.ts +++ b/server/local-first/doc-persistence.ts @@ -43,6 +43,33 @@ function pathToDocId(filePath: string): string { // Debounce timers per docId const saveTimers = new Map>(); +/** Shared write logic — saves Automerge doc to disk, optionally encrypted. */ +async function writeDocToDisk( + docId: string, + doc: Automerge.Doc, + encryptionKeyId?: string, + label = "Saved", +): Promise { + const filePath = docIdToPath(docId); + await mkdir(dirname(filePath), { recursive: true }); + const binary = Automerge.save(doc); + + if (encryptionKeyId) { + const key = await deriveSpaceKey(encryptionKeyId); + const ciphertext = await encryptBinary(binary, key); + const packed = packEncrypted(encryptionKeyId, ciphertext); + await writeFile(filePath, packed); + console.log( + `[DocStore] ${label} ${docId} encrypted (${packed.byteLength} bytes)`, + ); + } else { + await writeFile(filePath, binary); + console.log( + `[DocStore] ${label} ${docId} (${binary.byteLength} bytes)`, + ); + } +} + /** * Debounced save — writes Automerge binary to disk after SAVE_DEBOUNCE_MS. * If encryptionKeyId is provided, encrypts with rSEN header before writing. @@ -60,24 +87,7 @@ export function saveDoc( setTimeout(async () => { saveTimers.delete(docId); try { - const filePath = docIdToPath(docId); - await mkdir(dirname(filePath), { recursive: true }); - const binary = Automerge.save(doc); - - if (encryptionKeyId) { - const key = await deriveSpaceKey(encryptionKeyId); - const ciphertext = await encryptBinary(binary, key); - const packed = packEncrypted(encryptionKeyId, ciphertext); - await writeFile(filePath, packed); - console.log( - `[DocStore] Saved ${docId} encrypted (${packed.byteLength} bytes)`, - ); - } else { - await writeFile(filePath, binary); - console.log( - `[DocStore] Saved ${docId} (${binary.byteLength} bytes)`, - ); - } + await writeDocToDisk(docId, doc, encryptionKeyId); } catch (e) { console.error(`[DocStore] Failed to save ${docId}:`, e); } @@ -85,6 +95,31 @@ export function saveDoc( ); } +/** + * Immediate save — writes Automerge doc to disk synchronously (no debounce). + * Cancels any pending debounced save for this docId. + */ +export async function saveDocImmediate( + docId: string, + doc: Automerge.Doc, + encryptionKeyId?: string, +): Promise { + // Cancel pending debounce timer + const existing = saveTimers.get(docId); + if (existing) { + clearTimeout(existing); + saveTimers.delete(docId); + } + await writeDocToDisk(docId, doc, encryptionKeyId, "Saved immediately"); +} + +/** + * Returns the set of docIds that have pending (unsaved) debounce timers. + */ +export function getPendingDocIds(): Set { + return new Set(saveTimers.keys()); +} + /** * Save an opaque encrypted blob for relay-mode docs. * These are client-encrypted blobs the server cannot decrypt. diff --git a/server/local-first/sync-server.ts b/server/local-first/sync-server.ts index 6827e5e9..cc4184ff 100644 --- a/server/local-first/sync-server.ts +++ b/server/local-first/sync-server.ts @@ -330,6 +330,20 @@ export class SyncServer { return Array.from(this.#docSubscribers.get(docId) ?? []); } + /** + * Iterate all in-memory docs and call the callback for each. + * Uses Promise.allSettled so one failure doesn't block others. + */ + async flushAll(cb: (docId: string, doc: Automerge.Doc) => Promise): Promise { + const tasks: Promise[] = []; + for (const [docId, doc] of this.#docs) { + tasks.push(cb(docId, doc).catch(e => { + console.error(`[SyncServer] flushAll failed for ${docId}:`, e); + })); + } + await Promise.allSettled(tasks); + } + // ---------- Private ---------- #handleSubscribe(peer: Peer, msg: SubscribeMessage): void { diff --git a/server/sync-instance.ts b/server/sync-instance.ts index 4d987775..33128a69 100644 --- a/server/sync-instance.ts +++ b/server/sync-instance.ts @@ -12,7 +12,7 @@ */ import { SyncServer } from "./local-first/sync-server"; -import { saveDoc, saveEncryptedBlob, loadEncryptedBlob } from "./local-first/doc-persistence"; +import { saveDoc, saveDocImmediate, saveEncryptedBlob, loadEncryptedBlob } from "./local-first/doc-persistence"; import { getDocumentData } from "./community-store"; import { spaceKnowledgeIndex } from "./space-knowledge"; @@ -41,9 +41,11 @@ export const syncServer = new SyncServer({ saveDoc(docId, doc, encryptionKeyId); }, onDocEvict: (docId, doc) => { - // Persist to disk before evicting from memory + // Persist to disk immediately before evicting from memory (no debounce!) const encryptionKeyId = getEncryptionKeyId(docId); - saveDoc(docId, doc, encryptionKeyId); + saveDocImmediate(docId, doc, encryptionKeyId).catch(e => { + console.error(`[SyncInstance] Eviction save failed for ${docId}:`, e); + }); }, onRelayBackup: (docId, blob) => { saveEncryptedBlob(docId, blob); @@ -52,3 +54,17 @@ export const syncServer = new SyncServer({ return loadEncryptedBlob(docId); }, }); + +/** + * Flush all in-memory docs to disk immediately, then resolve. + * Called on SIGTERM/SIGINT before process exit. + */ +export async function flushAndShutdown(): Promise { + console.log("[SyncInstance] Flushing all docs to disk before shutdown..."); + const start = Date.now(); + await syncServer.flushAll(async (docId, doc) => { + const encryptionKeyId = getEncryptionKeyId(docId); + await saveDocImmediate(docId, doc, encryptionKeyId); + }); + console.log(`[SyncInstance] Flush complete (${Date.now() - start}ms)`); +} diff --git a/shared/local-first/sync.ts b/shared/local-first/sync.ts index ab1670d0..114ee661 100644 --- a/shared/local-first/sync.ts +++ b/shared/local-first/sync.ts @@ -423,6 +423,8 @@ export class DocSyncManager { disconnect(): void { this.#autoReconnect = false; this.#stopPing(); + // Flush pending IDB saves for cache consistency + this.flush().catch(() => {}); if (this.#ws) { this.#ws.close(); this.#ws = null; diff --git a/website/shell-offline.ts b/website/shell-offline.ts index 7c1d0109..6ed666c8 100644 --- a/website/shell-offline.ts +++ b/website/shell-offline.ts @@ -38,7 +38,13 @@ export function initOffline(spaceSlug: string) { console.warn("[shell] Offline runtime init failed — REST fallback only:", e); }); - // Flush pending writes before the page unloads + // Flush pending writes when tab is hidden (more reliable than beforeunload on mobile/bfcache) + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + runtime.flush(); + } + }); + // Flush pending writes before the page unloads (fallback) window.addEventListener("beforeunload", () => { runtime.flush(); });