Implement RPC contract validation baseline

This commit is contained in:
2026-05-14 15:41:51 -04:00
parent 379c07b50c
commit df367756d0
60 changed files with 10704 additions and 47 deletions

View File

@@ -1,5 +1,19 @@
{
"name": "@mosaiciq/desktop",
"private": true,
"type": "module"
"type": "module",
"scripts": {
"dev:bundle": "tsdown --watch",
"build": "tsdown",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mosaiciq/contracts": "workspace:*",
"@mosaiciq/shared": "workspace:*",
"better-sqlite3": "^12.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"tsdown": "^0.22.0"
}
}

View File

@@ -0,0 +1,192 @@
/**
* Background data refresh service
* Updates prices, filings, and earnings data on a schedule
*/
import type { BrowserWindow } from "electron";
import type { Db } from "@mosaiciq/shared/db";
import { fetchQuote, fetchFilings } from "@mosaiciq/shared/data";
import { upsertCompany } from "@mosaiciq/shared/db";
import { listFilings } from "@mosaiciq/shared/db";
export interface DataRefreshOptions {
priceInterval?: number; // minutes
filingInterval?: number; // minutes
earningsInterval?: number; // minutes
}
export function startDataRefresh(
db: Db,
mainWindow: BrowserWindow,
options: DataRefreshOptions = {}
): () => void {
const {
priceInterval = 5,
filingInterval = 60,
earningsInterval = 1440, // daily
} = options;
let priceTimeout: NodeJS.Timeout | null = null;
let filingTimeout: NodeJS.Timeout | null = null;
let earningsTimeout: NodeJS.Timeout | null = null;
// Get all portfolio holdings
function getPortfolioTickers(): string[] {
const stmt = db.prepare(`
SELECT DISTINCT ticker FROM holdings
UNION
SELECT DISTINCT ticker FROM companies
`);
const rows = stmt.all() as Array<{ ticker: string }>;
return rows.map((r) => r.ticker);
}
// Refresh prices for all tickers
async function refreshPrices() {
try {
const tickers = getPortfolioTickers();
if (tickers.length === 0) return;
console.log(`[DataRefresh] Refreshing prices for ${tickers.length} tickers`);
for (const ticker of tickers) {
const quote = await fetchQuote(ticker);
if (quote) {
// Update company price in database
const company = db.prepare("SELECT * FROM companies WHERE ticker = ?").get(ticker) as any;
if (company) {
db.prepare(`
UPDATE companies
SET price = ?, change_pct = ?, updated_at = datetime('now')
WHERE ticker = ?
`).run(quote.price, quote.changePercent, ticker);
}
}
}
// Notify UI of price updates
mainWindow?.webContents.send("data:prices-updated", {
timestamp: new Date().toISOString(),
});
console.log("[DataRefresh] Price refresh complete");
} catch (error) {
console.error("[DataRefresh] Error refreshing prices:", error);
}
// Schedule next refresh
priceTimeout = setTimeout(refreshPrices, priceInterval * 60 * 1000);
}
// Check for new filings
async function refreshFilings() {
try {
const tickers = getPortfolioTickers();
if (tickers.length === 0) return;
console.log(`[DataRefresh] Checking for new filings for ${tickers.length} tickers`);
for (const ticker of tickers) {
// Get most recent filing date from database
const existingFilings = listFilings(db, ticker);
const since = existingFilings.length > 0
? existingFilings[0].filedDate
: undefined;
const newFilings = await fetchFilings(ticker, { limit: 10, since });
for (const filing of newFilings) {
// Check if filing already exists
const exists = db.prepare(`
SELECT id FROM filings WHERE company_id = ? AND filed_date = ? AND form_type = ?
`).get(ticker, filing.filedDate, filing.formType);
if (!exists) {
db.prepare(`
INSERT INTO filings (id, company_id, form_type, filed_date, title)
VALUES (?, ?, ?, ?, ?)
`).run(
`${ticker}-${filing.formType}-${filing.filedDate}`,
ticker,
filing.formType,
filing.filedDate,
filing.title
);
// Notify UI of new filing
mainWindow?.webContents.send("alert:new-filing", {
ticker,
formType: filing.formType,
title: filing.title,
filedDate: filing.filedDate,
});
}
}
}
console.log("[DataRefresh] Filing refresh complete");
} catch (error) {
console.error("[DataRefresh] Error refreshing filings:", error);
}
// Schedule next refresh
filingTimeout = setTimeout(refreshFilings, filingInterval * 60 * 1000);
}
// Refresh earnings dates
async function refreshEarnings() {
try {
const tickers = getPortfolioTickers();
if (tickers.length === 0) return;
console.log(`[DataRefresh] Updating earnings dates for ${tickers.length} tickers`);
// Import dynamically to avoid circular dependency
const { getEarningsDate, getQuarterString } = await import("@mosaiciq/shared/data");
for (const ticker of tickers) {
const earningsDate = await getEarningsDate(ticker);
if (earningsDate) {
// Check if earnings schedule exists
const existing = db.prepare(`
SELECT id FROM earnings_schedules WHERE company_id = ? AND expected_date = ?
`).get(ticker, earningsDate.toISOString());
if (!existing) {
const quarter = getQuarterString(earningsDate);
db.prepare(`
INSERT INTO earnings_schedules (id, company_id, quarter, expected_date)
VALUES (?, ?, ?, ?)
`).run(
`earnings-${ticker}-${Date.now()}`,
ticker,
quarter,
earningsDate.toISOString()
);
}
}
}
console.log("[DataRefresh] Earnings refresh complete");
} catch (error) {
console.error("[DataRefresh] Error refreshing earnings:", error);
}
// Schedule next refresh
earningsTimeout = setTimeout(refreshEarnings, earningsInterval * 60 * 1000);
}
// Start all refresh cycles
console.log("[DataRefresh] Starting data refresh service");
priceTimeout = setTimeout(refreshPrices, 1000); // Start immediately
filingTimeout = setTimeout(refreshFilings, 5000); // Start after 5s
earningsTimeout = setTimeout(refreshEarnings, 10000); // Start after 10s
// Return cleanup function
return () => {
if (priceTimeout) clearTimeout(priceTimeout);
if (filingTimeout) clearTimeout(filingTimeout);
if (earningsTimeout) clearTimeout(earningsTimeout);
console.log("[DataRefresh] Stopped data refresh service");
};
}

