- add 3Y/5Y/10Y financial history filtering and reorganize normalization details UI - add new fiscal taxonomy surface/income bridge/KPI packs and update Rust taxonomy loading - auto-detect Homebrew SQLite for native `sqlite-vec` in local dev/e2e with docs and env guidance
259 lines
6.9 KiB
TypeScript
259 lines
6.9 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
import { Database } from "bun:sqlite";
|
|
import { createServer } from "node:net";
|
|
import { dirname, join } from "node:path";
|
|
import {
|
|
ensureFinancialIngestionSchemaHealthy,
|
|
resolveFinancialSchemaRepairMode,
|
|
} from "../lib/server/db/financial-ingestion-schema";
|
|
import { ensureLocalSqliteSchema } from "../lib/server/db/sqlite-schema-compat";
|
|
import { buildLocalDevConfig, resolveSqlitePath } from "./dev-env";
|
|
import { applyLocalSqliteVectorEnv } from "./sqlite-vector-env";
|
|
|
|
type DrizzleJournal = {
|
|
entries: Array<{ tag: string }>;
|
|
};
|
|
|
|
type ExitResult = {
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
};
|
|
|
|
function trim(value: string | undefined) {
|
|
const candidate = value?.trim();
|
|
return candidate ? candidate : undefined;
|
|
}
|
|
|
|
async function isPortAvailable(port: number, host: string) {
|
|
return await new Promise<boolean>((resolve) => {
|
|
const server = createServer();
|
|
|
|
server.once("error", () => resolve(false));
|
|
server.listen(port, host, () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function pickLocalPort(host: string) {
|
|
const candidatePorts = [3000, 3001, 3002, 3100, 3200, 3300];
|
|
|
|
for (const port of candidatePorts) {
|
|
if (await isPortAvailable(port, host)) {
|
|
return String(port);
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Unable to find an open local dev port from: ${candidatePorts.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
function hasTable(database: Database, tableName: string) {
|
|
try {
|
|
const row = database
|
|
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
|
.get(tableName) as { name: string } | null;
|
|
|
|
return row !== null;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readMigrationFiles() {
|
|
const journal = JSON.parse(
|
|
readFileSync(
|
|
join(process.cwd(), "drizzle", "meta", "_journal.json"),
|
|
"utf8",
|
|
),
|
|
) as DrizzleJournal;
|
|
|
|
return journal.entries.map((entry) =>
|
|
join(process.cwd(), "drizzle", `${entry.tag}.sql`),
|
|
);
|
|
}
|
|
|
|
function bootstrapFreshDatabase(databaseUrl: string) {
|
|
const databasePath = resolveSqlitePath(databaseUrl);
|
|
if (!databasePath || databasePath === ":memory:") {
|
|
return false;
|
|
}
|
|
|
|
mkdirSync(dirname(databasePath), { recursive: true });
|
|
|
|
const database = new Database(databasePath, { create: true });
|
|
|
|
try {
|
|
database.exec("PRAGMA foreign_keys = ON;");
|
|
|
|
const existingCoreTables = [
|
|
"user",
|
|
"filing",
|
|
"watchlist_item",
|
|
"filing_statement_snapshot",
|
|
"filing_taxonomy_snapshot",
|
|
"task_run",
|
|
"company_financial_bundle",
|
|
];
|
|
|
|
if (existingCoreTables.some((tableName) => hasTable(database, tableName))) {
|
|
return false;
|
|
}
|
|
|
|
for (const migrationFile of readMigrationFiles()) {
|
|
database.exec(readFileSync(migrationFile, "utf8"));
|
|
}
|
|
|
|
return true;
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
function exitFromResult(result: ExitResult) {
|
|
if (result.signal) {
|
|
process.exit(
|
|
result.signal === "SIGINT" || result.signal === "SIGTERM" ? 0 : 1,
|
|
);
|
|
return;
|
|
}
|
|
|
|
process.exit(result.code ?? 0);
|
|
}
|
|
|
|
const explicitPort = trim(process.env.PORT) || trim(process.env.APP_PORT);
|
|
const bindHost =
|
|
trim(process.env.HOSTNAME) || trim(process.env.HOST) || "127.0.0.1";
|
|
const resolvedPort = explicitPort || (await pickLocalPort(bindHost));
|
|
const config = buildLocalDevConfig({
|
|
...process.env,
|
|
HOSTNAME: bindHost,
|
|
PORT: resolvedPort,
|
|
});
|
|
const initialEnv = {
|
|
...config.env,
|
|
} as NodeJS.ProcessEnv;
|
|
const { config: sqliteVectorConfig, env } =
|
|
applyLocalSqliteVectorEnv(initialEnv);
|
|
|
|
delete env.NO_COLOR;
|
|
|
|
const databasePath = resolveSqlitePath(env.DATABASE_URL ?? "");
|
|
if (databasePath && databasePath !== ":memory:") {
|
|
mkdirSync(dirname(databasePath), { recursive: true });
|
|
}
|
|
|
|
mkdirSync(env.WORKFLOW_LOCAL_DATA_DIR ?? ".workflow-data", { recursive: true });
|
|
|
|
const initializedDatabase = bootstrapFreshDatabase(env.DATABASE_URL ?? "");
|
|
|
|
if (!initializedDatabase && databasePath && databasePath !== ":memory:") {
|
|
const client = new Database(databasePath, { create: true });
|
|
|
|
try {
|
|
client.exec("PRAGMA foreign_keys = ON;");
|
|
ensureLocalSqliteSchema(client);
|
|
const repairResult = ensureFinancialIngestionSchemaHealthy(client, {
|
|
mode: resolveFinancialSchemaRepairMode(env.FINANCIAL_SCHEMA_REPAIR_MODE),
|
|
});
|
|
|
|
if (repairResult.mode === "repaired") {
|
|
console.info(
|
|
`[dev] repaired financial ingestion schema (missing indexes: ${repairResult.repair?.missingIndexesBefore.join(", ") || "none"}; duplicate groups resolved: ${repairResult.repair?.duplicateGroupsResolved ?? 0}; bundle cache cleared: ${repairResult.repair?.bundleCacheCleared ? "yes" : "no"})`,
|
|
);
|
|
} else if (repairResult.mode === "drifted") {
|
|
console.warn(
|
|
`[dev] financial ingestion schema drift detected (missing indexes: ${repairResult.missingIndexes.join(", ") || "none"}; duplicate groups: ${repairResult.duplicateGroups})`,
|
|
);
|
|
} else if (repairResult.mode === "failed") {
|
|
console.warn(
|
|
`[dev] financial ingestion schema repair failed: ${repairResult.error ?? "unknown error"}`,
|
|
);
|
|
}
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
console.info(`[dev] local origin ${config.publicOrigin}`);
|
|
console.info(`[dev] sqlite ${env.DATABASE_URL}`);
|
|
console.info(
|
|
`[dev] workflow ${env.WORKFLOW_TARGET_WORLD} (${env.WORKFLOW_LOCAL_DATA_DIR})`,
|
|
);
|
|
if (sqliteVectorConfig.mode === "native") {
|
|
console.info(
|
|
`[dev] sqlite-vec native extension enabled (${sqliteVectorConfig.sqliteLibPath})`,
|
|
);
|
|
}
|
|
if (!explicitPort && resolvedPort !== "3000") {
|
|
console.info(
|
|
`[dev] port 3000 is busy, using http://localhost:${resolvedPort} instead`,
|
|
);
|
|
}
|
|
if (initializedDatabase) {
|
|
console.info(
|
|
"[dev] initialized the local SQLite schema from drizzle SQL files",
|
|
);
|
|
}
|
|
|
|
if (config.overrides.authOriginChanged) {
|
|
console.info(
|
|
"[dev] forcing Better Auth origin/trusted origins to the local origin",
|
|
);
|
|
}
|
|
|
|
if (config.overrides.apiBaseChanged) {
|
|
console.info(
|
|
"[dev] forcing NEXT_PUBLIC_API_URL to same-origin for local dev",
|
|
);
|
|
}
|
|
|
|
if (config.overrides.databaseChanged) {
|
|
console.info(
|
|
"[dev] using a local SQLite database instead of the deployment path",
|
|
);
|
|
}
|
|
|
|
if (config.overrides.workflowChanged) {
|
|
console.info("[dev] forcing Workflow to the local runtime for local dev");
|
|
}
|
|
|
|
if (config.overrides.secretFallbackUsed) {
|
|
console.info(
|
|
"[dev] using the built-in local Better Auth secret because BETTER_AUTH_SECRET is unset or still a placeholder",
|
|
);
|
|
}
|
|
|
|
const child = spawn(
|
|
"bun",
|
|
[
|
|
"--bun",
|
|
"next",
|
|
"dev",
|
|
"--turbopack",
|
|
"--hostname",
|
|
config.bindHost,
|
|
"--port",
|
|
config.port,
|
|
],
|
|
{
|
|
env,
|
|
stdio: "inherit",
|
|
},
|
|
);
|
|
|
|
function forwardSignal(signal: NodeJS.Signals) {
|
|
if (!child.killed) {
|
|
child.kill(signal);
|
|
}
|
|
}
|
|
|
|
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
|
|
child.on("exit", (code, signal) => {
|
|
exitFromResult({ code, signal });
|
|
});
|