Implement local SQLite backend and reactive UI

This commit is contained in:
2026-05-14 21:28:32 -04:00
parent 4aa3f7b362
commit f95b0ae912
35 changed files with 5444 additions and 2009 deletions

View File

@@ -12,6 +12,8 @@ export type IpcMethod<Input, Output> = {
input: z.ZodType<Input>;
output: z.ZodType<Output>;
handler: (input: Input) => Promise<Output> | Output;
rawArgs?: boolean;
rawResult?: boolean;
};
function validationDetail(error: z.ZodError): unknown {
@@ -22,10 +24,11 @@ function validationDetail(error: z.ZodError): unknown {
}
export function registerIpcMethod<Input, Output>(method: IpcMethod<Input, Output>): void {
ipcMain.handle(method.channel, async (_event, rawInput): Promise<IpcResult<Output>> => {
ipcMain.handle(method.channel, async (_event, ...rawArgs): Promise<IpcResult<Output> | Output> => {
const rawInput = method.rawArgs ? rawArgs : rawArgs[0];
const parsedInput = method.input.safeParse(rawInput);
if (!parsedInput.success) {
return {
const errorResult: IpcResult<Output> = {
ok: false,
error: {
code: "VALIDATION_ERROR",
@@ -33,29 +36,32 @@ export function registerIpcMethod<Input, Output>(method: IpcMethod<Input, Output
detail: validationDetail(parsedInput.error),
},
};
return method.rawResult ? errorResult as Output : errorResult;
}
try {
const output = await method.handler(parsedInput.data);
const parsedOutput = method.output.safeParse(output);
if (!parsedOutput.success) {
return {
const errorResult: IpcResult<Output> = {
ok: false,
error: {
code: "INTERNAL_ERROR",
message: "Invalid IPC response.",
},
};
return method.rawResult ? errorResult as Output : errorResult;
}
return { ok: true, data: parsedOutput.data };
return method.rawResult ? parsedOutput.data : { ok: true, data: parsedOutput.data };
} catch (error) {
return {
const errorResult: IpcResult<Output> = {
ok: false,
error: {
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "Unhandled IPC failure.",
},
};
return method.rawResult ? errorResult as Output : errorResult;
}
});
}

View File

@@ -8,9 +8,10 @@ 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 { getDatabase, bootstrapDatabase, getDataDir, updateClientSettings, getClientSettings } from "@mosaiciq/shared/db";
import { eventEmitter } from "@mosaiciq/shared/agents";
import { z } from "zod";
import { registerIpcMethod } from "./ipc.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = process.env.VITE_DEV_SERVER_URL || !app.isPackaged;
@@ -44,7 +45,17 @@ 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>> => {
registerIpcMethod<unknown[], RpcResult<RpcMethod>>({
channel: "rpc:call",
input: z.tuple([z.unknown(), z.unknown()]),
output: z.custom<RpcResult<RpcMethod>>((value) => {
if (!value || typeof value !== "object" || !("ok" in value)) return false;
const result = value as RpcResult<RpcMethod>;
return result.ok ? "data" in result : "error" in result;
}),
rawArgs: true,
rawResult: true,
handler: async ([method, payload]): Promise<RpcResult<RpcMethod>> => {
if (!isRpcMethod(method)) {
return { ok: false, error: { code: "VALIDATION_ERROR", message: "Unknown RPC method." } };
}
@@ -69,9 +80,14 @@ ipcMain.handle("rpc:call", async (_event, method: unknown, payload: unknown): Pr
try {
parseRpcResponse(method, result.data);
return result;
} catch {
} catch (error) {
console.error("[RPC] Invalid response:", {
method,
detail: error instanceof z.ZodError ? rpcValidationDetail(error) : undefined,
});
return { ok: false, error: { code: "INTERNAL_ERROR", message: "Invalid RPC response." } };
}
},
});
app.whenReady().then(async () => {
@@ -91,11 +107,7 @@ app.whenReady().then(async () => {
}
}
// 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);
}
bootstrapDatabase(db);
const win = await createWindow();

View File

@@ -5,6 +5,9 @@ const shared = {
outDir: "dist-electron",
sourcemap: true,
outExtensions: () => ({ js: ".cjs" }),
deps: {
neverBundle: ["electron"],
},
};
export default defineConfig([
@@ -12,7 +15,10 @@ export default defineConfig([
...shared,
entry: ["src/main.ts"],
clean: true,
noExternal: (id) => id.startsWith("@mosaiciq/"),
deps: {
...shared.deps,
alwaysBundle: (id: string) => id.startsWith("@mosaiciq/"),
},
},
{
...shared,

View File

@@ -1,4 +1,4 @@
import type { Alert, Catalyst, EarningsSchedule, ExportRecord, Filing, Holding, Risk, Screen } from "@mosaiciq/contracts/rpc";
import type { Screen } from "@mosaiciq/contracts/rpc";
export const screens: Screen[] = ["home", "workspace", "model", "memo", "agents"];
@@ -26,55 +26,6 @@ export const agentCatalog: Array<[string, string, string, string]> = [
["qa", "Model QA Agent", "Formula auditing, balance sheet checks, sanity tests", "cross-cutting"]
];
export const extraHoldings: Holding[] = [
{ ticker: "BJ", name: "BJ's Wholesale Club", price: 84.2, changePct: 0.4, weight: 8 },
{ ticker: "KR", name: "Kroger Co", price: 62.1, changePct: -0.6, weight: 5 },
{ ticker: "DG", name: "Dollar General", price: 78.5, changePct: 1.5, weight: 4 }
];
export const demoCatalysts: Catalyst[] = [
{ id: "cat-1", date: "2026-06-05", event: "Q3 FY25 Earnings", impact: "high", thesisRelevance: "supports", source: "[4]" },
{ id: "cat-2", date: "2026-07-15", event: "Executive member update", impact: "medium", thesisRelevance: "supports", source: "[2]" },
{ id: "cat-3", date: "2026-08-01", event: "Annual membership fee review", impact: "high", thesisRelevance: "neutral", source: "[1]" },
{ id: "cat-4", date: "2026-09-10", event: "New warehouse openings (Q4)", impact: "low", thesisRelevance: "supports", source: "[1]" }
];
export const demoAlerts: Alert[] = [
{ id: "alert-1", companyId: "cost", timestamp: "2026-05-12T08:30:00Z", type: "earnings_surprise", description: "Q2 FY25 earnings beat (+7.5% comp)", thesisImpact: "positive", status: "new", targetSection: "thesis" },
{ id: "alert-2", companyId: "cost", timestamp: "2026-05-12T06:00:00Z", type: "filing", description: "New 8-K: Executive compensation update", thesisImpact: "neutral", status: "new" },
{ id: "alert-3", companyId: "wmt", timestamp: "2026-05-11T14:00:00Z", type: "peer_event", description: "WMT announces price investment in grocery", thesisImpact: "negative", status: "reviewed" },
{ id: "alert-4", companyId: "cost", timestamp: "2026-05-11T10:00:00Z", type: "price_move", description: "COST +2.1% on heavy volume", thesisImpact: "positive", status: "reviewed" }
];
export const demoRisks: Risk[] = [
{ id: "risk-1", companyId: "cost", risk: "Amazon enters warehouse club segment", category: "competitive", severity: "high", likelihood: "low", mitigation: "Costco's 93% renewal rate creates switching costs", status: "open" },
{ id: "risk-2", companyId: "cost", risk: "Wage inflation compresses operating margin", category: "financial", severity: "medium", likelihood: "high", mitigation: "Automation and productivity offset 40-60% of wage pressure", status: "open" },
{ id: "risk-3", companyId: "cost", risk: "Membership fee increase delayed beyond FY26", category: "financial", severity: "medium", likelihood: "medium", mitigation: "Fee income growth from executive tier conversion", status: "mitigated" },
{ id: "risk-4", companyId: "cost", risk: "Regulatory pressure on merchandise sourcing", category: "regulatory", severity: "low", likelihood: "low", mitigation: "Diversified supply chain across 14 countries", status: "accepted" },
{ id: "risk-5", companyId: "cost", risk: "E-commerce disruption of warehouse model", category: "competitive", severity: "medium", likelihood: "medium", mitigation: "Costco.com growth and Instacart partnership", status: "open" },
{ id: "risk-6", companyId: "cost", risk: "Valuation compression on growth deceleration", category: "financial", severity: "high", likelihood: "medium", mitigation: "High-single-digit earnings growth supports premium", status: "open" }
];
export const demoEarnings: EarningsSchedule[] = [
{ id: "earn-1", companyId: "cost", quarter: "Q3 FY25", expectedDate: "2026-06-05", timing: "bmo" },
{ id: "earn-2", companyId: "cost", quarter: "Q4 FY25", expectedDate: "2026-09-25", timing: "bmo" },
{ id: "earn-3", companyId: "cost", quarter: "Q2 FY25", expectedDate: "2026-03-06", timing: "bmo", actualRevenue: "$62.5B", expectedRevenue: "$61.2B", actualEps: "$4.28", expectedEps: "$4.05" },
{ id: "earn-4", companyId: "cost", quarter: "Q1 FY25", expectedDate: "2025-12-12", timing: "bmo", actualRevenue: "$60.2B", expectedRevenue: "$59.8B", actualEps: "$4.04", expectedEps: "$3.98" }
];
export const demoFilings: Filing[] = [
{ id: "filing-1", companyId: "cost", formType: "10-K", filedDate: "2024-10-10", title: "Annual Report FY2024", keyChanges: "Updated segment reporting, new warehouse commitments ($2.1B)", reviewed: true },
{ id: "filing-2", companyId: "cost", formType: "10-Q", filedDate: "2026-03-10", title: "Quarterly Report Q2 FY25", keyChanges: "Membership fee income growth, margin expansion", reviewed: true },
{ id: "filing-3", companyId: "cost", formType: "8-K", filedDate: "2026-05-12", title: "Executive Compensation Update", keyChanges: "New CEO compensation structure", reviewed: false }
];
export const demoExports: ExportRecord[] = [
{ id: "export-1", type: "excel", title: "COST Model — Revenue Build FY25-FY27", companyId: "cost", format: "Excel", fileSize: "2.4 MB", status: "complete", createdAt: "2026-05-10T14:30:00Z" },
{ id: "export-2", type: "pdf", title: "COST Investment Memo — Draft", companyId: "cost", format: "PDF", fileSize: "1.8 MB", status: "complete", createdAt: "2026-05-09T16:00:00Z" },
{ id: "export-3", type: "ppt", title: "COST IC Presentation", companyId: "cost", format: "PowerPoint", fileSize: "4.2 MB", status: "processing", createdAt: "2026-05-12T09:00:00Z" },
{ id: "export-4", type: "pdf", title: "Peer Comparison Report", companyId: "cost", format: "PDF", fileSize: "980 KB", status: "complete", createdAt: "2026-05-08T11:00:00Z" }
];
export const keyboardShortcuts: Array<[string, string, string]> = [
["⌘K", "Open agent chat / command bar", "global"],
["⌘F", "Focus search bar", "global"],

View File

@@ -1,18 +1,17 @@
import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc";
import { parseRpcResponse } from "../../../packages/contracts/src/rpcSchemas";
import { handleMockRpc } from "../../../packages/shared/src/mockRpc";
export const rpc: RpcClient = {
async call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]) {
const useMockRpc = !window.mosaic;
if (useMockRpc) {
const result = await handleMockRpc(method, payload);
if (process.env.NODE_ENV !== "production" && result.ok) {
parseRpcResponse(method, result.data);
}
return result;
}
return window.mosaic!.call(method, payload);
if (!window.mosaic) {
return {
ok: false,
error: {
code: "INTERNAL_ERROR",
message: "Desktop backend unavailable. Run MosaicIQ in the Electron app to use the local SQLite backend."
}
};
}
return window.mosaic.call(method, payload);
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,14 @@
],
"scripts": {
"dev": "vite --host 127.0.0.1",
"dev:electron": "concurrently -k \"pnpm run dev\" \"pnpm run desktop:watch\" \"wait-on tcp:5173 apps/desktop/dist-electron/main.cjs apps/desktop/dist-electron/preload.cjs && env -u ELECTRON_RUN_AS_NODE electron apps/desktop/dist-electron/main.cjs\"",
"dev:electron": "concurrently -k \"pnpm run dev\" \"pnpm run desktop:watch\" \"pnpm run native:rebuild:electron && wait-on tcp:5173 apps/desktop/dist-electron/main.cjs apps/desktop/dist-electron/preload.cjs && env -u ELECTRON_RUN_AS_NODE electron apps/desktop/dist-electron/main.cjs\"",
"native:rebuild:electron": "node scripts/rebuild-better-sqlite3.mjs electron",
"native:rebuild:node": "node scripts/rebuild-better-sqlite3.mjs node",
"desktop:watch": "pnpm --filter @mosaiciq/desktop dev:bundle",
"build": "tsc -b && pnpm --filter @mosaiciq/desktop build && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "tsc --noEmit && pnpm --filter @mosaiciq/desktop typecheck",
"test": "vitest run",
"test": "pnpm run native:rebuild:node && vitest run",
"test:watch": "vitest",
"format:check": "prettier --check .",
"check": "pnpm run typecheck && pnpm run test && pnpm run format:check"
@@ -28,6 +30,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.4",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^22.15.3",
"@types/react": "^19.0.10",
@@ -35,11 +38,11 @@
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.1.2",
"electron": "^36.0.0",
"prettier": "^3.5.3",
"tailwindcss": "^4.2.4",
"typescript": "^5.8.0",
"vite": "^7.0.0",
"vitest": "^3.2.4",
"prettier": "^3.5.3",
"wait-on": "^8.0.3"
}
}

View File

@@ -196,6 +196,7 @@ export type RpcRequestMap = {
"company.setActive": { companyId: string };
"workspace.getSection": { companyId: string; section: string };
"workspace.listSources": { companyId: string };
"workspace.updateSection": { companyId: string; section: string; content: string };
"catalyst.list": { companyId: string };
"alert.list": { companyId?: string; since?: string };
"risk.list": { companyId: string };
@@ -204,6 +205,8 @@ export type RpcRequestMap = {
"filing.list": { companyId: string; since?: string };
"model.get": { companyId: string; tab: string };
"model.updateCell": { companyId: string; tab: string; row: number; col: number; value: string };
"model.createRow": { companyId: string; tab: string; label: string; kind: "actual" | "forecast" | "total"; values?: string[] };
"model.deleteRow": { companyId: string; tab: string; row: number };
"model.runScenario": { companyId: string; scenario: string; overrides: Record<string, string> };
"memo.get": { companyId: string };
"memo.updateSection": {
@@ -256,6 +259,7 @@ export type RpcResponseMap = {
"company.setActive": { ok: boolean };
"workspace.getSection": { content: WorkspaceSection; validationState: string };
"workspace.listSources": { sources: Array<{ type: string; title: string; metadata: string }> };
"workspace.updateSection": { content: WorkspaceSection; savedAt: string };
"catalyst.list": { catalysts: Catalyst[] };
"alert.list": { alerts: Alert[] };
"risk.list": { risks: Risk[] };
@@ -264,6 +268,8 @@ export type RpcResponseMap = {
"filing.list": { filings: Filing[] };
"model.get": { headers: string[]; rows: ModelRow[] };
"model.updateCell": { ok: boolean; affectedCells: string[] };
"model.createRow": { row: ModelRow; position: number };
"model.deleteRow": { ok: boolean };
"model.runScenario": { headers: string[]; rows: ModelRow[] };
"memo.get": {
status: "draft" | "review" | "final";

View File

@@ -57,6 +57,7 @@ export const RpcRequestSchemas = {
"company.setActive": z.object({ companyId: idString }),
"workspace.getSection": z.object({ companyId: idString, section: nonEmptyString }),
"workspace.listSources": z.object({ companyId: idString }),
"workspace.updateSection": z.object({ companyId: idString, section: nonEmptyString, content: z.string() }),
"catalyst.list": z.object({ companyId: idString }),
"alert.list": z.object({ companyId: idString.optional(), since: z.string().optional() }),
"risk.list": z.object({ companyId: idString }),
@@ -71,6 +72,14 @@ export const RpcRequestSchemas = {
col: nonNegativeIndex,
value: z.string(),
}),
"model.createRow": z.object({
companyId: idString,
tab: nonEmptyString,
label: nonEmptyString,
kind: z.enum(["actual", "forecast", "total"]),
values: z.array(z.string()).optional(),
}),
"model.deleteRow": z.object({ companyId: idString, tab: nonEmptyString, row: nonNegativeIndex }),
"model.runScenario": z.object({
companyId: idString,
scenario: nonEmptyString,
@@ -141,6 +150,7 @@ export const RpcResponseSchemas = {
"company.setActive": z.object({ ok: z.boolean() }),
"workspace.getSection": z.object({ content: WorkspaceSectionSchema, validationState: z.string() }),
"workspace.listSources": z.object({ sources: z.array(z.object({ type: z.string(), title: z.string(), metadata: z.string() })) }),
"workspace.updateSection": z.object({ content: WorkspaceSectionSchema, savedAt: z.string() }),
"catalyst.list": z.object({ catalysts: z.array(CatalystSchema) }),
"alert.list": z.object({ alerts: z.array(AlertSchema) }),
"risk.list": z.object({ risks: z.array(RiskSchema) }),
@@ -149,6 +159,8 @@ export const RpcResponseSchemas = {
"filing.list": z.object({ filings: z.array(FilingSchema) }),
"model.get": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
"model.updateCell": z.object({ ok: z.boolean(), affectedCells: z.array(z.string()) }),
"model.createRow": z.object({ row: ModelRowSchema, position: z.number().int().min(0) }),
"model.deleteRow": z.object({ ok: z.boolean() }),
"model.runScenario": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
"memo.get": z.object({
status: z.enum(["draft", "review", "final"]),

View File

@@ -3,18 +3,6 @@
"private": true,
"type": "module",
"exports": {
"./demoData": {
"types": "./src/demoData.ts",
"import": "./src/demoData.ts"
},
"./extraData": {
"types": "./src/extraData.ts",
"import": "./src/extraData.ts"
},
"./mockRpc": {
"types": "./src/mockRpc.ts",
"import": "./src/mockRpc.ts"
},
"./db": {
"types": "./src/db/index.ts",
"import": "./src/db/index.ts"

View File

@@ -16,6 +16,7 @@ import {
} from "./eventEmitter.js";
export interface AgentRunOptions {
runId?: string;
onProgress?: (progress: number, action: string) => void;
signal?: AbortSignal;
structured?: boolean;
@@ -50,7 +51,7 @@ export async function executeAgent(
schema,
} = options;
const runId = `run-${Date.now()}`;
const runId = options.runId ?? `run-${Date.now()}`;
const startedAt = new Date().toISOString();
// Check if LLM is configured
@@ -254,6 +255,10 @@ function storeAgentOutput(
`);
stmt.run(JSON.stringify({ output, rawResponse: rawResponse.slice(0, 1000) }), runId);
db.prepare(`
INSERT OR REPLACE INTO agent_outputs (run_id, agent_id, company_id, output_json, raw_response)
VALUES (?, ?, ?, ?, ?)
`).run(runId, agentId, companyId, JSON.stringify(output), rawResponse);
}
/**

View File

@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { initDatabase, closeDatabase } from "./database.js";
import { SCHEMA_VERSION } from "./schema.js";
import { getPortfolio } from "./queries.js";
@@ -10,9 +14,56 @@ describe("database initialization", () => {
const version = db.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get() as { value: string };
expect(version.value).toBe(String(SCHEMA_VERSION));
expect(getPortfolio(db)?.id).toBe("default");
expect(getPortfolio(db)?.holdings).toEqual([]);
expect(getPortfolio(db)?.activeCompanyId).toBe("");
expect(db.pragma("foreign_keys", { simple: true })).toBe(1);
} finally {
closeDatabase(db);
}
});
it("migrates v1 databases to v2 tables and composite memo review keys", () => {
const dir = mkdtempSync(join(tmpdir(), "mosaiciq-db-"));
const dbPath = join(dir, "mosaiciq.db");
const legacy = new Database(dbPath);
legacy.exec(`
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO _meta (key, value) VALUES ('schema_version', '1');
CREATE TABLE companies (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
sector TEXT NOT NULL
);
CREATE TABLE portfolios (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
active_company_id TEXT
);
CREATE TABLE memo_section_reviews (
company_id TEXT NOT NULL,
section_id TEXT NOT NULL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO companies (id, ticker, name, sector) VALUES ('aapl', 'AAPL', 'Apple', 'Technology');
INSERT INTO memo_section_reviews (company_id, section_id, status) VALUES ('aapl', 'thesis', 'approved');
`);
legacy.close();
const db = initDatabase({ path: dbPath });
try {
expect((db.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get() as { value: string }).value).toBe(String(SCHEMA_VERSION));
for (const tableName of ["workspace_sources", "agent_run_steps", "agent_outputs"]) {
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName)).toEqual({ name: tableName });
}
const reviewInfo = db.prepare("PRAGMA table_info(memo_section_reviews)").all() as Array<{ name: string; pk: number }>;
expect(reviewInfo.find((col) => col.name === "company_id")?.pk).toBe(1);
expect(reviewInfo.find((col) => col.name === "section_id")?.pk).toBe(2);
expect(db.prepare("SELECT status FROM memo_section_reviews WHERE company_id = 'aapl' AND section_id = 'thesis'").get()).toEqual({ status: "approved" });
} finally {
closeDatabase(db);
rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -74,24 +74,121 @@ export function initDatabase(config: DatabaseConfig = {}): Db {
// Set schema version
db.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
// Create default portfolio
db.prepare(`
INSERT INTO portfolios (id, name)
VALUES (?, ?)
`).run(DEFAULT_PORTFOLIO_ID, "Core Retail Coverage");
ensureDefaultPortfolio(db);
console.log(`[DB] Initialized database at ${dbPath}`);
} else {
const currentVersion = Number(version.value);
if (currentVersion !== SCHEMA_VERSION) {
if (currentVersion < SCHEMA_VERSION) {
migrateDatabase(db, currentVersion);
} else if (currentVersion !== SCHEMA_VERSION) {
console.warn(`[DB] Schema version mismatch: expected ${SCHEMA_VERSION}, got ${currentVersion}`);
// TODO: Run migrations
}
}
ensureDefaultPortfolio(db);
verifyMigrationState(db);
return db;
}
function ensureDefaultPortfolio(db: Db): void {
db.prepare(`
INSERT INTO portfolios (id, name)
VALUES (?, ?)
ON CONFLICT(id) DO NOTHING
`).run(DEFAULT_PORTFOLIO_ID, "Core Retail Coverage");
}
function migrateDatabase(db: Db, fromVersion: number): void {
db.transaction(() => {
if (fromVersion < 2) {
db.exec(`
CREATE TABLE IF NOT EXISTS workspace_sources (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '',
source_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workspace_sources_company ON workspace_sources(company_id);
CREATE TABLE IF NOT EXISTS agent_run_steps (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
step INTEGER NOT NULL,
label TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_run_steps_run ON agent_run_steps(run_id, step);
CREATE TABLE IF NOT EXISTS agent_outputs (
run_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
company_id TEXT NOT NULL,
output_json TEXT NOT NULL,
raw_response TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
`);
const reviewInfo = db.prepare("PRAGMA table_info(memo_section_reviews)").all() as Array<{ name: string; pk: number }>;
const sectionPk = reviewInfo.find((col) => col.name === "section_id")?.pk ?? 0;
const companyPk = reviewInfo.find((col) => col.name === "company_id")?.pk ?? 0;
if (sectionPk > 0 && companyPk === 0) {
db.exec(`
CREATE TABLE IF NOT EXISTS memo_section_reviews_v2 (
company_id TEXT NOT NULL,
section_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (company_id, section_id),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
INSERT OR IGNORE INTO memo_section_reviews_v2 (company_id, section_id, status, updated_at)
SELECT company_id, section_id, status, updated_at FROM memo_section_reviews;
DROP TABLE memo_section_reviews;
ALTER TABLE memo_section_reviews_v2 RENAME TO memo_section_reviews;
CREATE INDEX IF NOT EXISTS idx_memo_reviews_company ON memo_section_reviews(company_id);
`);
}
}
db.prepare("UPDATE _meta SET value = ? WHERE key = 'schema_version'").run(String(SCHEMA_VERSION));
})();
}
function verifyMigrationState(db: Db): void {
const requiredTables = ["workspace_sources", "agent_run_steps", "agent_outputs"];
for (const tableName of requiredTables) {
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName);
if (!table) {
console.error(`[DB] Migration verification failed: missing required table "${tableName}".`);
}
}
const reviewTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'memo_section_reviews'").get();
if (!reviewTable) {
console.error('[DB] Migration verification failed: missing required table "memo_section_reviews".');
return;
}
const reviewInfo = db.prepare("PRAGMA table_info(memo_section_reviews)").all() as Array<{ name: string; pk: number }>;
const companyPk = reviewInfo.find((col) => col.name === "company_id")?.pk ?? 0;
const sectionPk = reviewInfo.find((col) => col.name === "section_id")?.pk ?? 0;
if (companyPk !== 1 || sectionPk !== 2) {
console.error('[DB] Migration verification failed: "memo_section_reviews" must use PRIMARY KEY (company_id, section_id).');
}
}
/**
* Close the database connection
*/

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { closeDatabase, initDatabase } from "./database.js";
import { getClientSettings, getModel, updateModelCell } from "./queries.js";
import { createModelRow, deleteModelRow, getClientSettings, getModel, getPortfolio, listWorkspaceSources, updateMemoSectionReview, updateModelCell, updateWorkspaceSection, upsertCompany } from "./queries.js";
function withDb(test: (db: ReturnType<typeof initDatabase>) => void) {
const db = initDatabase({ inMemory: true });
@@ -47,4 +47,36 @@ describe("queries", () => {
expect(settings.sidebarWidth).toBe(240);
expect(settings.density).toBe("comfortable");
}));
it("returns an empty default portfolio before any company is added", () => withDb((db) => {
expect(getPortfolio(db)).toMatchObject({ id: "default", holdings: [], activeCompanyId: "" });
}));
it("lists persisted workspace sources", () => withDb((db) => {
db.prepare("INSERT INTO companies (id, ticker, name, sector) VALUES ('aapl', 'AAPL', 'Apple', 'Technology')").run();
db.prepare("INSERT INTO workspace_sources (id, company_id, type, title, metadata) VALUES ('s1', 'aapl', 'SEC Filing', '10-K', 'Filed today')").run();
expect(listWorkspaceSources(db, "aapl")).toEqual([{ type: "SEC Filing", title: "10-K", metadata: "Filed today" }]);
}));
it("supports section review ids reused across companies", () => withDb((db) => {
upsertCompany(db, { id: "a", ticker: "AAA", name: "A", sector: "Tech", price: 0, changePct: 0, thesis: "" });
upsertCompany(db, { id: "b", ticker: "BBB", name: "B", sector: "Tech", price: 0, changePct: 0, thesis: "" });
updateMemoSectionReview(db, "a", "thesis", "approved");
updateMemoSectionReview(db, "b", "thesis", "changes_requested");
expect(db.prepare("SELECT COUNT(*) as count FROM memo_section_reviews WHERE section_id = 'thesis'").get()).toEqual({ count: 2 });
}));
it("creates and updates workspace sections", () => withDb((db) => {
upsertCompany(db, { id: "aapl", ticker: "AAPL", name: "Apple", sector: "Technology", price: 0, changePct: 0, thesis: "" });
const section = updateWorkspaceSection(db, "aapl", "Business Description", "Backend-backed notes");
expect(section).toMatchObject({ title: "Business Description", content: "Backend-backed notes", validationState: "unverified" });
}));
it("creates and deletes model rows while compacting positions", () => withDb((db) => {
upsertCompany(db, { id: "aapl", ticker: "AAPL", name: "Apple", sector: "Technology", price: 0, changePct: 0, thesis: "" });
expect(createModelRow(db, "aapl", "operating", { label: "Revenue", kind: "actual", values: ["1"] }).position).toBe(0);
expect(createModelRow(db, "aapl", "operating", { label: "EPS", kind: "forecast", values: ["2"] }).position).toBe(1);
expect(deleteModelRow(db, "aapl", "operating", 0)).toBe(true);
expect(getModel(db, "aapl", "operating").rows.map((row) => row.label)).toEqual(["EPS"]);
}));
});

View File

@@ -39,6 +39,210 @@ export function parseJsonWithSchema<T>(
}
}
function optional<T>(value: T | null | undefined): T | undefined {
return value ?? undefined;
}
type CompanyRow = Omit<Company, "subIndustry" | "founded" | "headquarters" | "employees"> & {
subIndustry: string | null;
founded: string | null;
headquarters: string | null;
employees: number | null;
};
function mapCompanyRow(row: CompanyRow): Company {
return {
id: row.id,
ticker: row.ticker,
name: row.name,
sector: row.sector,
subIndustry: optional(row.subIndustry),
price: row.price,
changePct: row.changePct,
thesis: row.thesis,
founded: optional(row.founded),
headquarters: optional(row.headquarters),
employees: optional(row.employees),
};
}
type WorkspaceSectionRow = Omit<WorkspaceSection, "sourceAgent"> & { sourceAgent: string | null };
function mapWorkspaceSectionRow(row: WorkspaceSectionRow): WorkspaceSection {
return {
id: row.id,
title: row.title,
content: row.content,
validationState: row.validationState,
sourceAgent: optional(row.sourceAgent),
};
}
type CatalystRow = Omit<Catalyst, "source"> & { source: string | null };
function mapCatalystRow(row: CatalystRow): Catalyst {
return {
id: row.id,
date: row.date,
event: row.event,
impact: row.impact,
thesisRelevance: row.thesisRelevance,
source: optional(row.source),
};
}
type AlertRow = Omit<Alert, "companyId" | "targetSection"> & {
companyId: string | null;
targetSection: string | null;
};
function mapAlertRow(row: AlertRow): Alert {
return {
id: row.id,
companyId: optional(row.companyId),
timestamp: row.timestamp,
type: row.type,
description: row.description,
thesisImpact: row.thesisImpact,
status: row.status,
targetSection: optional(row.targetSection),
};
}
type EarningsScheduleRow = Omit<
EarningsSchedule,
"timing" | "actualRevenue" | "expectedRevenue" | "actualEps" | "expectedEps"
> & {
timing: EarningsSchedule["timing"] | null;
actualRevenue: string | null;
expectedRevenue: string | null;
actualEps: string | null;
expectedEps: string | null;
};
function mapEarningsScheduleRow(row: EarningsScheduleRow): EarningsSchedule {
return {
id: row.id,
companyId: row.companyId,
quarter: row.quarter,
expectedDate: row.expectedDate,
timing: optional(row.timing),
actualRevenue: optional(row.actualRevenue),
expectedRevenue: optional(row.expectedRevenue),
actualEps: optional(row.actualEps),
expectedEps: optional(row.expectedEps),
};
}
type FilingRow = Omit<Filing, "keyChanges" | "reviewed"> & {
keyChanges: string | null;
reviewed: number | boolean | null;
};
function mapFilingRow(row: FilingRow): Filing {
return {
id: row.id,
companyId: row.companyId,
formType: row.formType,
filedDate: row.filedDate,
title: row.title,
keyChanges: optional(row.keyChanges),
reviewed: row.reviewed == null ? undefined : Boolean(row.reviewed),
};
}
type MemoSectionRow = Omit<MemoSection, "updatedAt" | "primaryAgent"> & {
updatedAt: string | null;
primaryAgent: string | null;
};
function mapMemoSectionRow(row: MemoSectionRow): MemoSection {
return {
id: row.id,
title: row.title,
content: row.content,
updatedAt: optional(row.updatedAt),
primaryAgent: optional(row.primaryAgent),
};
}
type MemoCitationRow = Omit<MemoCitation, "sourceUrl"> & { sourceUrl: string | null };
function mapMemoCitationRow(row: MemoCitationRow): MemoCitation {
return {
id: row.id,
label: row.label,
sectionId: row.sectionId,
type: row.type,
title: row.title,
reference: row.reference,
verificationStatus: row.verificationStatus,
sourceUrl: optional(row.sourceUrl),
};
}
type MemoAnnotationRow = Omit<MemoAnnotation, "comment"> & { comment: string | null };
function mapMemoAnnotationRow(row: MemoAnnotationRow): MemoAnnotation {
return {
id: row.id,
sectionId: row.sectionId,
kind: row.kind,
selectedText: row.selectedText,
comment: optional(row.comment),
createdBy: row.createdBy,
createdAt: row.createdAt,
status: row.status,
};
}
type MemoSectionReviewRow = Omit<MemoSectionReview, "updatedAt"> & { updatedAt: string | null };
function mapMemoSectionReviewRow(row: MemoSectionReviewRow): MemoSectionReview {
return {
sectionId: row.sectionId,
status: row.status,
updatedAt: optional(row.updatedAt),
};
}
type AgentRow = Omit<Agent, "pipeline" | "confidence"> & {
pipeline: Agent["pipeline"] | null;
confidence?: Agent["confidence"] | null;
};
function mapAgentRow(row: AgentRow): Agent {
return {
id: row.id,
name: row.name,
status: row.status,
progress: row.progress,
action: row.action,
pipeline: optional(row.pipeline),
confidence: optional(row.confidence),
};
}
type ExportRecordRow = Omit<ExportRecord, "companyId" | "fileSize" | "downloadUrl"> & {
companyId: string | null;
fileSize: string | null;
downloadUrl: string | null;
};
function mapExportRecordRow(row: ExportRecordRow): ExportRecord {
return {
id: row.id,
type: row.type,
title: row.title,
companyId: optional(row.companyId),
format: row.format,
fileSize: optional(row.fileSize),
status: row.status,
createdAt: row.createdAt,
downloadUrl: optional(row.downloadUrl),
};
}
// ============== Portfolio ==============
export function getPortfolio(db: Db) {
@@ -94,6 +298,11 @@ export function setActiveCompany(db: Db, portfolioId: string, companyId: string)
return stmt.run(companyId, portfolioId);
}
export function clearActiveCompany(db: Db, portfolioId: string) {
const stmt = db.prepare("UPDATE portfolios SET active_company_id = NULL WHERE id = ?");
return stmt.run(portfolioId);
}
// ============== Companies ==============
export function getCompanyByTicker(db: Db, ticker: string): Company | null {
@@ -104,7 +313,12 @@ export function getCompanyByTicker(db: Db, ticker: string): Company | null {
FROM companies
WHERE ticker = ?
`);
return stmt.get(ticker.toUpperCase()) as Company | null;
const row = stmt.get(ticker.toUpperCase()) as CompanyRow | undefined;
return row ? mapCompanyRow(row) : null;
}
export function resolveCompany(db: Db, companyIdOrTicker: string): Company | null {
return getCompany(db, companyIdOrTicker) ?? getCompanyByTicker(db, companyIdOrTicker);
}
export function getCompany(db: Db, companyId: string): Company | null {
@@ -115,7 +329,8 @@ export function getCompany(db: Db, companyId: string): Company | null {
FROM companies
WHERE id = ?
`);
return stmt.get(companyId) as Company | null;
const row = stmt.get(companyId) as CompanyRow | undefined;
return row ? mapCompanyRow(row) : null;
}
export function searchCompanies(db: Db, query: string): Array<{ ticker: string; name: string; sector: string }> {
@@ -172,7 +387,30 @@ export function getWorkspaceSection(db: Db, companyId: string, section: string):
FROM workspace_sections
WHERE company_id = ? AND title = ?
`);
return stmt.get(companyId, section) as WorkspaceSection | null;
const row = stmt.get(companyId, section) as WorkspaceSectionRow | undefined;
return row ? mapWorkspaceSectionRow(row) : null;
}
export function createWorkspaceSection(db: Db, companyId: string, section: string): WorkspaceSection {
const id = `ws-${companyId}-${slugify(section)}`;
const stmt = db.prepare(`
INSERT INTO workspace_sections (id, company_id, title, content, validation_state)
VALUES (?, ?, ?, '', 'unverified')
ON CONFLICT(id) DO UPDATE SET title = excluded.title
RETURNING id, title, content, validation_state as validationState, source_agent as sourceAgent
`);
return mapWorkspaceSectionRow(stmt.get(id, companyId, section) as WorkspaceSectionRow);
}
export function updateWorkspaceSection(db: Db, companyId: string, section: string, content: string): WorkspaceSection {
const existing = getWorkspaceSection(db, companyId, section) ?? createWorkspaceSection(db, companyId, section);
const stmt = db.prepare(`
UPDATE workspace_sections
SET content = ?, validation_state = 'unverified', updated_at = datetime('now')
WHERE id = ? AND company_id = ?
RETURNING id, title, content, validation_state as validationState, source_agent as sourceAgent
`);
return mapWorkspaceSectionRow(stmt.get(content, existing.id, companyId) as WorkspaceSectionRow);
}
export function listWorkspaceSources(db: Db, companyId: string) {
@@ -184,6 +422,19 @@ export function listWorkspaceSources(db: Db, companyId: string) {
return stmt.all(companyId);
}
export function addWorkspaceSource(
db: Db,
companyId: string,
source: { id?: string; type: string; title: string; metadata?: string; sourceUrl?: string }
) {
const id = source.id ?? `source-${companyId}-${Date.now()}`;
const stmt = db.prepare(`
INSERT OR REPLACE INTO workspace_sources (id, company_id, type, title, metadata, source_url)
VALUES (?, ?, ?, ?, ?, ?)
`);
return stmt.run(id, companyId, source.type, source.title, source.metadata ?? "", source.sourceUrl ?? null);
}
// ============== Catalysts ==============
export function listCatalysts(db: Db, companyId: string): Catalyst[] {
@@ -194,7 +445,7 @@ export function listCatalysts(db: Db, companyId: string): Catalyst[] {
WHERE company_id = ?
ORDER BY date DESC
`);
return stmt.all(companyId) as Catalyst[];
return (stmt.all(companyId) as CatalystRow[]).map(mapCatalystRow);
}
// ============== Alerts ==============
@@ -215,7 +466,7 @@ export function listAlerts(db: Db, companyId?: string, since?: string): Alert[]
query += " ORDER BY timestamp DESC";
const stmt = db.prepare(query);
return stmt.all(...params) as Alert[];
return (stmt.all(...params) as AlertRow[]).map(mapAlertRow);
}
// ============== Risks ==============
@@ -251,7 +502,7 @@ export function getEarningsSchedule(db: Db, companyId: string): EarningsSchedule
WHERE company_id = ?
ORDER BY expected_date ASC
`);
return stmt.all(companyId) as EarningsSchedule[];
return (stmt.all(companyId) as EarningsScheduleRow[]).map(mapEarningsScheduleRow);
}
// ============== Filings ==============
@@ -268,15 +519,13 @@ export function listFilings(db: Db, companyId: string, since?: string): Filing[]
query += " ORDER BY filed_date DESC";
const stmt = db.prepare(query);
return (stmt.all(...params) as Array<Omit<Filing, "reviewed"> & { reviewed: number | boolean | null }>).map((filing) => ({
...filing,
reviewed: Boolean(filing.reviewed),
}));
return (stmt.all(...params) as FilingRow[]).map(mapFilingRow);
}
// ============== Model ==============
export function getModel(db: Db, companyId: string, tab: string): { headers: string[]; rows: ModelRow[] } {
createDefaultModel(db, companyId, tab);
const headerStmt = db.prepare(`
SELECT label FROM model_headers
WHERE model_id = (SELECT id FROM models WHERE company_id = ? AND tab = ?)
@@ -300,6 +549,15 @@ export function getModel(db: Db, companyId: string, tab: string): { headers: str
return { headers, rows };
}
export function createDefaultModel(db: Db, companyId: string, tab: string): void {
const id = `model-${companyId}-${slugify(tab)}`;
db.prepare(`
INSERT INTO models (id, company_id, tab)
VALUES (?, ?, ?)
ON CONFLICT(company_id, tab) DO NOTHING
`).run(id, companyId, tab);
}
export function updateModelCell(
db: Db,
companyId: string,
@@ -330,9 +588,35 @@ export function updateModelCell(
return { ok: true, affectedCells: [`${row}-${col}`] };
}
export function createModelRow(
db: Db,
companyId: string,
tab: string,
row: ModelRow
): { row: ModelRow; position: number } {
createDefaultModel(db, companyId, tab);
const model = db.prepare("SELECT id FROM models WHERE company_id = ? AND tab = ?").get(companyId, tab) as { id: string };
const next = db.prepare("SELECT COALESCE(MAX(position), -1) + 1 as position FROM model_rows WHERE model_id = ?").get(model.id) as { position: number };
db.prepare(`
INSERT INTO model_rows (model_id, position, label, kind, "values")
VALUES (?, ?, ?, ?, ?)
`).run(model.id, next.position, row.label, row.kind, JSON.stringify(row.values));
return { row, position: next.position };
}
export function deleteModelRow(db: Db, companyId: string, tab: string, row: number): boolean {
const model = db.prepare("SELECT id FROM models WHERE company_id = ? AND tab = ?").get(companyId, tab) as { id: string } | undefined;
if (!model) return false;
const result = db.prepare("DELETE FROM model_rows WHERE model_id = ? AND position = ?").run(model.id, row);
if (result.changes === 0) return false;
db.prepare("UPDATE model_rows SET position = position - 1 WHERE model_id = ? AND position > ?").run(model.id, row);
return true;
}
// ============== Memo ==============
export function getMemo(db: Db, companyId: string) {
createDefaultMemo(db, companyId);
const memoStmt = db.prepare("SELECT status FROM memos WHERE company_id = ?");
const memo = memoStmt.get(companyId) as { status: "draft" | "review" | "final" } | undefined;
@@ -342,14 +626,14 @@ export function getMemo(db: Db, companyId: string) {
WHERE company_id = ?
ORDER BY id
`);
const sections = sectionsStmt.all(companyId) as MemoSection[];
const sections = (sectionsStmt.all(companyId) as MemoSectionRow[]).map(mapMemoSectionRow);
const citationsStmt = db.prepare(`
SELECT id, label, section_id as sectionId, type, title, reference, verification_status as verificationStatus, source_url as sourceUrl
FROM memo_citations
WHERE company_id = ?
`);
const citations = citationsStmt.all(companyId) as MemoCitation[];
const citations = (citationsStmt.all(companyId) as MemoCitationRow[]).map(mapMemoCitationRow);
const annotationsStmt = db.prepare(`
SELECT id, section_id as sectionId, kind, selected_text as selectedText, comment, created_by as createdBy, created_at as createdAt, status
@@ -357,14 +641,14 @@ export function getMemo(db: Db, companyId: string) {
WHERE company_id = ?
ORDER BY created_at DESC
`);
const annotations = annotationsStmt.all(companyId) as MemoAnnotation[];
const annotations = (annotationsStmt.all(companyId) as MemoAnnotationRow[]).map(mapMemoAnnotationRow);
const reviewsStmt = db.prepare(`
SELECT section_id as sectionId, status, updated_at as updatedAt
FROM memo_section_reviews
WHERE company_id = ?
`);
const sectionReviews = reviewsStmt.all(companyId) as MemoSectionReview[];
const sectionReviews = (reviewsStmt.all(companyId) as MemoSectionReviewRow[]).map(mapMemoSectionReviewRow);
return {
status: memo?.status ?? "draft",
@@ -375,6 +659,14 @@ export function getMemo(db: Db, companyId: string) {
};
}
export function createDefaultMemo(db: Db, companyId: string): void {
db.prepare(`
INSERT INTO memos (company_id, status)
VALUES (?, 'draft')
ON CONFLICT(company_id) DO NOTHING
`).run(companyId);
}
export function updateMemoSection(
db: Db,
companyId: string,
@@ -390,8 +682,9 @@ export function updateMemoSection(
RETURNING id, title, content, updated_at as updatedAt, primary_agent as primaryAgent
`);
const result = stmt.get(updates.title, updates.content, sectionId, companyId) as MemoSection;
return result;
const result = stmt.get(updates.title, updates.content, sectionId, companyId) as MemoSectionRow;
if (!result) throw new Error(`Memo section "${sectionId}" not found.`);
return mapMemoSectionRow(result);
}
export function addMemoAnnotation(
@@ -403,10 +696,18 @@ export function addMemoAnnotation(
const stmt = db.prepare(`
INSERT INTO memo_annotations (id, company_id, section_id, kind, selected_text, comment, created_by, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
RETURNING
id,
section_id as sectionId,
kind,
selected_text as selectedText,
comment,
created_by as createdBy,
created_at as createdAt,
status
`);
return stmt.get(
const row = stmt.get(
id,
companyId,
annotation.sectionId,
@@ -416,18 +717,28 @@ export function addMemoAnnotation(
annotation.createdBy,
annotation.createdAt,
annotation.status
) as MemoAnnotation;
) as MemoAnnotationRow;
return mapMemoAnnotationRow(row);
}
export function resolveMemoAnnotation(db: Db, companyId: string, annotationId: string): MemoAnnotation {
export function resolveMemoAnnotation(db: Db, companyId: string, annotationId: string): MemoAnnotation | null {
const stmt = db.prepare(`
UPDATE memo_annotations
SET status = 'resolved'
WHERE id = ? AND company_id = ?
RETURNING *
RETURNING
id,
section_id as sectionId,
kind,
selected_text as selectedText,
comment,
created_by as createdBy,
created_at as createdAt,
status
`);
return stmt.get(annotationId, companyId) as MemoAnnotation;
const row = stmt.get(annotationId, companyId) as MemoAnnotationRow | undefined;
return row ? mapMemoAnnotationRow(row) : null;
}
export function updateMemoSectionReview(
@@ -442,10 +753,11 @@ export function updateMemoSectionReview(
ON CONFLICT(company_id, section_id) DO UPDATE SET
status = excluded.status,
updated_at = datetime('now')
RETURNING *
RETURNING section_id as sectionId, status, updated_at as updatedAt
`);
return stmt.get(companyId, sectionId, status) as MemoSectionReview;
const row = stmt.get(companyId, sectionId, status) as MemoSectionReviewRow;
return mapMemoSectionReviewRow(row);
}
// ============== Agents ==============
@@ -457,7 +769,7 @@ export function listAgents(db: Db, companyId?: string): Agent[] {
a.name,
COALESCE(r.status, 'idle') as status,
COALESCE(r.progress, 0) as progress,
r.action,
COALESCE(r.action, 'Idle') as action,
a.pipeline
FROM agents a
LEFT JOIN agent_runs r ON a.id = r.agent_id
@@ -470,7 +782,7 @@ export function listAgents(db: Db, companyId?: string): Agent[] {
`;
const stmt = db.prepare(query);
return stmt.all() as Agent[];
return (stmt.all() as AgentRow[]).map(mapAgentRow);
}
export function startAgent(db: Db, agentId: string, companyId: string): { runId: string } {
@@ -483,6 +795,52 @@ export function startAgent(db: Db, agentId: string, companyId: string): { runId:
return { runId };
}
export function createAgentRun(db: Db, agentId: string, companyId: string | null, status: "queued" | "running" | "failed" = "queued"): { runId: string } {
const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
db.prepare(`
INSERT INTO agent_runs (id, agent_id, company_id, status, started_at)
VALUES (?, ?, ?, ?, CASE WHEN ? = 'running' THEN datetime('now') ELSE NULL END)
`).run(runId, agentId, companyId, status, status);
return { runId };
}
export function addAgentRunStep(db: Db, runId: string, step: number, label: string, detail = ""): void {
db.prepare(`
INSERT OR REPLACE INTO agent_run_steps (id, run_id, step, label, detail)
VALUES (?, ?, ?, ?, ?)
`).run(`${runId}-${step}`, runId, step, label, detail);
}
export function listAgentRunSteps(db: Db, runId: string): Array<{ step: number; label: string; detail: string }> {
return db.prepare(`
SELECT step, label, detail
FROM agent_run_steps
WHERE run_id = ?
ORDER BY step
`).all(runId) as Array<{ step: number; label: string; detail: string }>;
}
export function storeAgentOutput(db: Db, runId: string, agentId: string, companyId: string, output: unknown, rawResponse = ""): void {
db.prepare(`
INSERT OR REPLACE INTO agent_outputs (run_id, agent_id, company_id, output_json, raw_response)
VALUES (?, ?, ?, ?, ?)
`).run(runId, agentId, companyId, JSON.stringify(output), rawResponse);
}
export function updateAgentRunCompletion(
db: Db,
runId: string,
status: "completed" | "failed" | "cancelled",
action: string,
error?: string
): void {
db.prepare(`
UPDATE agent_runs
SET status = ?, progress = ?, action = ?, completed_at = datetime('now'), error = ?
WHERE id = ?
`).run(status, status === "completed" ? 100 : 0, action, error ?? null, runId);
}
export function pauseAgent(db: Db, agentId: string): { ok: boolean } {
const stmt = db.prepare(`
UPDATE agent_runs
@@ -564,7 +922,7 @@ export function listExports(db: Db, companyId?: string): ExportRecord[] {
query += " ORDER BY created_at DESC";
const stmt = db.prepare(query);
return stmt.all(...params) as ExportRecord[];
return (stmt.all(...params) as ExportRecordRow[]).map(mapExportRecordRow);
}
export function createExport(
@@ -583,6 +941,12 @@ export function createExport(
return { exportId: id };
}
export function getExport(db: Db, exportId: string): ExportRecord | null {
const stmt = db.prepare("SELECT id, type, title, company_id as companyId, format, file_size as fileSize, status, created_at as createdAt, download_url as downloadUrl FROM export_records WHERE id = ?");
const row = stmt.get(exportId) as ExportRecordRow | undefined;
return row ? mapExportRecordRow(row) : null;
}
export function updateExportStatus(
db: Db,
exportId: string,
@@ -597,6 +961,10 @@ export function updateExportStatus(
stmt.run(status, downloadUrl ?? null, exportId);
}
function slugify(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "default";
}
// ============== Settings ==============
const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
@@ -688,16 +1056,35 @@ export function getServerSettings(db: Db): ServerSettings {
}
}
return ServerSettingsSchema.parse({
agentConfigs,
dataSources: {
const settingsStmt = db.prepare("SELECT key, value FROM client_settings WHERE key IN ('server.dataSources', 'server.exportPipelines')");
const settingsRows = settingsStmt.all() as Array<{ key: string; value: string }>;
let dataSources: Record<string, boolean> = {
sec_filings: true,
transcripts: true,
market_data: false,
analyst_reports: false,
press_releases: true,
},
exportPipelines: {},
};
let exportPipelines: Record<string, unknown> = {};
for (const row of settingsRows) {
try {
const parsed = JSON.parse(row.value) as unknown;
if (row.key === "server.dataSources") {
const value = z.record(z.boolean()).safeParse(parsed);
if (value.success) dataSources = { ...dataSources, ...value.data };
} else if (row.key === "server.exportPipelines") {
const value = z.record(z.unknown()).safeParse(parsed);
if (value.success) exportPipelines = value.data;
}
} catch {
// Ignore malformed server settings.
}
}
return ServerSettingsSchema.parse({
agentConfigs,
dataSources,
exportPipelines,
});
}
@@ -721,7 +1108,10 @@ export function updateServerSettings(
updateAgentConfig(db, agentId, config as Record<string, unknown>);
}
}
// Data sources and export pipelines are stored separately
// For now, they're hardcoded in getServerSettings
// TODO: Add separate tables for data_sources and export_pipelines
if (settings.dataSources !== undefined) {
updateClientSetting(db, "server.dataSources", settings.dataSources);
}
if (settings.exportPipelines !== undefined) {
updateClientSetting(db, "server.exportPipelines", settings.exportPipelines);
}
}

View File

@@ -3,7 +3,7 @@
* All data is stored locally with type-safe accessors
*/
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2;
export const SQL_SCHEMA = `
-- Database version tracking
@@ -68,6 +68,19 @@ CREATE TABLE IF NOT EXISTS workspace_sections (
CREATE INDEX IF NOT EXISTS idx_workspace_company ON workspace_sections(company_id);
CREATE TABLE IF NOT EXISTS workspace_sources (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '',
source_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workspace_sources_company ON workspace_sources(company_id);
-- Catalysts
CREATE TABLE IF NOT EXISTS catalysts (
id TEXT PRIMARY KEY,
@@ -242,9 +255,10 @@ CREATE INDEX IF NOT EXISTS idx_memo_annotations_section ON memo_annotations(sect
CREATE TABLE IF NOT EXISTS memo_section_reviews (
company_id TEXT NOT NULL,
section_id TEXT NOT NULL PRIMARY KEY,
section_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (company_id, section_id),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
@@ -294,6 +308,30 @@ CREATE INDEX IF NOT EXISTS idx_agent_runs_agent ON agent_runs(agent_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_company ON agent_runs(company_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
CREATE TABLE IF NOT EXISTS agent_run_steps (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
step INTEGER NOT NULL,
label TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_run_steps_run ON agent_run_steps(run_id, step);
CREATE TABLE IF NOT EXISTS agent_outputs (
run_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
company_id TEXT NOT NULL,
output_json TEXT NOT NULL,
raw_response TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
-- Agent configurations (server settings)
CREATE TABLE IF NOT EXISTS agent_configs (
agent_id TEXT PRIMARY KEY,

View File

@@ -1,271 +1,42 @@
/**
* Seed database with demo data for MosaicIQ
* Runtime database bootstrap for MosaicIQ.
*
* This intentionally inserts only application-owned capability metadata, not
* sample research data.
*/
import type { Agent } from "@mosaiciq/contracts/rpc";
import type { Db } from "./database.js";
import type {
Catalyst,
Alert,
Risk,
EarningsSchedule,
Filing,
ExportRecord,
Agent,
Company,
} from "@mosaiciq/contracts/rpc";
import {
activeCompanyId,
holdings,
companies,
agents,
modelHeaders,
modelRows,
memoSections,
memoCitations,
memoAnnotations,
memoSectionReviews,
} from "../demoData.js";
import { demoAlerts, demoCatalysts, demoEarnings, demoExports, demoFilings, demoRisks } from "../extraData.js";
export function seedDatabase(db: Db): void {
console.log("[DB] Seeding database with demo data...");
const AGENT_DEFINITIONS: Array<Pick<Agent, "id" | "name" | "pipeline"> & { description: string }> = [
{ id: "cr", name: "Company Research Agent", description: "Structured profiles from filings, transcripts, external data", pipeline: "research" },
{ id: "sf", name: "SEC Filings Agent", description: "Segment data, KPIs, risk factors, accounting policies", pipeline: "research" },
{ id: "fm", name: "Financial Modeling Agent", description: "Revenue builds, margin models, three-statement frameworks", pipeline: "research" },
{ id: "ec", name: "Earnings Call Agent", description: "Management tone, guidance, KPIs, Q&A themes", pipeline: "competitive" },
{ id: "ci", name: "Competitive Intel Agent", description: "Peer analysis, market positioning, competitive threats", pipeline: "competitive" },
{ id: "va", name: "Valuation Agent", description: "DCF, trading comps, scenario analysis, multiples", pipeline: "research" },
{ id: "rk", name: "Risk Agent", description: "Business, financial, competitive, regulatory risks", pipeline: "competitive" },
{ id: "mw", name: "Memo Writing Agent", description: "Investment memos, research reports, IC memos", pipeline: "research" },
{ id: "pa", name: "Presentation Agent", description: "IC presentation drafts, slide outlines, exhibits", pipeline: "research" },
{ id: "mn", name: "Monitoring Agent", description: "Filing alerts, thesis changes, earnings events", pipeline: "cross-cutting" },
{ id: "sv", name: "Source Verification Agent", description: "Citation checking, source reliability, cross-referencing", pipeline: "cross-cutting" },
{ id: "rt", name: "Red Team Agent", description: "Thesis challenges, assumption stress-testing, bear cases", pipeline: "competitive" },
{ id: "ex", name: "Export Agent", description: "PDF, Excel, PowerPoint export pipelines", pipeline: "cross-cutting" },
{ id: "qa", name: "Model QA Agent", description: "Formula auditing, balance sheet checks, sanity tests", pipeline: "cross-cutting" }
];
// Insert companies
const companyStmt = db.prepare(`
INSERT OR REPLACE INTO companies (id, ticker, name, sector, sub_industry, price, change_pct, thesis, founded, headquarters, employees)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const company of Object.values(companies) as Company[]) {
companyStmt.run(
company.id,
company.ticker,
company.name,
company.sector,
company.subIndustry ?? null,
company.price,
company.changePct,
company.thesis,
company.founded ?? null,
company.headquarters ?? null,
company.employees ?? null
);
}
// Set active company and add holdings
const portfolioStmt = db.prepare("UPDATE portfolios SET active_company_id = ? WHERE id = ?");
portfolioStmt.run(activeCompanyId, "default");
const holdingStmt = db.prepare(`
INSERT OR REPLACE INTO holdings (portfolio_id, ticker, name, price, change_pct, weight)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const holding of holdings) {
holdingStmt.run("default", holding.ticker, holding.name, holding.price, holding.changePct, holding.weight);
}
// Insert agents metadata
export function bootstrapDatabase(db: Db): void {
const agentStmt = db.prepare(`
INSERT OR REPLACE INTO agents (id, name, description, pipeline)
INSERT INTO agents (id, name, description, pipeline)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
pipeline = excluded.pipeline
`);
for (const agent of agents) {
agentStmt.run(agent.id, agent.name, "", agent.pipeline ?? "research");
for (const agent of AGENT_DEFINITIONS) {
agentStmt.run(agent.id, agent.name, agent.description, agent.pipeline ?? null);
}
}
// Create a model for COST
const modelId = "model-cost-income";
db.prepare("INSERT OR REPLACE INTO models (id, company_id, tab) VALUES (?, ?, ?)").run(modelId, activeCompanyId, "income");
// Insert model headers
const headerStmt = db.prepare("INSERT OR REPLACE INTO model_headers (model_id, position, label) VALUES (?, ?, ?)");
modelHeaders.forEach((label: string, idx: number) => {
headerStmt.run(modelId, idx, label);
});
// Insert model rows
const rowStmt = db.prepare("INSERT OR REPLACE INTO model_rows (model_id, position, label, kind, \"values\") VALUES (?, ?, ?, ?, ?)");
modelRows.forEach((row, idx: number) => {
rowStmt.run(modelId, idx, row.label, row.kind, JSON.stringify(row.values));
});
// Insert memo for COST
db.prepare("INSERT OR REPLACE INTO memos (company_id, status) VALUES (?, ?)").run(activeCompanyId, "review");
// Insert memo sections
const memoStmt = db.prepare(`
INSERT OR REPLACE INTO memo_sections (id, company_id, title, content, primary_agent)
VALUES (?, ?, ?, ?, ?)
`);
for (const section of memoSections) {
memoStmt.run(section.id, activeCompanyId, section.title, section.content, section.primaryAgent ?? null);
}
// Insert memo citations
const citationStmt = db.prepare(`
INSERT OR REPLACE INTO memo_citations (id, company_id, section_id, label, type, title, reference, verification_status, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const citation of memoCitations) {
citationStmt.run(
citation.id,
activeCompanyId,
citation.sectionId,
citation.label,
citation.type,
citation.title,
citation.reference,
citation.verificationStatus,
citation.sourceUrl ?? null
);
}
// Insert memo annotations
const annotationStmt = db.prepare(`
INSERT OR REPLACE INTO memo_annotations (id, company_id, section_id, kind, selected_text, comment, created_by, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const annotation of memoAnnotations) {
annotationStmt.run(
annotation.id,
activeCompanyId,
annotation.sectionId,
annotation.kind,
annotation.selectedText,
annotation.comment ?? null,
annotation.createdBy,
annotation.createdAt,
annotation.status
);
}
// Insert memo section reviews
const reviewStmt = db.prepare(`
INSERT OR REPLACE INTO memo_section_reviews (company_id, section_id, status, updated_at)
VALUES (?, ?, ?, ?)
`);
for (const review of memoSectionReviews) {
reviewStmt.run(activeCompanyId, review.sectionId, review.status, review.updatedAt);
}
// Insert catalysts
const catalystStmt = db.prepare(`
INSERT OR REPLACE INTO catalysts (id, company_id, date, event, impact, thesis_relevance, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const catalyst of demoCatalysts) {
catalystStmt.run(
catalyst.id,
activeCompanyId,
catalyst.date,
catalyst.event,
catalyst.impact,
catalyst.thesisRelevance,
catalyst.source ?? null
);
}
// Insert alerts
const alertStmt = db.prepare(`
INSERT OR REPLACE INTO alerts (id, company_id, timestamp, type, description, thesis_impact, status, target_section)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const alert of demoAlerts as Alert[]) {
alertStmt.run(
alert.id,
alert.companyId ?? null,
alert.timestamp,
alert.type,
alert.description,
alert.thesisImpact,
alert.status,
alert.targetSection ?? null
);
}
// Insert risks
const riskStmt = db.prepare(`
INSERT OR REPLACE INTO risks (id, company_id, risk, category, severity, likelihood, mitigation, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const risk of demoRisks as Risk[]) {
riskStmt.run(
risk.id,
risk.companyId,
risk.risk,
risk.category,
risk.severity,
risk.likelihood,
risk.mitigation,
risk.status
);
}
// Insert earnings schedules
const earningsStmt = db.prepare(`
INSERT OR REPLACE INTO earnings_schedules (id, company_id, quarter, expected_date, timing, actual_revenue, expected_revenue, actual_eps, expected_eps)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const earnings of demoEarnings as EarningsSchedule[]) {
earningsStmt.run(
earnings.id,
earnings.companyId,
earnings.quarter,
earnings.expectedDate,
earnings.timing ?? null,
earnings.actualRevenue ?? null,
earnings.expectedRevenue ?? null,
earnings.actualEps ?? null,
earnings.expectedEps ?? null
);
}
// Insert filings
const filingStmt = db.prepare(`
INSERT OR REPLACE INTO filings (id, company_id, form_type, filed_date, title, key_changes, reviewed)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const filing of demoFilings as Filing[]) {
filingStmt.run(
filing.id,
filing.companyId,
filing.formType,
filing.filedDate,
filing.title,
filing.keyChanges ?? null,
filing.reviewed ? 1 : 0
);
}
// Insert exports
const exportStmt = db.prepare(`
INSERT OR REPLACE INTO export_records (id, type, title, company_id, format, status, created_at, download_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const exp of demoExports as ExportRecord[]) {
exportStmt.run(
exp.id,
exp.type,
exp.title,
exp.companyId ?? null,
exp.format,
exp.status,
exp.createdAt,
exp.downloadUrl ?? null
);
}
console.log("[DB] Demo data seeded successfully");
}

View File

@@ -1,162 +0,0 @@
import type { Agent, Company, Holding, MemoAnnotation, MemoCitation, MemoSection, MemoSectionReview, ModelRow } from "../../contracts/src/rpc.js";
export const activeCompanyId = "cost";
export const holdings: Holding[] = [
{ ticker: "COST", name: "Costco Wholesale Corp", price: 921.4, changePct: 1.2, weight: 32 },
{ ticker: "AMZN", name: "Amazon.com Inc", price: 186.5, changePct: 0.8, weight: 28 },
{ ticker: "WMT", name: "Walmart Inc", price: 168.3, changePct: -0.3, weight: 22 },
{ ticker: "TGT", name: "Target Corp", price: 142.8, changePct: -1.1, weight: 18 }
];
export const companies: Record<string, Company> = {
cost: {
id: "cost",
ticker: "COST",
name: "Costco Wholesale Corporation",
sector: "Consumer Staples",
subIndustry: "Membership Warehouse Clubs",
price: 921.4,
changePct: 1.2,
thesis: "Membership renewal durability and traffic resilience support premium multiple, while fuel normalization and wage pressure remain the key model sensitivities.",
founded: "1983",
headquarters: "Issaquah, WA",
employees: 320000
},
amzn: {
id: "amzn",
ticker: "AMZN",
name: "Amazon.com Inc",
sector: "Consumer Discretionary",
subIndustry: "Internet Retail",
price: 186.5,
changePct: 0.8,
thesis: "AWS re-acceleration and advertising monetization drive margin expansion, while retail competition and regulatory risk remain.",
founded: "1994",
headquarters: "Seattle, WA",
employees: 1540000
},
wmt: {
id: "wmt",
ticker: "WMT",
name: "Walmart Inc",
sector: "Consumer Staples",
subIndustry: "Hypermarkets",
price: 168.3,
changePct: -0.3,
thesis: "Grocery share gains and e-commerce profitability inflection offset margin pressure from price investment strategy.",
founded: "1962",
headquarters: "Bentonville, AR",
employees: 2100000
},
tgt: {
id: "tgt",
ticker: "TGT",
name: "Target Corp",
sector: "Consumer Discretionary",
subIndustry: "General Merchandise Stores",
price: 142.8,
changePct: -1.1,
thesis: "Traffic recovery and margin normalization potential, but discretionary headwinds and inventory risk weigh on near-term.",
founded: "1902",
headquarters: "Minneapolis, MN",
employees: 415000
}
};
export const agents: Agent[] = [
{ id: "sf", name: "SEC Filings Agent", status: "running", progress: 45, action: "Extracting segment data from 10-K", pipeline: "research" },
{ id: "fm", name: "Financial Modeling Agent", status: "running", progress: 62, action: "Building revenue schedule", pipeline: "research" },
{ id: "ec", name: "Earnings Call Agent", status: "running", progress: 78, action: "Summarizing Q2 FY25 call", pipeline: "competitive" },
{ id: "cr", name: "Company Research Agent", status: "completed", progress: 100, action: "Company overview complete", pipeline: "research" },
{ id: "va", name: "Valuation Agent", status: "queued", progress: 0, action: "Waiting for model outputs", pipeline: "research" },
{ id: "ci", name: "Competitive Intel Agent", status: "queued", progress: 0, action: "Waiting for earnings analysis", pipeline: "competitive" },
{ id: "rk", name: "Risk Agent", status: "idle", progress: 0, action: "Risk analysis pending", pipeline: "competitive" },
{ id: "mw", name: "Memo Writing Agent", status: "idle", progress: 0, action: "Memo drafting pending", pipeline: "research" },
{ id: "pa", name: "Presentation Agent", status: "idle", progress: 0, action: "Presentation pending", pipeline: "research" },
{ id: "mn", name: "Monitoring Agent", status: "idle", progress: 0, action: "Monitoring pipeline idle", pipeline: "cross-cutting" },
{ id: "sv", name: "Source Verification Agent", status: "idle", progress: 0, action: "Verification pending", pipeline: "cross-cutting" },
{ id: "rt", name: "Red Team Agent", status: "idle", progress: 0, action: "Adversarial review pending", pipeline: "competitive" },
{ id: "ex", name: "Export Agent", status: "idle", progress: 0, action: "Export pipeline idle", pipeline: "cross-cutting" },
{ id: "qa", name: "Model QA Agent", status: "idle", progress: 0, action: "QA review pending", pipeline: "cross-cutting" }
];
export const modelHeaders = ["FY2022A", "FY2023A", "FY2024A", "FY2025E", "FY2026E"];
export const modelRows: ModelRow[] = [
{ label: "Revenue", kind: "actual", values: ["$226.9B", "$242.3B", "$254.5B", "$270.4B", "$286.1B"] },
{ label: "Gross Margin", kind: "actual", values: ["12.1%", "12.3%", "12.6%", "12.7%", "12.8%"] },
{ label: "Operating Income", kind: "forecast", values: ["$7.8B", "$8.1B", "$9.3B", "$10.1B", "$10.9B"] },
{ label: "EPS", kind: "forecast", values: ["$13.14", "$14.16", "$16.56", "$18.12", "$19.84"] }
];
export const memoSections: MemoSection[] = [
{
id: "thesis",
title: "Investment Thesis",
content: "Costco remains a high-quality compounder with unusually durable traffic, renewal, and private-label economics. The core thesis is that membership fee income, disciplined SKU curation, and steady warehouse productivity can support high-single-digit earnings growth over a multi-year horizon, even as the current valuation requires disciplined sensitivity work around renewal fees, wage inflation, and merchandise margin.",
primaryAgent: "mw"
},
{
id: "drivers",
title: "Key Drivers",
content: "The model is most sensitive to membership fee cadence, comparable sales excluding fuel, and operating leverage across warehouse labor and logistics. A 50 bps change in core merchandise margin or a one-year shift in fee timing drives a disproportionate share of the bear-to-bull spread.",
primaryAgent: "mw"
},
{
id: "variant",
title: "Variant Perception",
content: "Consensus treats Costco as a fully discovered quality compounder, but underweights the durability of traffic share gains in grocery and consumables. The variant view is that renewal behavior and executive member penetration create more operating resilience than the market is giving credit for during a slower discretionary cycle.",
primaryAgent: "mw"
},
{
id: "valuation",
title: "Valuation",
content: "The base case triangulates a premium earnings multiple, a DCF anchored on low-teens discount-rate sensitivity, and peer multiples against scaled staples and retail platforms. The current share price embeds limited margin for execution misses, so valuation work should frame upside through fee timing and downside through wage and shrink pressure.",
primaryAgent: "va"
},
{
id: "quality",
title: "Business Quality",
content: "Costco's moat is built on purchasing scale, a low-markup operating philosophy, a limited-SKU model, and recurring membership economics. ROIC remains supported by high inventory turns and negative working capital dynamics, while management quality is reflected in disciplined capital allocation and consistent reinvestment in member value.",
primaryAgent: "cr"
},
{
id: "financials",
title: "Financial Summary",
content: "Revenue growth is expected to track warehouse expansion, comparable sales excluding fuel, and modest e-commerce contribution. Margin analysis should separate merchandise gross margin, membership fee income, wage inflation, logistics costs, and fuel volatility so the model does not overstate operating leverage.",
primaryAgent: "fm"
},
{
id: "risks",
title: "Risks & Mitigants",
content: "Key risks include valuation compression, delayed fee increases, labor cost inflation, weaker discretionary categories, and international execution risk. Mitigants include renewal-rate stability, grocery-led traffic, balance-sheet flexibility, and management's demonstrated willingness to protect the member value proposition through cycles.",
primaryAgent: "rk"
},
{
id: "catalysts",
title: "Catalysts",
content: "Near-term catalysts include membership fee announcements, monthly sales reports, executive member penetration updates, new warehouse openings, and quarterly commentary on traffic versus ticket. A clean fee-increase signal with stable renewal metrics would likely be the most important rerating event.",
primaryAgent: "mn"
}
];
export const memoCitations: MemoCitation[] = [
{ id: "citation-10k-membership", label: "[1]", sectionId: "thesis", type: "sec_filing", title: "FY2024 10-K - Membership economics", reference: "Annual report discussion of membership fee income and renewal rates", verificationStatus: "verified", sourceUrl: "https://investor.costco.com" },
{ id: "citation-call-executive", label: "[2]", sectionId: "drivers", type: "earnings_transcript", title: "Q2 FY2025 earnings call - Executive tier metrics", reference: "Management commentary on executive member penetration and traffic", verificationStatus: "verified" },
{ id: "citation-consensus-margin", label: "[3]", sectionId: "variant", type: "analyst_report", title: "Consensus margin sensitivity note", reference: "External analyst framing of merchandise margin risk", verificationStatus: "unverified" },
{ id: "citation-dcf-model", label: "[4]", sectionId: "valuation", type: "model", title: "Internal DCF sensitivity model", reference: "Discount-rate and terminal multiple sensitivity output", verificationStatus: "flagged" },
{ id: "citation-risk-note", label: "[5]", sectionId: "risks", type: "internal_note", title: "Risk register - Labor and fee timing", reference: "Internal notes from source verification pass", verificationStatus: "verified" }
];
export const memoAnnotations: MemoAnnotation[] = [
{ id: "annotation-thesis-target", sectionId: "thesis", kind: "comment", selectedText: "current valuation requires disciplined sensitivity work", comment: "Quantify target price range before IC circulation.", createdBy: "JD", createdAt: "2026-05-09T14:30:00.000Z", status: "open" },
{ id: "annotation-drivers-fee", sectionId: "drivers", kind: "comment", selectedText: "membership fee cadence", comment: "Tie fee cadence to the model sensitivity table.", createdBy: "MW", createdAt: "2026-05-09T15:10:00.000Z", status: "open" },
{ id: "annotation-valuation-dcf", sectionId: "valuation", kind: "highlight", selectedText: "low-teens discount-rate sensitivity", createdBy: "SV", createdAt: "2026-05-09T16:05:00.000Z", status: "open" }
];
export const memoSectionReviews: MemoSectionReview[] = memoSections.map((section) => ({
sectionId: section.id,
status: section.id === "thesis" ? "approved" : section.id === "drivers" || section.id === "variant" ? "in_review" : section.id === "valuation" ? "changes_requested" : "pending",
updatedAt: "2026-05-09T13:00:00.000Z"
}));

View File

@@ -1,44 +0,0 @@
import type { Alert, Catalyst, EarningsSchedule, ExportRecord, Filing, Risk } from "../../contracts/src/rpc.js";
export const demoCatalysts: Catalyst[] = [
{ id: "cat-1", date: "2026-06-05", event: "Q3 FY25 Earnings", impact: "high", thesisRelevance: "supports", source: "[4]" },
{ id: "cat-2", date: "2026-07-15", event: "Executive member update", impact: "medium", thesisRelevance: "supports", source: "[2]" },
{ id: "cat-3", date: "2026-08-01", event: "Annual membership fee review", impact: "high", thesisRelevance: "neutral", source: "[1]" },
{ id: "cat-4", date: "2026-09-10", event: "New warehouse openings (Q4)", impact: "low", thesisRelevance: "supports", source: "[1]" }
];
export const demoAlerts: Alert[] = [
{ id: "alert-1", companyId: "cost", timestamp: "2026-05-12T08:30:00Z", type: "earnings_surprise", description: "Q2 FY25 earnings beat (+7.5% comp)", thesisImpact: "positive", status: "new", targetSection: "thesis" },
{ id: "alert-2", companyId: "cost", timestamp: "2026-05-12T06:00:00Z", type: "filing", description: "New 8-K: Executive compensation update", thesisImpact: "neutral", status: "new" },
{ id: "alert-3", companyId: "wmt", timestamp: "2026-05-11T14:00:00Z", type: "peer_event", description: "WMT announces price investment in grocery", thesisImpact: "negative", status: "reviewed" },
{ id: "alert-4", companyId: "cost", timestamp: "2026-05-11T10:00:00Z", type: "price_move", description: "COST +2.1% on heavy volume", thesisImpact: "positive", status: "reviewed" }
];
export const demoRisks: Risk[] = [
{ id: "risk-1", companyId: "cost", risk: "Amazon enters warehouse club segment", category: "competitive", severity: "high", likelihood: "low", mitigation: "Costco's 93% renewal rate creates switching costs", status: "open" },
{ id: "risk-2", companyId: "cost", risk: "Wage inflation compresses operating margin", category: "financial", severity: "medium", likelihood: "high", mitigation: "Automation and productivity offset 40-60% of wage pressure", status: "open" },
{ id: "risk-3", companyId: "cost", risk: "Membership fee increase delayed beyond FY26", category: "financial", severity: "medium", likelihood: "medium", mitigation: "Fee income growth from executive tier conversion", status: "mitigated" },
{ id: "risk-4", companyId: "cost", risk: "Regulatory pressure on merchandise sourcing", category: "regulatory", severity: "low", likelihood: "low", mitigation: "Diversified supply chain across 14 countries", status: "accepted" },
{ id: "risk-5", companyId: "cost", risk: "E-commerce disruption of warehouse model", category: "competitive", severity: "medium", likelihood: "medium", mitigation: "Costco.com growth and Instacart partnership", status: "open" },
{ id: "risk-6", companyId: "cost", risk: "Valuation compression on growth deceleration", category: "financial", severity: "high", likelihood: "medium", mitigation: "High-single-digit earnings growth supports premium", status: "open" }
];
export const demoEarnings: EarningsSchedule[] = [
{ id: "earn-1", companyId: "cost", quarter: "Q3 FY25", expectedDate: "2026-06-05", timing: "bmo" },
{ id: "earn-2", companyId: "cost", quarter: "Q4 FY25", expectedDate: "2026-09-25", timing: "bmo" },
{ id: "earn-3", companyId: "cost", quarter: "Q2 FY25", expectedDate: "2026-03-06", timing: "bmo", actualRevenue: "$62.5B", expectedRevenue: "$61.2B", actualEps: "$4.28", expectedEps: "$4.05" },
{ id: "earn-4", companyId: "cost", quarter: "Q1 FY25", expectedDate: "2025-12-12", timing: "bmo", actualRevenue: "$60.2B", expectedRevenue: "$59.8B", actualEps: "$4.04", expectedEps: "$3.98" }
];
export const demoFilings: Filing[] = [
{ id: "filing-1", companyId: "cost", formType: "10-K", filedDate: "2024-10-10", title: "Annual Report FY2024", keyChanges: "Updated segment reporting, new warehouse commitments ($2.1B)", reviewed: true },
{ id: "filing-2", companyId: "cost", formType: "10-Q", filedDate: "2026-03-10", title: "Quarterly Report Q2 FY25", keyChanges: "Membership fee income growth, margin expansion", reviewed: true },
{ id: "filing-3", companyId: "cost", formType: "8-K", filedDate: "2026-05-12", title: "Executive Compensation Update", keyChanges: "New CEO compensation structure", reviewed: false }
];
export const demoExports: ExportRecord[] = [
{ id: "export-1", type: "excel", title: "COST Model — Revenue Build FY25-FY27", companyId: "cost", format: "Excel", fileSize: "2.4 MB", status: "complete", createdAt: "2026-05-10T14:30:00Z" },
{ id: "export-2", type: "pdf", title: "COST Investment Memo — Draft", companyId: "cost", format: "PDF", fileSize: "1.8 MB", status: "complete", createdAt: "2026-05-09T16:00:00Z" },
{ id: "export-3", type: "ppt", title: "COST IC Presentation", companyId: "cost", format: "PowerPoint", fileSize: "4.2 MB", status: "processing", createdAt: "2026-05-12T09:00:00Z" },
{ id: "export-4", type: "pdf", title: "Peer Comparison Report", companyId: "cost", format: "PDF", fileSize: "980 KB", status: "complete", createdAt: "2026-05-08T11:00:00Z" }
];

View File

@@ -1,250 +0,0 @@
import type { MemoAnnotation, MemoCitation, MemoSection, MemoSectionReview, RpcMethod, RpcRequestMap, RpcResponseMap, RpcResult } from "../../contracts/src/rpc.js";
import { parseRpcRequest, parseRpcResponse } from "../../contracts/src/rpcSchemas.js";
import { activeCompanyId, agents, companies, holdings, memoAnnotations, memoCitations, memoSectionReviews, memoSections, modelHeaders, modelRows } from "./demoData.js";
import { demoAlerts, demoCatalysts, demoEarnings, demoExports, demoFilings, demoRisks } from "./extraData.js";
type MemoStoreEntry = {
status: "draft" | "review" | "final";
sections: MemoSection[];
citations: MemoCitation[];
annotations: MemoAnnotation[];
sectionReviews: MemoSectionReview[];
};
const memoStore = new Map<string, MemoStoreEntry>();
export async function handleMockRpc<T extends RpcMethod>(
method: T,
payload: RpcRequestMap[T]
): Promise<RpcResult<T>> {
try {
parseRpcRequest(method, payload);
switch (method) {
case "portfolio.get":
return ok("portfolio.get", { id: "core", name: "Core Retail Coverage", holdings, activeCompanyId }) as RpcResult<T>;
case "portfolio.addHolding": {
const { ticker } = payload as RpcRequestMap["portfolio.addHolding"];
const company = Object.values(companies).find((c) => c.ticker === ticker.toUpperCase());
if (!company) return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`) as RpcResult<T>;
const holding = { ticker: company.ticker, name: company.name, price: company.price, changePct: company.changePct, weight: 5 };
return ok("portfolio.addHolding", { holding }) as RpcResult<T>;
}
case "portfolio.removeHolding":
return ok("portfolio.removeHolding", { ok: true }) as RpcResult<T>;
case "company.get": {
const { companyId } = payload as RpcRequestMap["company.get"];
const company = companies[companyId];
if (!company) return fail("NOT_FOUND", `Company "${companyId}" was not found.`) as RpcResult<T>;
return ok("company.get", { company }) as RpcResult<T>;
}
case "company.search": {
const { query } = payload as RpcRequestMap["company.search"];
const q = query.toLowerCase();
const results = Object.values(companies).filter((c) => c.ticker.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.sector.toLowerCase().includes(q)).map((c) => ({ ticker: c.ticker, name: c.name, sector: c.sector }));
return ok("company.search", { results }) as RpcResult<T>;
}
case "company.setActive":
return ok("company.setActive", { ok: true }) as RpcResult<T>;
case "workspace.getSection": {
const { companyId, section } = payload as RpcRequestMap["workspace.getSection"];
if (!companies[companyId]) return fail("NOT_FOUND", `Company "${companyId}" not found.`) as RpcResult<T>;
return ok("workspace.getSection", { content: { id: section, title: section, content: `Content for ${section}`, validationState: "verified" as const, sourceAgent: "cr" }, validationState: "verified" }) as RpcResult<T>;
}
case "workspace.listSources": {
const { companyId } = payload as RpcRequestMap["workspace.listSources"];
if (!companies[companyId]) return fail("NOT_FOUND", `Company "${companyId}" not found.`) as RpcResult<T>;
return ok("workspace.listSources", { sources: [{ type: "SEC Filing", title: "10-K FY2024", metadata: "Filed Oct 2024" }, { type: "Earnings Transcript", title: "Q2 FY25 Call", metadata: "Mar 2025" }] }) as RpcResult<T>;
}
case "catalyst.list":
return ok("catalyst.list", { catalysts: demoCatalysts }) as RpcResult<T>;
case "alert.list":
return ok("alert.list", { alerts: demoAlerts }) as RpcResult<T>;
case "risk.list":
return ok("risk.list", { risks: demoRisks }) as RpcResult<T>;
case "risk.add": {
const { companyId, risk } = payload as RpcRequestMap["risk.add"];
const newRisk = { ...risk, id: `risk-${Date.now()}`, companyId };
return ok("risk.add", { risk: newRisk }) as RpcResult<T>;
}
case "earnings.getSchedule":
return ok("earnings.getSchedule", { schedule: demoEarnings }) as RpcResult<T>;
case "filing.list":
return ok("filing.list", { filings: demoFilings }) as RpcResult<T>;
case "agent.list":
return ok("agent.list", { agents }) as RpcResult<T>;
case "agent.start":
return ok("agent.start", { runId: `run-${Date.now()}` }) as RpcResult<T>;
case "agent.pause":
return ok("agent.pause", { ok: true }) as RpcResult<T>;
case "agent.restart":
return ok("agent.restart", { runId: `run-${Date.now()}` }) as RpcResult<T>;
case "agent.chat":
return ok("agent.chat", { response: "I've analyzed the data. Key findings: revenue growth remains on track with 5.6% YoY. The main assumption driving the model is membership fee cadence." }) as RpcResult<T>;
case "agent.configure":
return ok("agent.configure", { ok: true }) as RpcResult<T>;
case "agent.getTrace":
return ok("agent.getTrace", { steps: [{ step: 1, label: "Load filings", detail: "Loaded 10-K and 3 quarterly reports" }, { step: 2, label: "Extract segments", detail: "Parsed 5 revenue segments" }, { step: 3, label: "Build model", detail: "Constructed revenue build with growth rates" }] }) as RpcResult<T>;
case "agent.runPipeline":
return ok("agent.runPipeline", { runIds: [`run-${Date.now()}`, `run-${Date.now() + 1}`, `run-${Date.now() + 2}`] }) as RpcResult<T>;
case "model.get":
return ok("model.get", { headers: modelHeaders, rows: modelRows }) as RpcResult<T>;
case "model.updateCell":
return ok("model.updateCell", { ok: true, affectedCells: [] }) as RpcResult<T>;
case "model.runScenario":
return ok("model.runScenario", { headers: [...modelHeaders], rows: modelRows.map((r) => ({ ...r })) }) as RpcResult<T>;
case "memo.get": {
const { companyId } = payload as RpcRequestMap["memo.get"];
const memo = getCompanyMemo(companyId);
if (!memo) return fail("NOT_FOUND", `Company "${companyId}" was not found.`) as RpcResult<T>;
return ok("memo.get", cloneMemo(memo)) as RpcResult<T>;
}
case "memo.updateSection": {
const { companyId, sectionId, title, content } = payload as RpcRequestMap["memo.updateSection"];
const memo = getCompanyMemo(companyId);
if (!memo) return fail("NOT_FOUND", `Company "${companyId}" not found.`) as RpcResult<T>;
if (content.trim().length === 0) return fail("VALIDATION_ERROR", "Memo section content cannot be empty.") as RpcResult<T>;
if (title !== undefined && title.trim().length === 0) return fail("VALIDATION_ERROR", "Memo section title cannot be empty.") as RpcResult<T>;
const idx = memo.sections.findIndex((s) => s.id === sectionId);
if (idx === -1) return fail("NOT_FOUND", `Section "${sectionId}" not found.`) as RpcResult<T>;
const savedAt = new Date().toISOString();
const section = { ...memo.sections[idx], ...(title === undefined ? {} : { title }), content, updatedAt: savedAt };
memo.sections[idx] = section;
return ok("memo.updateSection", { section: { ...section }, status: memo.status, savedAt }) as RpcResult<T>;
}
case "memo.addAnnotation": {
const { companyId, sectionId, kind, selectedText, comment } = payload as RpcRequestMap["memo.addAnnotation"];
const memo = getCompanyMemo(companyId);
if (!memo) return fail("NOT_FOUND", `Company "${companyId}" not found.`) as RpcResult<T>;
if (!memo.sections.some((s) => s.id === sectionId)) return fail("NOT_FOUND", `Section "${sectionId}" not found.`) as RpcResult<T>;
if (selectedText.trim().length === 0) return fail("VALIDATION_ERROR", "Annotation selected text cannot be empty.") as RpcResult<T>;
if (kind === "comment" && (!comment || comment.trim().length === 0)) return fail("VALIDATION_ERROR", "Comment annotation requires comment text.") as RpcResult<T>;
const annotation: MemoAnnotation = { id: `annotation-${Date.now()}`, sectionId, kind, selectedText: selectedText.trim(), ...(comment === undefined ? {} : { comment: comment.trim() }), createdBy: "JD", createdAt: new Date().toISOString(), status: "open" };
memo.annotations.unshift(annotation);
return ok("memo.addAnnotation", { annotation: { ...annotation } }) as RpcResult<T>;
}
case "memo.resolveAnnotation": {
const { companyId, annotationId } = payload as RpcRequestMap["memo.resolveAnnotation"];
const memo = getCompanyMemo(companyId);
if (!memo) return fail("NOT_FOUND", `Company "${companyId}" not found.`) as RpcResult<T>;
const ai = memo.annotations.findIndex((a) => a.id === annotationId);
if (ai === -1) return fail("NOT_FOUND", `Annotation "${annotationId}" not found.`) as RpcResult<T>;
const annotation = { ...memo.annotations[ai], status: "resolved" as const };
memo.annotations[ai] = annotation;
return ok("memo.resolveAnnotation", { annotation: { ...annotation } }) as RpcResult<T>;
}
case "memo.updateSectionReview": {
const { companyId, sectionId, status } = payload as RpcRequestMap["memo.updateSectionReview"];
const memo = getCompanyMemo(companyId);
if (!memo) return fail("NOT_FOUND", `Company "${companyId}" not found.`) as RpcResult<T>;
if (!memo.sections.some((s) => s.id === sectionId)) return fail("NOT_FOUND", `Section "${sectionId}" not found.`) as RpcResult<T>;
const updatedAt = new Date().toISOString();
const review = { sectionId, status, updatedAt };
const ri = memo.sectionReviews.findIndex((r) => r.sectionId === sectionId);
if (ri === -1) memo.sectionReviews.push(review);
else memo.sectionReviews[ri] = review;
return ok("memo.updateSectionReview", { review: { ...review } }) as RpcResult<T>;
}
case "memo.acceptEdit":
return ok("memo.acceptEdit", { ok: true }) as RpcResult<T>;
case "memo.rejectEdit":
return ok("memo.rejectEdit", { ok: true }) as RpcResult<T>;
case "export.list":
return ok("export.list", { exports: demoExports }) as RpcResult<T>;
case "export.create":
return ok("export.create", { exportId: `export-${Date.now()}` }) as RpcResult<T>;
case "export.download":
return ok("export.download", { data: new ArrayBuffer(0) }) as RpcResult<T>;
case "settings.get": {
const { scope } = payload as RpcRequestMap["settings.get"];
if (scope === "client") return ok("settings.get", { settings: { theme: "light" as const, density: "comfortable" as const, sidebarWidth: 240, navCollapsed: {}, keybindings: {} } }) as RpcResult<T>;
return ok("settings.get", { settings: { agentConfigs: {}, dataSources: { sec_filings: true, transcripts: true, market_data: false, analyst_reports: false, press_releases: true }, exportPipelines: {} } }) as RpcResult<T>;
}
case "settings.update":
return ok("settings.update", { ok: true }) as RpcResult<T>;
default:
return fail("VALIDATION_ERROR", "Unknown RPC method.") as RpcResult<T>;
}
} catch (error) {
if (error instanceof Error && error.name === "ZodError") {
return fail("VALIDATION_ERROR", "Invalid RPC payload.", error) as RpcResult<T>;
}
return fail("INTERNAL_ERROR", "Unhandled mock RPC failure.", error) as RpcResult<T>;
}
}
function ok<T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
if (process.env.NODE_ENV !== "production") {
parseRpcResponse(_, data);
}
return { ok: true, data };
}
function getCompanyMemo(companyId: string): MemoStoreEntry | null {
if (!companies[companyId]) return null;
const stored = memoStore.get(companyId);
if (stored) return stored;
const memo = {
status: "draft" as const,
sections: memoSections.map((s) => ({ ...s })),
citations: memoCitations.map((c) => ({ ...c })),
annotations: memoAnnotations.map((a) => ({ ...a })),
sectionReviews: memoSectionReviews.map((r) => ({ ...r }))
};
memoStore.set(companyId, memo);
return memo;
}
function cloneMemo(memo: MemoStoreEntry): RpcResponseMap["memo.get"] {
return {
status: memo.status,
sections: memo.sections.map((s) => ({ ...s })),
citations: memo.citations.map((c) => ({ ...c })),
annotations: memo.annotations.map((a) => ({ ...a })),
sectionReviews: memo.sectionReviews.map((r) => ({ ...r }))
};
}
function fail<T extends RpcMethod>(
code: "NOT_FOUND" | "VALIDATION_ERROR" | "INTERNAL_ERROR" | "AGENT_FAILED" | "CONFLICT" | "RATE_LIMITED",
message: string,
detail?: unknown
): RpcResult<T> {
return { ok: false, error: { code, message, detail } };
}

View File

@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
import { createRpcHandler } from "../db/rpcHandler.js";
import { bootstrapDatabase } from "../db/seed.js";
import { parseRpcResponse } from "@mosaiciq/contracts/rpcSchemas";
describe("agent RPC", () => {
let db: Db;
let rpc: ReturnType<typeof createRpcHandler>;
beforeEach(() => {
db = initDatabase({ inMemory: true });
bootstrapDatabase(db);
rpc = createRpcHandler(db);
});
afterEach(() => {
closeDatabase(db);
});
it("returns contract-valid idle agents", async () => {
const result = await rpc("agent.list", {});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(() => parseRpcResponse("agent.list", result.data)).not.toThrow();
expect(result.data.agents.every((agent) => typeof agent.action === "string")).toBe(true);
});
it("returns a clear chat failure when no company is active", async () => {
const result = await rpc("agent.chat", { agentId: "fm", message: "What changed?" });
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION_ERROR" } });
});
});

View File

@@ -1,6 +1,10 @@
import type { Db } from "../db/database.js";
import { listAgents, pauseAgent, restartAgent, startAgent, updateAgentConfig } from "../db/queries.js";
import { ok } from "./result.js";
import { addAgentRunStep, createAgentRun, getPortfolio, listAgentRunSteps, listAgents, pauseAgent, storeAgentOutput, updateAgentConfig, updateAgentRunCompletion } from "../db/queries.js";
import { executeAgent } from "../agents/runner.js";
import { buildChatContext } from "../llm/context.js";
import { complete, isConfigured } from "../llm/client.js";
import { getAgentPrompt } from "../llm/prompts.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
type AgentMethod =
@@ -17,30 +21,64 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
return {
"agent.list": ({ companyId }) => ok("agent.list", { agents: listAgents(db, companyId) }),
"agent.start": ({ agentId, companyId }) => {
const { runId } = startAgent(db, agentId, companyId);
const { runId } = createAgentRun(db, agentId, companyId, "queued");
void executeAgent(db, agentId, companyId, { runId }).catch((error) => {
updateAgentRunCompletion(db, runId, "failed", "Agent failed", error instanceof Error ? error.message : String(error));
});
return ok("agent.start", { runId });
},
"agent.pause": ({ agentId }) => ok("agent.pause", pauseAgent(db, agentId)),
"agent.restart": ({ agentId }) => {
const { runId } = restartAgent(db, agentId);
const companyId = getPortfolio(db)?.activeCompanyId;
if (!companyId) return fail("VALIDATION_ERROR", "Select or add a company before restarting an agent.");
const { runId } = createAgentRun(db, agentId, companyId, "queued");
void executeAgent(db, agentId, companyId, { runId }).catch((error) => {
updateAgentRunCompletion(db, runId, "failed", "Agent failed", error instanceof Error ? error.message : String(error));
});
return ok("agent.restart", { runId });
},
"agent.chat": () =>
ok("agent.chat", {
response: "I've analyzed the data. Key findings: revenue growth remains on track with 5.6% YoY.",
}),
"agent.chat": async ({ agentId, message }) => {
const portfolio = getPortfolio(db);
const companyId = portfolio?.activeCompanyId;
if (!companyId) return fail("VALIDATION_ERROR", "Select or add a company before chatting with an agent.");
if (!isConfigured()) return fail("AGENT_FAILED", "LLM API key is not configured. Set PI_API_KEY to use agent chat.");
const { runId } = createAgentRun(db, agentId, companyId, "running");
addAgentRunStep(db, runId, 1, "Build context", "Loaded local company, memo, filings, and model context.");
try {
const context = await buildChatContext(db, companyId, message);
addAgentRunStep(db, runId, 2, "Generate response", `Sent analyst message to ${agentId}.`);
const response = await complete(getAgentPrompt("chat", context));
storeAgentOutput(db, runId, agentId, companyId, { response }, response);
updateAgentRunCompletion(db, runId, "completed", "Chat response generated");
addAgentRunStep(db, runId, 3, "Persist response", "Saved chat response to the local run log.");
return ok("agent.chat", { response });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
updateAgentRunCompletion(db, runId, "failed", "Chat failed", message);
return fail("AGENT_FAILED", message, error);
}
},
"agent.configure": ({ agentId, config }) => {
updateAgentConfig(db, agentId, config);
return ok("agent.configure", { ok: true });
},
"agent.getTrace": () =>
ok("agent.getTrace", {
steps: [
{ step: 1, label: "Load filings", detail: "Loaded 10-K and 3 quarterly reports" },
{ step: 2, label: "Extract segments", detail: "Parsed 5 revenue segments" },
{ step: 3, label: "Build model", detail: "Constructed revenue build with growth rates" },
],
}),
"agent.runPipeline": () => ok("agent.runPipeline", { runIds: [] }),
"agent.getTrace": ({ runId }) => ok("agent.getTrace", { steps: listAgentRunSteps(db, runId) }),
"agent.runPipeline": ({ companyId, pipeline }) => {
const pipelineAgents: Record<string, string[]> = {
research: ["sf", "cr", "fm", "va", "mw", "pa"],
competitive: ["ec", "ci", "rk", "rt"],
"cross-cutting": ["mn", "sv", "ex", "qa"],
};
const agentIds = pipelineAgents[pipeline];
if (!agentIds) return fail("VALIDATION_ERROR", `Unknown pipeline: ${pipeline}`);
const runIds = agentIds.map((pipelineAgentId) => {
const { runId } = createAgentRun(db, pipelineAgentId, companyId, "queued");
void executeAgent(db, pipelineAgentId, companyId, { runId }).catch((error) => {
updateAgentRunCompletion(db, runId, "failed", "Agent failed", error instanceof Error ? error.message : String(error));
});
return runId;
});
return ok("agent.runPipeline", { runIds });
},
};
}

View File

@@ -2,6 +2,9 @@ import type { Db } from "../db/database.js";
import {
getCompany,
getCompanyByTicker,
createDefaultMemo,
createDefaultModel,
resolveCompany,
searchCompanies,
setActiveCompany,
upsertCompany,
@@ -11,10 +14,17 @@ import { fetchQuote, getCompanyProfile, searchStocks } from "../data/market.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
function errorDetail(operation: string, error: unknown): { operation: string; message?: string } {
return {
operation,
message: error instanceof Error ? error.message : undefined,
};
}
export function companyHandlers(db: Db): RpcHandlers<"company.get" | "company.search" | "company.setActive"> {
return {
"company.get": async ({ companyId }) => {
let company = getCompany(db, companyId);
let company = resolveCompany(db, companyId);
if (!company) {
try {
@@ -31,7 +41,7 @@ export function companyHandlers(db: Db): RpcHandlers<"company.get" | "company.se
changePct: quote?.changePercent || 0,
thesis: "",
});
company = getCompany(db, companyId);
company = getCompanyByTicker(db, companyId);
}
} catch (error) {
console.error("[RPC] Error fetching company:", error);
@@ -48,7 +58,7 @@ export function companyHandlers(db: Db): RpcHandlers<"company.get" | "company.se
UPDATE companies
SET price = ?, change_pct = ?, updated_at = datetime('now')
WHERE id = ?
`).run(quote.price, quote.changePercent, companyId);
`).run(quote.price, quote.changePercent, company.id);
}
} catch {
// Use cached price if refresh fails.
@@ -94,7 +104,23 @@ export function companyHandlers(db: Db): RpcHandlers<"company.get" | "company.se
}
},
"company.setActive": ({ companyId }) => {
setActiveCompany(db, DEFAULT_PORTFOLIO_ID, companyId);
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" was not found.`);
try {
setActiveCompany(db, DEFAULT_PORTFOLIO_ID, company.id);
} catch (error) {
return fail("INTERNAL_ERROR", "Could not prepare company workspace.", errorDetail("setActiveCompany", error));
}
try {
createDefaultMemo(db, company.id);
} catch (error) {
return fail("INTERNAL_ERROR", "Could not prepare company workspace.", errorDetail("createDefaultMemo", error));
}
try {
createDefaultModel(db, company.id, "operating");
} catch (error) {
return fail("INTERNAL_ERROR", "Could not prepare company workspace.", errorDetail("createDefaultModel", error));
}
return ok("company.setActive", { ok: true });
},
};

View File

@@ -1,16 +1,55 @@
import type { Db } from "../db/database.js";
import { createExport, listExports } from "../db/queries.js";
import { ok } from "./result.js";
import { getDataDir } from "../db/database.js";
import { createExport, getCompany, getExport, getMemo, getModel, listExports, updateExportStatus } from "../db/queries.js";
import { exportModelAsExcel, exportMemoAsHTML, exportPresentation, getMemoFilename, getModelFilename } from "../export/index.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export function exportHandlers(db: Db): RpcHandlers<"export.list" | "export.create" | "export.download"> {
return {
"export.list": ({ companyId }) => ok("export.list", { exports: listExports(db, companyId) }),
"export.create": ({ type, companyId, options }) => {
"export.create": async ({ type, companyId, options }) => {
const format = (options?.format as string | undefined) ?? "pdf";
const { exportId } = createExport(db, type, `Export ${type}`, companyId, format);
const company = getCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
const title = `${company.ticker} ${type.toUpperCase()} Export`;
const { exportId } = createExport(db, type, title, company.id, format);
try {
const exportDir = join(getDataDir(), "exports");
mkdirSync(exportDir, { recursive: true });
let buffer: Buffer;
let filename: string;
if (type === "excel") {
const model = getModel(db, company.id, "operating");
buffer = await exportModelAsExcel(model.headers, model.rows, company);
filename = getModelFilename(company, "operating");
} else if (type === "ppt") {
const memo = getMemo(db, company.id);
buffer = await exportPresentation(memo, company);
filename = `${company.ticker}_Presentation_${new Date().toISOString().split("T")[0]}.pptx`;
} else {
const memo = getMemo(db, company.id);
buffer = Buffer.from(await exportMemoAsHTML(memo, company), "utf-8");
filename = getMemoFilename(company).replace(/\.pdf$/, ".html");
}
const filePath = join(exportDir, `${exportId}-${filename}`);
writeFileSync(filePath, buffer);
updateExportStatus(db, exportId, "complete", filePath);
} catch (error) {
updateExportStatus(db, exportId, "failed");
return fail("INTERNAL_ERROR", "Export failed.", error);
}
return ok("export.create", { exportId });
},
"export.download": () => ok("export.download", { data: new ArrayBuffer(0) }),
"export.download": ({ exportId }) => {
const record = getExport(db, exportId);
if (!record) return fail("NOT_FOUND", `Export "${exportId}" not found.`);
if (record.status !== "complete") return fail("CONFLICT", `Export "${exportId}" is ${record.status}.`);
if (!record.downloadUrl) return fail("NOT_FOUND", `Export "${exportId}" has no stored file.`);
const buffer = readFileSync(record.downloadUrl);
return ok("export.download", { data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) });
},
};
}

View File

@@ -1,5 +1,5 @@
import type { Db } from "../db/database.js";
import { getEarningsSchedule, listAlerts, listCatalysts, listFilings, listRisks, addRisk } from "../db/queries.js";
import { getEarningsSchedule, listAlerts, listCatalysts, listFilings, listRisks, addRisk, addWorkspaceSource, resolveCompany } from "../db/queries.js";
import { getEarningsDate } from "../data/earnings.js";
import { fetchFilings } from "../data/sec.js";
import { ok } from "./result.js";
@@ -15,22 +15,30 @@ type MarketMethod =
export function marketHandlers(db: Db): RpcHandlers<MarketMethod> {
return {
"catalyst.list": ({ companyId }) => ok("catalyst.list", { catalysts: listCatalysts(db, companyId) }),
"catalyst.list": ({ companyId }) => {
const company = resolveCompany(db, companyId);
return ok("catalyst.list", { catalysts: company ? listCatalysts(db, company.id) : [] });
},
"alert.list": ({ companyId, since }) => ok("alert.list", { alerts: listAlerts(db, companyId, since) }),
"risk.list": ({ companyId }) => ok("risk.list", { risks: listRisks(db, companyId) }),
"risk.list": ({ companyId }) => {
const company = resolveCompany(db, companyId);
return ok("risk.list", { risks: company ? listRisks(db, company.id) : [] });
},
"risk.add": ({ companyId, risk }) => ok("risk.add", { risk: addRisk(db, companyId, risk) }),
"earnings.getSchedule": async ({ companyId }) => {
let schedule = getEarningsSchedule(db, companyId);
const company = resolveCompany(db, companyId);
if (!company) return ok("earnings.getSchedule", { schedule: [] });
let schedule = getEarningsSchedule(db, company.id);
if (schedule.length === 0) {
try {
const earningsDate = await getEarningsDate(companyId);
const earningsDate = await getEarningsDate(company.ticker);
if (earningsDate) {
const quarter = `Q${Math.floor(earningsDate.getMonth() / 3) + 1} ${earningsDate.getFullYear()}`;
db.prepare(`
INSERT INTO earnings_schedules (id, company_id, quarter, expected_date)
VALUES (?, ?, ?, ?)
`).run(`earnings-${companyId}-${Date.now()}`, companyId, quarter, earningsDate.toISOString());
schedule = getEarningsSchedule(db, companyId);
`).run(`earnings-${company.id}-${Date.now()}`, company.id, quarter, earningsDate.toISOString());
schedule = getEarningsSchedule(db, company.id);
}
} catch (error) {
console.error("[RPC] Error fetching earnings date:", error);
@@ -39,19 +47,32 @@ export function marketHandlers(db: Db): RpcHandlers<MarketMethod> {
return ok("earnings.getSchedule", { schedule });
},
"filing.list": async ({ companyId, since }) => {
let filings = listFilings(db, companyId, since);
const company = resolveCompany(db, companyId);
if (!company) return ok("filing.list", { filings: [] });
let filings = listFilings(db, company.id, since);
if (filings.length === 0) {
try {
const secFilings = await fetchFilings(companyId, { limit: 20 });
const secFilings = await fetchFilings(company.ticker, { limit: 20 });
for (const filing of secFilings) {
db.prepare(`
INSERT INTO filings (id, company_id, form_type, filed_date, title)
VALUES (?, ?, ?, ?, ?)
`).run(`${companyId}-${filing.formType}-${filing.filedDate}`, companyId, filing.formType, filing.filedDate, filing.title);
`).run(`${company.id}-${filing.formType}-${filing.filedDate}`, company.id, filing.formType, filing.filedDate, filing.title);
try {
addWorkspaceSource(db, company.id, {
id: `source-${company.id}-${filing.formType}-${filing.filedDate}`,
type: "SEC Filing",
title: filing.title,
metadata: `Filed ${filing.filedDate}`,
sourceUrl: filing.url,
});
} catch (error) {
console.warn("[RPC] Could not persist filing workspace source:", error);
}
}
filings = secFilings.map((filing) => ({
id: `${companyId}-${filing.formType}-${filing.filedDate}`,
companyId,
id: `${company.id}-${filing.formType}-${filing.filedDate}`,
companyId: company.id,
formType: filing.formType,
filedDate: filing.filedDate,
title: filing.title,

View File

@@ -1,8 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { activeCompanyId, memoSections } from "../demoData.js";
import { parseRpcResponse } from "@mosaiciq/contracts/rpcSchemas";
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
import { createRpcHandler } from "../db/rpcHandler.js";
import { seedDatabase } from "../db/seed.js";
const activeCompanyId = "aapl";
const memoSections = [{ id: "thesis", title: "Investment Thesis", content: "Apple remains a high-quality platform business." }];
describe("memo RPC", () => {
let db: Db;
@@ -10,7 +12,9 @@ describe("memo RPC", () => {
beforeEach(() => {
db = initDatabase({ inMemory: true });
seedDatabase(db);
db.prepare("INSERT INTO companies (id, ticker, name, sector, price, change_pct, thesis) VALUES ('aapl', 'AAPL', 'Apple Inc.', 'Technology', 0, 0, '')").run();
db.prepare("INSERT INTO memos (company_id, status) VALUES ('aapl', 'draft')").run();
db.prepare("INSERT INTO memo_sections (id, company_id, title, content) VALUES (?, 'aapl', ?, ?)").run(memoSections[0].id, memoSections[0].title, memoSections[0].content);
rpc = createRpcHandler(db);
});
@@ -65,4 +69,56 @@ describe("memo RPC", () => {
expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
});
it("returns contract-valid camelCase annotation mutation responses", async () => {
const add = await rpc("memo.addAnnotation", {
companyId: activeCompanyId,
sectionId: memoSections[0].id,
kind: "highlight",
selectedText: "Apple remains a high-quality platform business",
});
expect(add.ok).toBe(true);
if (!add.ok) return;
expect(() => parseRpcResponse("memo.addAnnotation", add.data)).not.toThrow();
expect(add.data.annotation).toMatchObject({
sectionId: memoSections[0].id,
selectedText: "Apple remains a high-quality platform business",
createdBy: "JD",
status: "open",
});
expect(add.data.annotation).not.toHaveProperty("section_id");
expect(add.data.annotation).not.toHaveProperty("selected_text");
const resolve = await rpc("memo.resolveAnnotation", {
companyId: activeCompanyId,
annotationId: add.data.annotation.id,
});
expect(resolve.ok).toBe(true);
if (!resolve.ok) return;
expect(() => parseRpcResponse("memo.resolveAnnotation", resolve.data)).not.toThrow();
expect(resolve.data.annotation).toMatchObject({
id: add.data.annotation.id,
status: "resolved",
});
expect(resolve.data.annotation).not.toHaveProperty("created_at");
});
it("returns a contract-valid camelCase section review mutation response", async () => {
const result = await rpc("memo.updateSectionReview", {
companyId: activeCompanyId,
sectionId: memoSections[0].id,
status: "changes_requested",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(() => parseRpcResponse("memo.updateSectionReview", result.data)).not.toThrow();
expect(result.data.review).toMatchObject({
sectionId: memoSections[0].id,
status: "changes_requested",
});
expect(result.data.review).not.toHaveProperty("section_id");
});
});

View File

@@ -3,12 +3,20 @@ import {
addMemoAnnotation,
getMemo,
resolveMemoAnnotation,
resolveCompany,
updateMemoSection,
updateMemoSectionReview,
} from "../db/queries.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
function errorDetail(operation: string, error: unknown): { operation: string; message?: string } {
return {
operation,
message: error instanceof Error ? error.message : undefined,
};
}
type MemoMethod =
| "memo.get"
| "memo.updateSection"
@@ -20,25 +28,44 @@ type MemoMethod =
export function memoHandlers(db: Db): RpcHandlers<MemoMethod> {
return {
"memo.get": ({ companyId }) => ok("memo.get", getMemo(db, companyId)),
"memo.get": ({ companyId }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
try {
return ok("memo.get", getMemo(db, company.id));
} catch (error) {
return fail("INTERNAL_ERROR", "Could not load memo for company.", errorDetail("getMemo", error));
}
},
"memo.updateSection": ({ companyId, sectionId, title, content }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
if (content.trim().length === 0) return fail("VALIDATION_ERROR", "Memo section content cannot be empty.");
if (title !== undefined && title.trim().length === 0) return fail("VALIDATION_ERROR", "Memo section title cannot be empty.");
const section = updateMemoSection(db, companyId, sectionId, { title, content });
try {
const section = updateMemoSection(db, company.id, sectionId, { title, content });
return ok("memo.updateSection", {
section,
status: "draft",
savedAt: new Date().toISOString(),
});
} catch {
return fail("NOT_FOUND", `Section "${sectionId}" not found.`);
}
},
"memo.addAnnotation": ({ companyId, sectionId, kind, selectedText, comment }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
if (selectedText.trim().length === 0) return fail("VALIDATION_ERROR", "Annotation selected text cannot be empty.");
if (kind === "comment" && (!comment || comment.trim().length === 0)) {
return fail("VALIDATION_ERROR", "Comment annotation requires comment text.");
}
if (!getMemo(db, company.id).sections.some((section) => section.id === sectionId)) {
return fail("NOT_FOUND", `Section "${sectionId}" not found.`);
}
const annotation = addMemoAnnotation(db, companyId, {
const annotation = addMemoAnnotation(db, company.id, {
sectionId,
kind,
selectedText: selectedText.trim(),
@@ -50,13 +77,18 @@ export function memoHandlers(db: Db): RpcHandlers<MemoMethod> {
return ok("memo.addAnnotation", { annotation });
},
"memo.resolveAnnotation": ({ companyId, annotationId }) => {
const annotation = resolveMemoAnnotation(db, companyId, annotationId);
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
const annotation = resolveMemoAnnotation(db, company.id, annotationId);
if (!annotation) return fail("NOT_FOUND", `Annotation "${annotationId}" not found.`);
return ok("memo.resolveAnnotation", { annotation });
},
"memo.updateSectionReview": ({ companyId, sectionId, status }) =>
ok("memo.updateSectionReview", { review: updateMemoSectionReview(db, companyId, sectionId, status) }),
"memo.acceptEdit": () => ok("memo.acceptEdit", { ok: true }),
"memo.rejectEdit": () => ok("memo.rejectEdit", { ok: true }),
"memo.updateSectionReview": ({ companyId, sectionId, status }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("memo.updateSectionReview", { review: updateMemoSectionReview(db, company.id, sectionId, status) });
},
"memo.acceptEdit": ({ editId }) => fail("NOT_FOUND", `Suggested edit "${editId}" not found.`),
"memo.rejectEdit": ({ editId }) => fail("NOT_FOUND", `Suggested edit "${editId}" not found.`),
};
}

View File

@@ -1,8 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { activeCompanyId } from "../demoData.js";
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
import { createRpcHandler } from "../db/rpcHandler.js";
import { seedDatabase } from "../db/seed.js";
describe("model RPC", () => {
let db: Db;
@@ -10,7 +8,7 @@ describe("model RPC", () => {
beforeEach(() => {
db = initDatabase({ inMemory: true });
seedDatabase(db);
db.prepare("INSERT INTO companies (id, ticker, name, sector, price, change_pct, thesis) VALUES ('aapl', 'AAPL', 'Apple Inc.', 'Technology', 0, 0, '')").run();
rpc = createRpcHandler(db);
});
@@ -20,7 +18,7 @@ describe("model RPC", () => {
it("rejects out-of-range model cell updates without throwing", async () => {
const result = await rpc("model.updateCell", {
companyId: activeCompanyId,
companyId: "aapl",
tab: "income",
row: 0,
col: 999,
@@ -29,4 +27,19 @@ describe("model RPC", () => {
expect(result).toEqual({ ok: true, data: { ok: false, affectedCells: [] } });
});
it("returns an empty model when no rows exist yet", async () => {
const result = await rpc("model.get", { companyId: "aapl", tab: "operating" });
expect(result).toEqual({ ok: true, data: { headers: [], rows: [] } });
});
it("returns a typed failure when model storage is unavailable", async () => {
db.prepare("DROP TABLE models").run();
const result = await rpc("model.get", { companyId: "aapl", tab: "operating" });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe("INTERNAL_ERROR");
expect(result.error.message).toBe("Could not load model for company.");
});
});

View File

@@ -1,16 +1,50 @@
import type { Db } from "../db/database.js";
import { getModel, updateModelCell } from "../db/queries.js";
import { ok } from "./result.js";
import { createModelRow, deleteModelRow, getModel, resolveCompany, updateModelCell } from "../db/queries.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCell" | "model.runScenario"> {
function errorDetail(operation: string, error: unknown): { operation: string; message?: string } {
return {
"model.get": ({ companyId, tab }) => ok("model.get", getModel(db, companyId, tab)),
"model.updateCell": ({ companyId, tab, row, col, value }) =>
ok("model.updateCell", updateModelCell(db, companyId, tab, row, col, value)),
"model.runScenario": ({ companyId }) => {
const model = getModel(db, companyId, "income");
return ok("model.runScenario", model);
operation,
message: error instanceof Error ? error.message : undefined,
};
}
export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCell" | "model.createRow" | "model.deleteRow" | "model.runScenario"> {
return {
"model.get": ({ companyId, tab }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
try {
return ok("model.get", getModel(db, company.id, tab));
} catch (error) {
return fail("INTERNAL_ERROR", "Could not load model for company.", errorDetail("getModel", error));
}
},
"model.updateCell": ({ companyId, tab, row, col, value }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("model.updateCell", updateModelCell(db, company.id, tab, row, col, value));
},
"model.createRow": ({ companyId, tab, label, kind, values }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("model.createRow", createModelRow(db, company.id, tab, { label, kind, values: values ?? [] }));
},
"model.deleteRow": ({ companyId, tab, row }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("model.deleteRow", { ok: deleteModelRow(db, company.id, tab, row) });
},
"model.runScenario": ({ companyId, overrides }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
const model = getModel(db, company.id, "operating");
const rows = model.rows.map((row, rowIndex) => ({
...row,
values: row.values.map((value, colIndex) => overrides[`${rowIndex}-${colIndex}`] ?? value),
}));
return ok("model.runScenario", { headers: model.headers, rows });
},
};
}

View File

@@ -1,6 +1,8 @@
import type { Db } from "../db/database.js";
import { addHolding, getCompany, getPortfolio, removeHolding } from "../db/queries.js";
import type { Holding } from "@mosaiciq/contracts/rpc";
import { addHolding, clearActiveCompany, createDefaultMemo, createDefaultModel, getCompanyByTicker, getPortfolio, removeHolding, resolveCompany, setActiveCompany, upsertCompany } from "../db/queries.js";
import { DEFAULT_PORTFOLIO_ID } from "../db/schema.js";
import { fetchQuote, getCompanyProfile } from "../data/market.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
@@ -11,8 +13,28 @@ export function portfolioHandlers(db: Db): RpcHandlers<"portfolio.get" | "portfo
if (!portfolio) return fail("NOT_FOUND", "Portfolio not found.");
return ok("portfolio.get", portfolio);
},
"portfolio.addHolding": ({ ticker }) => {
const company = getCompany(db, ticker.toUpperCase());
"portfolio.addHolding": async ({ ticker }) => {
const normalizedTicker = ticker.toUpperCase();
let company = getCompanyByTicker(db, normalizedTicker);
if (!company) {
const [quote, profile] = await Promise.all([
fetchQuote(normalizedTicker),
getCompanyProfile(normalizedTicker),
]);
if (!quote && !profile) return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`);
upsertCompany(db, {
id: normalizedTicker.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
ticker: normalizedTicker,
name: profile?.name || normalizedTicker,
sector: profile?.sector || "Unknown",
subIndustry: profile?.industry,
price: quote?.price || 0,
changePct: quote?.changePercent || 0,
thesis: profile?.description || "",
employees: profile?.employees,
});
company = getCompanyByTicker(db, normalizedTicker);
}
if (!company) return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`);
const holding = {
ticker: company.ticker,
@@ -22,10 +44,24 @@ export function portfolioHandlers(db: Db): RpcHandlers<"portfolio.get" | "portfo
weight: 5,
};
addHolding(db, DEFAULT_PORTFOLIO_ID, holding);
const portfolio = getPortfolio(db);
if (!portfolio?.activeCompanyId) setActiveCompany(db, DEFAULT_PORTFOLIO_ID, company.id);
createDefaultMemo(db, company.id);
createDefaultModel(db, company.id, "operating");
return ok("portfolio.addHolding", { holding });
},
"portfolio.removeHolding": ({ ticker }) => {
const company = resolveCompany(db, ticker);
removeHolding(db, DEFAULT_PORTFOLIO_ID, ticker.toUpperCase());
const portfolio = getPortfolio(db);
if (company && portfolio?.activeCompanyId === company.id) {
const next = portfolio.holdings.find((holding: Holding) => holding.ticker.toUpperCase() !== ticker.toUpperCase());
if (!next) clearActiveCompany(db, DEFAULT_PORTFOLIO_ID);
else {
const nextCompany = getCompanyByTicker(db, next.ticker);
if (nextCompany) setActiveCompany(db, DEFAULT_PORTFOLIO_ID, nextCompany.id);
}
}
return ok("portfolio.removeHolding", { ok: true });
},
};

View File

@@ -0,0 +1,77 @@
import { parseRpcResponse } from "@mosaiciq/contracts/rpcSchemas";
import type { RpcMethod, RpcRequestMap } from "@mosaiciq/contracts/rpc";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
import { createRpcHandler } from "../db/rpcHandler.js";
import { bootstrapDatabase } from "../db/seed.js";
describe("startup RPC contract", () => {
let db: Db;
let rpc: ReturnType<typeof createRpcHandler>;
beforeEach(() => {
db = initDatabase({ inMemory: true });
bootstrapDatabase(db);
rpc = createRpcHandler(db);
});
afterEach(() => {
closeDatabase(db);
});
async function expectContractValid<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]) {
const result = await rpc(method, payload);
expect(result.ok).toBe(true);
if (!result.ok) return result;
expect(() => parseRpcResponse(method, result.data)).not.toThrow();
return result;
}
it("returns contract-valid empty portfolio startup responses", async () => {
const portfolio = await expectContractValid("portfolio.get", undefined);
if (!portfolio.ok) return;
expect(portfolio.data.activeCompanyId).toBe("");
expect(portfolio.data.holdings).toEqual([]);
await expectContractValid("agent.list", {});
});
it("returns contract-valid responses for company-scoped startup RPCs without demo data", async () => {
db.prepare("INSERT INTO companies (id, ticker, name, sector, price, change_pct, thesis) VALUES ('aapl', 'AAPL', 'Apple Inc.', 'Technology', 0, 0, '')").run();
db.prepare("UPDATE portfolios SET active_company_id = 'aapl' WHERE id = 'default'").run();
const companyId = "aapl";
await expectContractValid("company.get", { companyId });
await expectContractValid("agent.list", { companyId });
await expectContractValid("model.get", { companyId, tab: "operating" });
await expectContractValid("model.createRow", { companyId, tab: "operating", label: "Revenue", kind: "forecast", values: ["1"] });
await expectContractValid("model.deleteRow", { companyId, tab: "operating", row: 0 });
await expectContractValid("memo.get", { companyId });
await expectContractValid("workspace.getSection", { companyId, section: "Company Snapshot" });
await expectContractValid("workspace.listSources", { companyId });
await expectContractValid("workspace.updateSection", { companyId, section: "Company Snapshot", content: "Updated notes" });
await expectContractValid("catalyst.list", { companyId });
await expectContractValid("alert.list", { companyId });
await expectContractValid("risk.list", { companyId });
await expectContractValid("earnings.getSchedule", { companyId });
await expectContractValid("filing.list", { companyId });
await expectContractValid("export.list", { companyId });
});
it("returns typed failures for company activation errors", async () => {
db.prepare("INSERT INTO companies (id, ticker, name, sector, price, change_pct, thesis) VALUES ('aapl', 'AAPL', 'Apple Inc.', 'Technology', 0, 0, '')").run();
const missing = await rpc("company.setActive", { companyId: "MSFT" });
expect(missing.ok).toBe(false);
if (!missing.ok) expect(missing.error.code).toBe("NOT_FOUND");
db.prepare("DROP TABLE models").run();
const broken = await rpc("company.setActive", { companyId: "AAPL" });
expect(broken.ok).toBe(false);
if (!broken.ok) {
expect(broken.error.code).toBe("INTERNAL_ERROR");
expect(broken.error.message).toBe("Could not prepare company workspace.");
expect(broken.error.detail).toMatchObject({ operation: "createDefaultModel" });
}
});
});

View File

@@ -1,31 +1,36 @@
import type { WorkspaceSection } from "@mosaiciq/contracts/rpc";
import type { Db } from "../db/database.js";
import { getWorkspaceSection } from "../db/queries.js";
import { ok } from "./result.js";
import { createWorkspaceSection, getWorkspaceSection, listWorkspaceSources, resolveCompany, updateWorkspaceSection } from "../db/queries.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
export function workspaceHandlers(db: Db): RpcHandlers<"workspace.getSection" | "workspace.listSources"> {
function isMissingTableError(error: unknown, tableName: string): boolean {
return error instanceof Error && error.message.includes(`no such table: ${tableName}`);
}
export function workspaceHandlers(db: Db): RpcHandlers<"workspace.getSection" | "workspace.listSources" | "workspace.updateSection"> {
return {
"workspace.getSection": ({ companyId, section }) => {
const content = getWorkspaceSection(db, companyId, section);
if (!content) {
const newSection: WorkspaceSection = {
id: `ws-${Date.now()}`,
title: section,
content: "",
validationState: "unverified",
sourceAgent: undefined,
};
return ok("workspace.getSection", { content: newSection, validationState: "unverified" });
}
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
const content = getWorkspaceSection(db, company.id, section) ?? createWorkspaceSection(db, company.id, section);
return ok("workspace.getSection", { content, validationState: content.validationState });
},
"workspace.listSources": () => {
const sources = [
{ type: "SEC Filing", title: "10-K FY2024", metadata: "Filed Oct 2024" },
{ type: "Earnings Transcript", title: "Q2 FY25 Call", metadata: "Mar 2025" },
];
return ok("workspace.listSources", { sources });
"workspace.listSources": ({ companyId }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
try {
return ok("workspace.listSources", { sources: listWorkspaceSources(db, company.id) as Array<{ type: string; title: string; metadata: string }> });
} catch (error) {
if (isMissingTableError(error, "workspace_sources")) {
return fail("INTERNAL_ERROR", "Workspace sources table is unavailable; database migration is required.");
}
return fail("INTERNAL_ERROR", "Could not load workspace sources.", error instanceof Error ? { message: error.message } : undefined);
}
},
"workspace.updateSection": ({ companyId, section, content }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("workspace.updateSection", { content: updateWorkspaceSection(db, company.id, section, content), savedAt: new Date().toISOString() });
},
};
}

207
pnpm-lock.yaml generated
View File

@@ -18,6 +18,9 @@ importers:
specifier: ^3.24.2
version: 3.25.76
devDependencies:
'@electron/rebuild':
specifier: ^4.0.4
version: 4.0.4
'@tailwindcss/vite':
specifier: ^4.2.4
version: 4.2.4(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(lightningcss@1.32.0))
@@ -221,6 +224,11 @@ packages:
resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
engines: {node: '>=12'}
'@electron/rebuild@4.0.4':
resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==}
engines: {node: '>=22.12.0'}
hasBin: true
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -412,6 +420,10 @@ packages:
'@hapi/topo@6.0.2':
resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -428,6 +440,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@malept/cross-spawn-promise@2.0.0':
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -878,6 +894,10 @@ packages:
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
engines: {node: ^20.17.0 || >=22.9.0}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -1023,6 +1043,10 @@ packages:
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1068,6 +1092,10 @@ packages:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1213,6 +1241,9 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
extract-zip@2.0.1:
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
engines: {node: '>= 10.17.0'}
@@ -1387,6 +1418,13 @@ packages:
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@4.0.0:
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
engines: {node: '>=20'}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -1600,6 +1638,14 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@@ -1622,9 +1668,26 @@ packages:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
node-abi@4.31.0:
resolution: {integrity: sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==}
engines: {node: '>=22.12.0'}
node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
node-gyp@12.3.0:
resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==}
engines: {node: ^20.17.0 || >=22.9.0}
hasBin: true
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nopt@9.0.0:
resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
engines: {node: ^20.17.0 || >=22.9.0}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -1654,6 +1717,10 @@ packages:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1689,6 +1756,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
proc-log@6.1.0:
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
engines: {node: ^20.17.0 || >=22.9.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -1730,6 +1801,10 @@ packages:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
read-binary-file-arch@1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
hasBin: true
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -1826,6 +1901,14 @@ packages:
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
@@ -1899,6 +1982,10 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.15:
resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
engines: {node: '>=18'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1991,6 +2078,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@6.25.0:
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
engines: {node: '>=18.17'}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
@@ -2090,6 +2181,16 @@ packages:
engines: {node: '>=12.0.0'}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
which@6.0.1:
resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==}
engines: {node: ^20.17.0 || >=22.9.0}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -2112,6 +2213,10 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -2282,6 +2387,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron/rebuild@4.0.4':
dependencies:
'@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3
node-abi: 4.31.0
node-api-version: 0.2.1
node-gyp: 12.3.0
read-binary-file-arch: 1.0.6
transitivePeerDependencies:
- supports-color
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -2411,6 +2527,10 @@ snapshots:
dependencies:
'@hapi/hoek': 11.0.7
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2430,6 +2550,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@malept/cross-spawn-promise@2.0.0':
dependencies:
cross-spawn: 7.0.6
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -2778,6 +2902,8 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
abbrev@4.0.0: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@@ -2949,6 +3075,8 @@ snapshots:
chownr@1.1.4: {}
chownr@3.0.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -2998,6 +3126,12 @@ snapshots:
crc-32: 1.2.2
readable-stream: 3.6.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
csstype@3.2.3: {}
dayjs@1.11.20: {}
@@ -3150,6 +3284,8 @@ snapshots:
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
extract-zip@2.0.1:
dependencies:
debug: 4.4.3
@@ -3333,6 +3469,10 @@ snapshots:
isarray@1.0.0: {}
isexe@2.0.0: {}
isexe@4.0.0: {}
jiti@2.7.0: {}
joi@18.2.1:
@@ -3499,6 +3639,12 @@ snapshots:
minimist@1.2.8: {}
minipass@7.1.3: {}
minizlib@3.1.0:
dependencies:
minipass: 7.1.3
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
@@ -3515,8 +3661,33 @@ snapshots:
dependencies:
semver: 7.8.0
node-abi@4.31.0:
dependencies:
semver: 7.8.0
node-api-version@0.2.1:
dependencies:
semver: 7.8.0
node-gyp@12.3.0:
dependencies:
env-paths: 2.2.1
exponential-backoff: 3.1.3
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.0
tar: 7.5.15
tinyglobby: 0.2.16
undici: 6.25.0
which: 6.0.1
node-releases@2.0.38: {}
nopt@9.0.0:
dependencies:
abbrev: 4.0.0
normalize-path@3.0.0: {}
normalize-url@6.1.0: {}
@@ -3536,6 +3707,8 @@ snapshots:
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
@@ -3576,6 +3749,8 @@ snapshots:
prettier@3.8.3: {}
proc-log@6.1.0: {}
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
@@ -3611,6 +3786,12 @@ snapshots:
react@19.2.6: {}
read-binary-file-arch@1.0.6:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -3751,6 +3932,12 @@ snapshots:
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
siginfo@2.0.0: {}
@@ -3829,6 +4016,14 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.5.15:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.3
minizlib: 3.1.0
yallist: 5.0.0
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -3895,6 +4090,8 @@ snapshots:
undici-types@6.21.0: {}
undici@6.25.0: {}
universalify@0.1.2: {}
unzipper@0.10.14:
@@ -4006,6 +4203,14 @@ snapshots:
transitivePeerDependencies:
- debug
which@2.0.2:
dependencies:
isexe: 2.0.0
which@6.0.1:
dependencies:
isexe: 4.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -4025,6 +4230,8 @@ snapshots:
yallist@3.1.1: {}
yallist@5.0.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:

View File

@@ -0,0 +1,31 @@
import { spawnSync } from "node:child_process";
import { rmSync } from "node:fs";
import { dirname } from "node:path";
import { createRequire } from "node:module";
const target = process.argv[2];
if (target !== "node" && target !== "electron") {
console.error("Usage: node scripts/rebuild-better-sqlite3.mjs <node|electron>");
process.exit(1);
}
if (target === "electron") {
const result = spawnSync("pnpm", ["exec", "electron-rebuild", "-f", "-w", "better-sqlite3"], {
stdio: "inherit",
});
process.exit(result.status ?? 1);
}
const requireFromShared = createRequire(new URL("../packages/shared/package.json", import.meta.url));
const packageJsonPath = requireFromShared.resolve("better-sqlite3/package.json");
const packageDir = dirname(packageJsonPath);
rmSync(new URL("build", `file://${packageDir}/`), { recursive: true, force: true });
const result = spawnSync("pnpm", ["run", "install"], {
cwd: packageDir,
stdio: "inherit",
});
process.exit(result.status ?? 1);