View File

@@ -0,0 +1,165 @@
/**
* File download handler for Electron
* Manages save dialogs and file downloads
*/
import { dialog, shell } from "electron";
import type { BrowserWindow } from "electron";
import path from "node:path";
import { writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { z } from "zod";
import { registerIpcMethod } from "./ipc.js";
export interface FileDownloadOptions {
defaultName: string;
filters: Array<{ name: string; extensions: string[] }>;
data: Buffer;
mimeType?: string;
}
/**
* Handle file download with save dialog
*/
export async function handleFileDownload(
mainWindow: BrowserWindow,
options: FileDownloadOptions
): Promise<{ saved: boolean; filePath?: string; error?: string }> {
const { defaultName, filters, data, mimeType } = options;
try {
// Show save dialog
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: defaultName,
filters,
properties: ["createDirectory"],
});
if (result.canceled || !result.filePath) {
return { saved: false };
}
// Ensure directory exists
const dir = path.dirname(result.filePath);
try {
mkdirSync(dir, { recursive: true });
} catch {
// Directory might already exist
}
// Write file
writeFileSync(result.filePath, data);
// Open file with default application
await shell.openPath(result.filePath);
return { saved: true, filePath: result.filePath };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("[FileHandler] Error saving file:", errorMsg);
return { saved: false, error: errorMsg };
}
}
/**
* Save file to temp directory and return path
*/
export function saveToTemp(
filename: string,
data: Buffer
): { filePath: string; error?: string } {
try {
const tempDir = path.join(tmpdir(), "mosaiciq");
mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, filename);
writeFileSync(filePath, data);
return { filePath };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("[FileHandler] Error saving to temp:", errorMsg);
return { filePath: "", error: errorMsg };
}
}
/**
* Open file with default application
*/
export async function openFile(filePath: string): Promise<boolean> {
try {
await shell.openPath(filePath);
return true;
} catch (error) {
console.error("[FileHandler] Error opening file:", error);
return false;
}
}
/**
* Show file in default file manager
*/
export async function showInFolder(filePath: string): Promise<boolean> {
try {
await shell.showItemInFolder(filePath);
return true;
} catch (error) {
console.error("[FileHandler] Error showing in folder:", error);
return false;
}
}
/**
* Get filters for file type
*/
export function getFileFilters(type: "pdf" | "excel" | "ppt"): Array<{ name: string; extensions: string[] }> {
const filters: Record<string, Array<{ name: string; extensions: string[] }>> = {
pdf: [{ name: "PDF Files", extensions: ["pdf"] }],
excel: [{ name: "Excel Files", extensions: ["xlsx", "xls"] }],
ppt: [{ name: "PowerPoint Files", extensions: ["pptx", "ppt"] }],
};
return filters[type] || [];
}
/**
* Register IPC handlers for file operations
*/
export function registerFileHandlers(mainWindow: BrowserWindow) {
const FileDownloadOptionsSchema: z.ZodType<FileDownloadOptions> = z.object({
defaultName: z.string().trim().min(1),
filters: z.array(z.object({
name: z.string().trim().min(1),
extensions: z.array(z.string().trim().min(1)),
})),
data: z.custom<Buffer>((value) => Buffer.isBuffer(value), { message: "Expected Buffer" }),
mimeType: z.string().optional(),
});
const FileSaveResultSchema = z.object({
saved: z.boolean(),
filePath: z.string().optional(),
error: z.string().optional(),
});
registerIpcMethod({
channel: "file:save",
input: FileDownloadOptionsSchema,
output: FileSaveResultSchema,
handler: (options) => handleFileDownload(mainWindow, options),
});
registerIpcMethod({
channel: "file:open",
input: z.string().trim().min(1),
output: z.boolean(),
handler: openFile,
});
registerIpcMethod({
channel: "file:showInFolder",
input: z.string().trim().min(1),
output: z.boolean(),
handler: showInFolder,
});
}

View File

@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
const handlers = new Map<string, (event: unknown, input: unknown) => Promise<unknown>>();
vi.mock("electron", () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, input: unknown) => Promise<unknown>) => {
handlers.set(channel, handler);
}),
},
}));
describe("registerIpcMethod", () => {
beforeEach(() => {
handlers.clear();
});
it("calls handler for valid payloads", async () => {
const { registerIpcMethod } = await import("./ipc.js");
const handler = vi.fn((input: { name: string }) => ({ greeting: `hello ${input.name}` }));
registerIpcMethod({
channel: "test:valid",
input: z.object({ name: z.string().min(1) }),
output: z.object({ greeting: z.string() }),
handler,
});
await expect(handlers.get("test:valid")?.({}, { name: "Ada" })).resolves.toEqual({
ok: true,
data: { greeting: "hello Ada" },
});
expect(handler).toHaveBeenCalledOnce();
});
it("does not call handler for invalid payloads", async () => {
const { registerIpcMethod } = await import("./ipc.js");
const handler = vi.fn();
registerIpcMethod({
channel: "test:invalid-input",
input: z.object({ name: z.string().min(1) }),
output: z.object({ greeting: z.string() }),
handler,
});
const result = await handlers.get("test:invalid-input")?.({}, { name: "" });
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION_ERROR" } });
expect(handler).not.toHaveBeenCalled();
});
it("returns a controlled error for invalid handler output", async () => {
const { registerIpcMethod } = await import("./ipc.js");
registerIpcMethod({
channel: "test:invalid-output",
input: z.object({ name: z.string() }),
output: z.object({ greeting: z.string() }),
handler: () => ({ greeting: 123 }) as never,
});
const result = await handlers.get("test:invalid-output")?.({}, { name: "Ada" });
expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL_ERROR" } });
});
});

61
apps/desktop/src/ipc.ts Normal file
View File

@@ -0,0 +1,61 @@
import { ipcMain } from "electron";
import { z } from "zod";
export type IpcErrorCode = "VALIDATION_ERROR" | "INTERNAL_ERROR";
export type IpcResult<Output> =
| { ok: true; data: Output }
| { ok: false; error: { code: IpcErrorCode; message: string; detail?: unknown } };
export type IpcMethod<Input, Output> = {
channel: string;
input: z.ZodType<Input>;
output: z.ZodType<Output>;
handler: (input: Input) => Promise<Output> | Output;
};
function validationDetail(error: z.ZodError): unknown {
return error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
}));
}
export function registerIpcMethod<Input, Output>(method: IpcMethod<Input, Output>): void {
ipcMain.handle(method.channel, async (_event, rawInput): Promise<IpcResult<Output>> => {
const parsedInput = method.input.safeParse(rawInput);
if (!parsedInput.success) {
return {
ok: false,
error: {
code: "VALIDATION_ERROR",
message: "Invalid IPC payload.",
detail: validationDetail(parsedInput.error),
},
};
}
try {
const output = await method.handler(parsedInput.data);
const parsedOutput = method.output.safeParse(output);
if (!parsedOutput.success) {
return {
ok: false,
error: {
code: "INTERNAL_ERROR",
message: "Invalid IPC response.",
},
};
}
return { ok: true, data: parsedOutput.data };
} catch (error) {
return {
ok: false,
error: {
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "Unhandled IPC failure.",
},
};
}
});
}

View File

@@ -1,13 +1,21 @@
import { app, BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain, globalShortcut } from "electron";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { handleRpc } from "./rpc.js";
import type { RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc.js";
import { startDataRefresh } from "./dataRefresh.js";
import { startAutoSnapshot } from "./snapshotService.js";
import { registerFileHandlers } from "./fileHandler.js";
import type { ClientSettings, RpcMethod, RpcResult } from "@mosaiciq/contracts/rpc";
import { isRpcMethod, parseRpcRequest, parseRpcResponse } from "@mosaiciq/contracts/rpcSchemas";
import { getDatabase, seedDatabase, getDataDir, updateClientSettings, getClientSettings } from "@mosaiciq/shared/db";
import { eventEmitter } from "@mosaiciq/shared/agents";
import { z } from "zod";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = process.env.VITE_DEV_SERVER_URL || !app.isPackaged;
async function createWindow() {
async function createWindow(): Promise<BrowserWindow> {
const win = new BrowserWindow({
width: 1440,
height: 960,
@@ -16,7 +24,7 @@ async function createWindow() {
title: "MosaicIQ",
backgroundColor: "#f8f5ed",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
@@ -27,15 +35,177 @@ async function createWindow() {
await win.loadURL(process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173");
win.webContents.openDevTools({ mode: "detach" });
} else {
await win.loadFile(path.join(__dirname, "../../../../apps/web/dist/index.html"));
await win.loadFile(path.join(__dirname, "../../web/dist/index.html"));
}
return win;
}
ipcMain.handle("rpc:call", (_event, method: RpcMethod, payload: RpcRequestMap[RpcMethod]) => {
return handleRpc(method, payload);
function rpcValidationDetail(error: z.ZodError): unknown {
return error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }));
}
ipcMain.handle("rpc:call", async (_event, method: unknown, payload: unknown): Promise<RpcResult<RpcMethod>> => {
if (!isRpcMethod(method)) {
return { ok: false, error: { code: "VALIDATION_ERROR", message: "Unknown RPC method." } };
}
let parsedPayload;
try {
parsedPayload = parseRpcRequest(method, payload);
} catch (error) {
return {
ok: false,
error: {
code: "VALIDATION_ERROR",
message: "Invalid RPC payload.",
detail: error instanceof z.ZodError ? rpcValidationDetail(error) : undefined,
},
};
}
const result = await handleRpc(method, parsedPayload);
if (!result.ok) return result;
try {
parseRpcResponse(method, result.data);
return result;
} catch {
return { ok: false, error: { code: "INTERNAL_ERROR", message: "Invalid RPC response." } };
}
});
app.whenReady().then(createWindow);
app.whenReady().then(async () => {
// Initialize database
const db = getDatabase();
// Load client settings from JSON file if exists
const settingsPath = path.join(getDataDir(), "settings.json");
if (existsSync(settingsPath)) {
try {
const saved = JSON.parse(readFileSync(settingsPath, "utf-8")) as Partial<ClientSettings>;
// Merge saved settings into database
updateClientSettings(db, saved);
console.log("[Settings] Loaded settings from", settingsPath);
} catch (error) {
console.warn("[Settings] Failed to load settings file:", error);
}
}
// Seed demo data if empty (check if any companies exist)
const companyCount = db.prepare("SELECT COUNT(*) as count FROM companies").get() as { count: number };
if (companyCount.count === 0) {
seedDatabase(db);
}
const win = await createWindow();
// Register file handlers
registerFileHandlers(win);
// Start data refresh service
const stopDataRefresh = startDataRefresh(db, win, {
priceInterval: 5, // 5 minutes
filingInterval: 60, // 1 hour
earningsInterval: 1440, // daily
});
// Start auto-snapshot service
const stopSnapshot = startAutoSnapshot(db, win, {
interval: 5, // 5 minutes
maxSnapshots: 100,
});
// Register Cmd+S for manual snapshot
globalShortcut.register("CommandOrControl+S", async () => {
const activeCompanyId = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as {
active_company_id: string | null;
} | undefined;
if (activeCompanyId?.active_company_id) {
// Get the manual snapshot function from the snapshot service
const createManualSnapshot = (globalThis as any).createManualSnapshot;
if (createManualSnapshot) {
const snapshot = await createManualSnapshot(activeCompanyId.active_company_id);
// Show notification
win.webContents.send("snapshot:notification", {
title: "Snapshot Created",
message: `Manual snapshot created for ${activeCompanyId.active_company_id.toUpperCase()}`,
snapshotId: snapshot?.id,
});
console.log(`[Shortcut] Manual snapshot created for ${activeCompanyId.active_company_id}`);
}
}
});
// Register Cmd+Shift+S for labeled snapshot
globalShortcut.register("CommandOrControl+Shift+S", async () => {
const activeCompanyId = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as {
active_company_id: string | null;
} | undefined;
if (activeCompanyId?.active_company_id) {
// Send event to renderer to show input dialog
win.webContents.send("snapshot:request-label", {
companyId: activeCompanyId.active_company_id,
});
}
});
// Forward event emitter events to renderer process
const eventTypes = ["agent.progress", "agent.completed", "agent.failed", "agent.started", "agent.streaming", "validation.updated", "memo.updated", "model.updated"];
const unsubscribeForwarders: Array<() => void> = [];
for (const eventType of eventTypes) {
const listener = (data: unknown) => {
win.webContents.send("server-event", data);
};
unsubscribeForwarders.push(eventEmitter.on(eventType, listener));
}
win.on("closed", () => {
for (const unsubscribe of unsubscribeForwarders) unsubscribe();
});
// Handle label response from renderer
ipcMain.on("snapshot:create-with-label", (_event, { companyId, label }) => {
const createManualSnapshot = (globalThis as any).createManualSnapshot;
if (createManualSnapshot) {
createManualSnapshot(companyId, label || undefined).then((snapshot: any) => {
win.webContents.send("snapshot:notification", {
title: "Snapshot Created",
message: `Snapshot "${label || "Manual"}" created`,
snapshotId: snapshot?.id,
});
});
}
});
// Cleanup on app quit
app.on("before-quit", () => {
for (const unsubscribe of unsubscribeForwarders) unsubscribe();
stopDataRefresh();
stopSnapshot();
globalShortcut.unregisterAll();
});
});
// Save settings when they change
ipcMain.on("settings:save", (_event, settings: Partial<ClientSettings>) => {
const db = getDatabase();
updateClientSettings(db, settings);
// Also persist to JSON file for backup and portability
const settingsPath = path.join(getDataDir(), "settings.json");
try {
const currentSettings = getClientSettings(db);
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), "utf-8");
console.log("[Settings] Saved settings to", settingsPath);
} catch (error) {
console.error("[Settings] Failed to save settings file:", error);
}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();

View File

@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc.js";
import type { RpcClient, RpcMethod, RpcRequestMap, ServerEvent } from "../../../packages/contracts/src/rpc.js";
import { ServerEventSchema, ServerEventTypeSchema } from "../../../packages/contracts/src/rpcSchemas.js";
const api: RpcClient = {
call(method, payload) {
@@ -7,7 +8,67 @@ const api: RpcClient = {
}
};
contextBridge.exposeInMainWorld("mosaic", api);
// Event listener management
const eventListeners = new Map<string, Set<(data: unknown) => void>>();
// Subscribe to IPC events from main process
ipcRenderer.on("server-event", (_event, eventData) => {
const parsedEvent = ServerEventSchema.safeParse(eventData);
if (!parsedEvent.success) {
console.warn("[IPC] Ignoring invalid server event.");
return;
}
const event = parsedEvent.data as ServerEvent;
const listeners = eventListeners.get(event.type);
if (listeners) {
for (const listener of listeners) {
try {
listener(event);
} catch (error) {
console.error(`[IPC] Error in event listener for ${event.type}:`, error);
}
}
}
});
contextBridge.exposeInMainWorld("mosaic", {
...api,
// Subscribe to server events
on(eventType: string, callback: (data: unknown) => void) {
const parsedEventType = ServerEventTypeSchema.safeParse(eventType);
if (!parsedEventType.success) {
console.warn(`[IPC] Ignoring subscription for unknown event type: ${eventType}`);
return () => {};
}
const type = parsedEventType.data;
if (!eventListeners.has(type)) {
eventListeners.set(type, new Set());
}
eventListeners.get(type)!.add(callback);
// Return unsubscribe function
return () => {
const listeners = eventListeners.get(type);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) {
eventListeners.delete(type);
}
}
};
},
// Remove all listeners for an event type
removeAllListeners(eventType?: string) {
if (eventType) {
const parsedEventType = ServerEventTypeSchema.safeParse(eventType);
if (parsedEventType.success) {
eventListeners.delete(parsedEventType.data);
}
} else {
eventListeners.clear();
}
}
});
declare global {
interface Window {
@@ -16,6 +77,8 @@ declare global {
method: T,
payload: RpcRequestMap[T]
): Promise<import("../../../packages/contracts/src/rpc.js").RpcResult<T>>;
on(eventType: string, callback: (data: unknown) => void): () => void;
removeAllListeners(eventType?: string): void;
};
}
}

View File

@@ -1,9 +1,19 @@
import type { RpcMethod, RpcRequestMap, RpcResult } from "../../../packages/contracts/src/rpc.js";
import { handleMockRpc } from "../../../packages/shared/src/mockRpc.js";
import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc";
import { getDatabase, createRpcHandler } from "@mosaiciq/shared/db";
// Get database instance (initialized in main.ts)
let dbInstance = getDatabase();
// Create RPC handler with database
const dbRpcHandler = createRpcHandler(dbInstance);
export async function handleRpc<T extends RpcMethod>(
method: T,
payload: RpcRequestMap[T]
): Promise<RpcResult<T>> {
return handleMockRpc(method, payload);
return dbRpcHandler(method, payload);
}
export function setDatabase(db: typeof dbInstance): void {
dbInstance = db;
}

View File

@@ -0,0 +1,237 @@
/**
* Auto-snapshot service
* Creates periodic snapshots of company state
*/
import type { BrowserWindow } from "electron";
import type { Db } from "@mosaiciq/shared/db";
import { createSnapshot, listSnapshots, getSnapshot } from "@mosaiciq/shared/db";
import type { Snapshot } from "@mosaiciq/contracts/rpc";
export interface SnapshotOptions {
interval?: number; // minutes
maxSnapshots?: number; // per company
}
interface SnapshotChangeTracker {
[companyId: string]: {
changeCount: number;
lastSnapshot: number;
};
}
export function startAutoSnapshot(
db: Db,
mainWindow: BrowserWindow,
options: SnapshotOptions = {}
): () => void {
const {
interval = 5, // 5 minutes
maxSnapshots = 100,
} = options;
let timeout: NodeJS.Timeout | null = null;
const tracker: SnapshotChangeTracker = {};
// Get active company ID
function getActiveCompanyId(): string | null {
const row = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as {
active_company_id: string | null;
} | undefined;
return row?.active_company_id || null;
}
// Capture current company state as JSON
function captureCompanyState(companyId: string): string {
// Get memo sections
const memoSections = db.prepare(`
SELECT id, title, content, updated_at, primary_agent
FROM memo_sections
WHERE company_id = ?
`).all(companyId);
// Get model data
const models = db.prepare(`
SELECT id, tab FROM models WHERE company_id = ?
`).all(companyId);
const modelData = models.map((model: any) => {
const headers = db.prepare("SELECT label FROM model_headers WHERE model_id = ? ORDER BY position").all(model.id);
const rows = db.prepare("SELECT label, kind, values FROM model_rows WHERE model_id = ? ORDER BY position").all(model.id);
return {
id: model.id,
tab: model.tab,
headers,
rows,
};
});
// Get workspace sections
const workspaceSections = db.prepare(`
SELECT id, title, content, validation_state, source_agent
FROM workspace_sections
WHERE company_id = ?
`).all(companyId);
// Get annotations
const annotations = db.prepare(`
SELECT id, section_id, kind, selected_text, comment, created_by, created_at, status
FROM memo_annotations
WHERE company_id = ?
`).all(companyId);
// Get risks
const risks = db.prepare(`
SELECT id, risk, category, severity, likelihood, mitigation, status
FROM risks
WHERE company_id = ?
`).all(companyId);
// Get catalysts
const catalysts = db.prepare(`
SELECT id, date, event, impact, thesis_relevance, source
FROM catalysts
WHERE company_id = ?
`).all(companyId);
return JSON.stringify({
companyId,
capturedAt: new Date().toISOString(),
memoSections,
modelData,
workspaceSections,
annotations,
risks,
catalysts,
});
}
// Create a manual snapshot
async function createManualSnapshot(companyId?: string, label?: string): Promise<Snapshot | null> {
const targetId = companyId || getActiveCompanyId();
if (!targetId) return null;
try {
const state = captureCompanyState(targetId);
const snapshot = createSnapshot(db, targetId, state, {
label,
type: "manual",
changeCount: tracker[targetId]?.changeCount || 0,
});
// Clean up old snapshots if over limit
cleanupOldSnapshots(targetId, maxSnapshots);
// Notify UI
mainWindow?.webContents.send("snapshot:created", {
companyId: targetId,
snapshotId: snapshot.id,
label: snapshot.label || snapshot.type,
});
// Reset change count
if (tracker[targetId]) {
tracker[targetId].changeCount = 0;
tracker[targetId].lastSnapshot = Date.now();
}
console.log(`[Snapshot] Created manual snapshot for ${targetId}`);
return snapshot;
} catch (error) {
console.error("[Snapshot] Error creating manual snapshot:", error);
return null;
}
}
// Auto-snapshot on interval
async function performAutoSnapshot() {
const companyId = getActiveCompanyId();
if (!companyId) {
// Schedule next check
timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000);
return;
}
// Initialize tracker if needed
if (!tracker[companyId]) {
tracker[companyId] = {
changeCount: 0,
lastSnapshot: Date.now(),
};
}
// Check if there are changes
const companyTracker = tracker[companyId];
if (companyTracker.changeCount > 0) {
try {
const state = captureCompanyState(companyId);
createSnapshot(db, companyId, state, {
type: "auto",
changeCount: companyTracker.changeCount,
});
// Clean up old snapshots
cleanupOldSnapshots(companyId, maxSnapshots);
// Notify UI
mainWindow?.webContents.send("snapshot:created", {
companyId,
snapshotId: `snapshot-${Date.now()}`,
label: "Auto-snapshot",
});
// Reset change count
companyTracker.changeCount = 0;
companyTracker.lastSnapshot = Date.now();
console.log(`[Snapshot] Auto-snapshot created for ${companyId}`);
} catch (error) {
console.error("[Snapshot] Error creating auto-snapshot:", error);
}
}
// Schedule next snapshot
timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000);
}
// Keep only the most recent snapshots
function cleanupOldSnapshots(companyId: string, maxCount: number) {
const snapshots = listSnapshots(db, companyId);
if (snapshots.length > maxCount) {
// Delete oldest snapshots beyond the limit
const toDelete = snapshots.slice(maxCount);
const stmt = db.prepare("DELETE FROM snapshots WHERE id = ?");
for (const snapshot of toDelete) {
stmt.run(snapshot.id);
}
console.log(`[Snapshot] Cleaned up ${toDelete.length} old snapshots for ${companyId}`);
}
}
// Track changes to data
function trackChange(companyId: string) {
if (!tracker[companyId]) {
tracker[companyId] = {
changeCount: 0,
lastSnapshot: Date.now(),
};
}
tracker[companyId].changeCount++;
}
// Start auto-snapshot cycle
console.log("[Snapshot] Starting auto-snapshot service");
timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000);
// Expose the manual snapshot function
(globalThis as any).createManualSnapshot = createManualSnapshot;
(globalThis as any).trackSnapshotChange = trackChange;
// Return cleanup function
return () => {
if (timeout) clearTimeout(timeout);
delete (globalThis as any).createManualSnapshot;
delete (globalThis as any).trackSnapshotChange;
console.log("[Snapshot] Stopped auto-snapshot service");
};
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node", "electron"],
"lib": ["ES2022", "DOM"]
},
"include": ["src", "tsdown.config.ts"]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "tsdown";
const shared = {
format: "cjs" as const,
outDir: "dist-electron",
sourcemap: true,
outExtensions: () => ({ js: ".cjs" }),
};
export default defineConfig([
{
...shared,
entry: ["src/main.ts"],
clean: true,
noExternal: (id) => id.startsWith("@mosaiciq/"),
},
{
...shared,
entry: ["src/preload.ts"],
},
]);