Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled
Details
CI/CD / deploy (push) Has been cancelled
Details
This commit is contained in:
commit
c3f8e9ef1b
|
|
@ -92,12 +92,13 @@ import { sheetsModule } from "../modules/rsheets/mod";
|
||||||
import { exchangeModule } from "../modules/rexchange/mod";
|
import { exchangeModule } from "../modules/rexchange/mod";
|
||||||
import { auctionsModule } from "../modules/rauctions/mod";
|
import { auctionsModule } from "../modules/rauctions/mod";
|
||||||
import { credModule } from "../modules/rcred/mod";
|
import { credModule } from "../modules/rcred/mod";
|
||||||
|
import { feedsModule } from "../modules/rfeeds/mod";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
||||||
import { renderOutputListPage } from "./output-list";
|
import { renderOutputListPage } from "./output-list";
|
||||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||||
import { syncServer } from "./sync-instance";
|
import { syncServer, flushAndShutdown } from "./sync-instance";
|
||||||
import { loadAllDocs } from "./local-first/doc-persistence";
|
import { loadAllDocs } from "./local-first/doc-persistence";
|
||||||
import { backupRouter } from "./local-first/backup-routes";
|
import { backupRouter } from "./local-first/backup-routes";
|
||||||
import { ipfsRouter } from "./ipfs-routes";
|
import { ipfsRouter } from "./ipfs-routes";
|
||||||
|
|
@ -123,6 +124,27 @@ process.on('unhandledRejection', (reason) => {
|
||||||
console.error('[FATAL] Unhandled rejection (swallowed):', 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)
|
// Register modules (order determines app-switcher menu position)
|
||||||
registerModule(canvasModule);
|
registerModule(canvasModule);
|
||||||
registerModule(pubsModule);
|
registerModule(pubsModule);
|
||||||
|
|
@ -155,6 +177,7 @@ registerModule(govModule); // Governance decision circuits
|
||||||
registerModule(exchangeModule); // P2P crypto/fiat exchange
|
registerModule(exchangeModule); // P2P crypto/fiat exchange
|
||||||
registerModule(auctionsModule); // Community auctions with USDC
|
registerModule(auctionsModule); // Community auctions with USDC
|
||||||
registerModule(credModule); // Contribution recognition via CredRank
|
registerModule(credModule); // Contribution recognition via CredRank
|
||||||
|
registerModule(feedsModule); // Community RSS dashboard
|
||||||
registerModule(designModule); // Scribus DTP + AI design agent
|
registerModule(designModule); // Scribus DTP + AI design agent
|
||||||
// De-emphasized modules (bottom of menu)
|
// De-emphasized modules (bottom of menu)
|
||||||
registerModule(forumModule);
|
registerModule(forumModule);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,33 @@ function pathToDocId(filePath: string): string {
|
||||||
// Debounce timers per docId
|
// Debounce timers per docId
|
||||||
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
/** Shared write logic — saves Automerge doc to disk, optionally encrypted. */
|
||||||
|
async function writeDocToDisk(
|
||||||
|
docId: string,
|
||||||
|
doc: Automerge.Doc<any>,
|
||||||
|
encryptionKeyId?: string,
|
||||||
|
label = "Saved",
|
||||||
|
): Promise<void> {
|
||||||
|
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.
|
* Debounced save — writes Automerge binary to disk after SAVE_DEBOUNCE_MS.
|
||||||
* If encryptionKeyId is provided, encrypts with rSEN header before writing.
|
* If encryptionKeyId is provided, encrypts with rSEN header before writing.
|
||||||
|
|
@ -60,24 +87,7 @@ export function saveDoc(
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
saveTimers.delete(docId);
|
saveTimers.delete(docId);
|
||||||
try {
|
try {
|
||||||
const filePath = docIdToPath(docId);
|
await writeDocToDisk(docId, doc, encryptionKeyId);
|
||||||
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)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[DocStore] Failed to save ${docId}:`, 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<any>,
|
||||||
|
encryptionKeyId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 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<string> {
|
||||||
|
return new Set(saveTimers.keys());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save an opaque encrypted blob for relay-mode docs.
|
* Save an opaque encrypted blob for relay-mode docs.
|
||||||
* These are client-encrypted blobs the server cannot decrypt.
|
* These are client-encrypted blobs the server cannot decrypt.
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,20 @@ export class SyncServer {
|
||||||
return Array.from(this.#docSubscribers.get(docId) ?? []);
|
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<any>) => Promise<void>): Promise<void> {
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
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 ----------
|
// ---------- Private ----------
|
||||||
|
|
||||||
#handleSubscribe(peer: Peer, msg: SubscribeMessage): void {
|
#handleSubscribe(peer: Peer, msg: SubscribeMessage): void {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SyncServer } from "./local-first/sync-server";
|
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 { getDocumentData } from "./community-store";
|
||||||
import { spaceKnowledgeIndex } from "./space-knowledge";
|
import { spaceKnowledgeIndex } from "./space-knowledge";
|
||||||
|
|
||||||
|
|
@ -41,9 +41,11 @@ export const syncServer = new SyncServer({
|
||||||
saveDoc(docId, doc, encryptionKeyId);
|
saveDoc(docId, doc, encryptionKeyId);
|
||||||
},
|
},
|
||||||
onDocEvict: (docId, doc) => {
|
onDocEvict: (docId, doc) => {
|
||||||
// Persist to disk before evicting from memory
|
// Persist to disk immediately before evicting from memory (no debounce!)
|
||||||
const encryptionKeyId = getEncryptionKeyId(docId);
|
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) => {
|
onRelayBackup: (docId, blob) => {
|
||||||
saveEncryptedBlob(docId, blob);
|
saveEncryptedBlob(docId, blob);
|
||||||
|
|
@ -52,3 +54,17 @@ export const syncServer = new SyncServer({
|
||||||
return loadEncryptedBlob(docId);
|
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<void> {
|
||||||
|
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)`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,8 @@ export class DocSyncManager {
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.#autoReconnect = false;
|
this.#autoReconnect = false;
|
||||||
this.#stopPing();
|
this.#stopPing();
|
||||||
|
// Flush pending IDB saves for cache consistency
|
||||||
|
this.flush().catch(() => {});
|
||||||
if (this.#ws) {
|
if (this.#ws) {
|
||||||
this.#ws.close();
|
this.#ws.close();
|
||||||
this.#ws = null;
|
this.#ws = null;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,13 @@ export function initOffline(spaceSlug: string) {
|
||||||
console.warn("[shell] Offline runtime init failed — REST fallback only:", e);
|
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", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
runtime.flush();
|
runtime.flush();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue