diff --git a/packages/core/src/__tests__/agent-store.test.ts b/packages/core/src/__tests__/agent-store.test.ts index 841d1c52b8..a4b08795d6 100644 --- a/packages/core/src/__tests__/agent-store.test.ts +++ b/packages/core/src/__tests__/agent-store.test.ts @@ -10,8 +10,9 @@ * agent:heartbeat, agent:stateChanged), error paths, state transition * validation, concurrency locking, and SQLite persistence. */ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest"; import { AgentStore } from "../agent-store.js"; +import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js"; import { TaskStore } from "../store.js"; import { validateSnapshotEnvelope } from "../shared-mesh-state.js"; import { rm } from "node:fs/promises"; @@ -31,6 +32,11 @@ function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "fn-agent-store-test-")); } +// FNXC:CoreTests 2026-06-25-16:30: amortize the ~129-migration db.init() cost +// across this file's in-memory stores via one migrated-schema snapshot. +beforeAll(() => installInMemoryDbSnapshot()); +afterAll(() => clearInMemoryDbSnapshot()); + describe("AgentStore", () => { let rootDir: string; let store: AgentStore; diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index bbcf67d8a4..c74e57e1ac 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js"; +import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js"; import { GoalStore } from "../goal-store.js"; import { Database, SCHEMA_VERSION } from "../db.js"; import type { MissionFeature } from "../mission-types.js"; @@ -13,6 +14,11 @@ function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "kb-mission-test-")); } +// FNXC:CoreTests 2026-06-25-16:30: amortize the ~129-migration db.init() cost +// across this file's in-memory databases via one migrated-schema snapshot. +beforeAll(() => installInMemoryDbSnapshot()); +afterAll(() => clearInMemoryDbSnapshot()); + function linearIr(name: string): WorkflowIr { return { version: "v1", diff --git a/packages/core/src/__tests__/store-test-helpers.ts b/packages/core/src/__tests__/store-test-helpers.ts index d9855dc7bd..98256dd457 100644 --- a/packages/core/src/__tests__/store-test-helpers.ts +++ b/packages/core/src/__tests__/store-test-helpers.ts @@ -24,7 +24,7 @@ vi.mock("../run-command.js", async (importOriginal) => { import { execSync } from "node:child_process"; import { runCommandAsync } from "../run-command.js"; -import { Database } from "../db.js"; +import { Database, setInMemoryTemplateSnapshot } from "../db.js"; import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; import { TaskStore, TaskHasDependentsError } from "../store.js"; import type { Task } from "../types.js"; @@ -74,6 +74,49 @@ export function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "kb-store-test-")); } +/* + * FNXC:CoreTests 2026-06-25-16:30: + * Migrated in-memory DB snapshot for DB-backed test suites. + * + * db.init() replays SCHEMA_SQL + ~129 migrations on every fresh in-memory DB + * (~30-90ms each). Suites that build a new store per test (AgentStore, + * MissionStore, TaskStore, dashboard route stores, …) pay that cost hundreds of + * times. This harness migrates ONE in-memory DB per test file, serializes it, + * and registers the bytes via setInMemoryTemplateSnapshot so every later + * in-memory Database is restored from the snapshot instead of re-migrating. + * + * Isolation is unchanged: each test still constructs its own brand-new + * in-memory DB; only the migration work is amortized. Disk-backed stores + * (cross-instance persistence tests) are never touched by the snapshot. + * + * Usage: + * beforeAll(() => installInMemoryDbSnapshot()); + * afterAll(() => clearInMemoryDbSnapshot()); + * Leave existing per-test `new ({ inMemoryDb: true }); init()` as-is. + */ +let cachedMigratedSnapshot: Uint8Array | null = null; + +export function installInMemoryDbSnapshot(): void { + if (process.env.FN_NO_SNAPSHOT === "1") return; // A/B benchmark escape hatch + if (!cachedMigratedSnapshot) { + // Build the template with the hook OFF so this DB runs real migrations once. + setInMemoryTemplateSnapshot(null); + const templateDir = makeTmpDir(); + const template = new Database(templateDir, { inMemory: true }); + try { + template.init(); + cachedMigratedSnapshot = template.serializeSnapshot(); + } finally { + template.close(); + } + } + setInMemoryTemplateSnapshot(cachedMigratedSnapshot); +} + +export function clearInMemoryDbSnapshot(): void { + setInMemoryTemplateSnapshot(null); +} + async function clearDirectoryContents(dir: string): Promise { try { const entries = await readdir(dir); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index f84d0c1e59..025f77e1bb 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -1830,6 +1830,26 @@ export function integrityCheckSqliteFileAsync( // ── Database Class ─────────────────────────────────────────────────── +/** + * FNXC:CoreTests 2026-06-25-16:30: + * Test-only migrated-schema snapshot. db.init() replays the full schema plus + * ~129 migrations on every fresh in-memory DB (~30-90ms each), which is minutes + * of pure setup across thousands of DB-backed tests. The snapshot harness + * (store-test-helpers.ts → installInMemoryDbSnapshot) builds ONE migrated + * in-memory DB per test file, serializes it, and registers the bytes here. + * Any subsequent in-memory Database deserializes the snapshot at open time, so + * init() finds schemaVersion === SCHEMA_VERSION and the matching compat + * fingerprint and short-circuits all migration/backfill work. Each test still + * gets a brand-new, fully-isolated in-memory DB — only the migration cost is + * amortized. Never consulted for disk-backed (production) databases. + */ +let inMemoryTemplateSnapshot: Uint8Array | null = null; + +/** Register/clear the in-memory migrated-DB snapshot. Test harness only. */ +export function setInMemoryTemplateSnapshot(snapshot: Uint8Array | null): void { + inMemoryTemplateSnapshot = snapshot; +} + type SharedIntegrityCheckState = { timer: ReturnType | null; subscribers: Set; @@ -1928,6 +1948,13 @@ export class Database { } else { // Wait up to the configured timeout for locks to clear before returning SQLITE_BUSY. this.db.exec(`PRAGMA busy_timeout = ${this.busyTimeoutMs}`); + // FNXC:CoreTests 2026-06-25-16:30: + // Restore the migrated-schema snapshot in place of replaying migrations. + // deserialize() swaps page content only; the connection-level pragmas set + // above/below (busy_timeout, foreign_keys) persist across the swap. + if (inMemoryTemplateSnapshot) { + this.db.deserialize(inMemoryTemplateSnapshot); + } } // Enable foreign key enforcement this.db.exec("PRAGMA foreign_keys = ON"); @@ -1935,6 +1962,15 @@ export class Database { this._fts5Available = probeFts5(this.db); } + /** + * FNXC:CoreTests 2026-06-25-16:30: + * Serialize the entire database to a byte buffer for the test snapshot + * harness (see setInMemoryTemplateSnapshot). Test-only. + */ + serializeSnapshot(): Uint8Array { + return this.db.serialize(); + } + /** * True when the underlying SQLite build has FTS5 (`CREATE VIRTUAL TABLE … USING fts5`). * Node's bundled SQLite only exposes FTS5 when built with `SQLITE_ENABLE_FTS5`; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 398067b035..4df0f26a9e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -697,6 +697,9 @@ export { toJsonNullable, fromJson, SCHEMA_VERSION, + // FNXC:CoreTests 2026-06-25-16:30: test-only migrated-DB snapshot hook so + // cross-package suites (dashboard route tests) can amortize db.init() cost. + setInMemoryTemplateSnapshot, } from "./db.js"; export { ProjectIdentityConflictError, diff --git a/packages/core/src/sqlite-adapter.ts b/packages/core/src/sqlite-adapter.ts index 733afb9999..e99b697ee5 100644 --- a/packages/core/src/sqlite-adapter.ts +++ b/packages/core/src/sqlite-adapter.ts @@ -44,6 +44,11 @@ interface RawDatabase { exec(sql: string): void; prepare(sql: string): RawStatement; close(): void; + // Optional snapshot API (node:sqlite ≥ 22.x, bun:sqlite). Both runtimes + // expose `serialize()` → Uint8Array and `deserialize(buf)` that replaces the + // open database's contents in place. Used only by the test snapshot harness. + serialize?: () => Uint8Array; + deserialize?: (data: Uint8Array) => void; } type DatabaseCtor = new (path: string) => RawDatabase; @@ -84,6 +89,31 @@ export class DatabaseSync { this.impl.close(); } + /** + * FNXC:CoreTests 2026-06-25-16:30: + * Snapshot the entire database into a byte buffer. Backs the test-only + * migrated-DB snapshot harness so the 129-migration init() runs once per + * test file instead of once per test. Throws if the runtime lacks the API. + */ + serialize(): Uint8Array { + if (typeof this.impl.serialize !== "function") { + throw new Error("SQLite runtime does not support serialize()"); + } + return this.impl.serialize(); + } + + /** + * FNXC:CoreTests 2026-06-25-16:30: + * Replace this (in-memory) database's contents with a previously serialized + * snapshot. Restores a fully-migrated schema without replaying migrations. + */ + deserialize(data: Uint8Array): void { + if (typeof this.impl.deserialize !== "function") { + throw new Error("SQLite runtime does not support deserialize()"); + } + this.impl.deserialize(data); + } + prepare(sql: string): SqliteStatement { const stmt = this.impl.prepare(sql); // Both node:sqlite and bun:sqlite expose the same .all/.get/.run shape. diff --git a/packages/dashboard/src/__tests__/db-snapshot-helper.ts b/packages/dashboard/src/__tests__/db-snapshot-helper.ts new file mode 100644 index 0000000000..8a1d12df3d --- /dev/null +++ b/packages/dashboard/src/__tests__/db-snapshot-helper.ts @@ -0,0 +1,45 @@ +/* + * FNXC:DashboardTests 2026-06-25-16:30: + * Migrated in-memory DB snapshot for dashboard route suites. + * + * Route tests build a fresh in-memory TaskStore/AgentStore per test, and each + * store.init() replays SCHEMA_SQL + ~129 migrations (~30-90ms). This helper + * migrates ONE in-memory DB per test file, serializes it, and registers the + * bytes via @fusion/core's setInMemoryTemplateSnapshot so every later in-memory + * Database is restored from the snapshot instead of re-migrating. Test + * isolation is unchanged — each test still gets its own fresh in-memory DB. + * + * Mirrors packages/core/src/__tests__/store-test-helpers.ts; kept separate + * because that file lives in @fusion/core's private __tests__ dir. + * + * Usage: + * beforeAll(() => installInMemoryDbSnapshot()); + * afterAll(() => clearInMemoryDbSnapshot()); + */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database, setInMemoryTemplateSnapshot } from "@fusion/core"; + +let cachedMigratedSnapshot: Uint8Array | null = null; + +export function installInMemoryDbSnapshot(): void { + if (process.env.FN_NO_SNAPSHOT === "1") return; // A/B benchmark escape hatch + if (!cachedMigratedSnapshot) { + // Build the template with the hook OFF so it runs real migrations once. + setInMemoryTemplateSnapshot(null); + const templateDir = mkdtempSync(join(tmpdir(), "fn-dash-db-snapshot-")); + const template = new Database(templateDir, { inMemory: true }); + try { + template.init(); + cachedMigratedSnapshot = template.serializeSnapshot(); + } finally { + template.close(); + } + } + setInMemoryTemplateSnapshot(cachedMigratedSnapshot); +} + +export function clearInMemoryDbSnapshot(): void { + setInMemoryTemplateSnapshot(null); +} diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index 637410b4b7..94578b705d 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest"; import express from "express"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -11,11 +11,17 @@ import { registerWorkflowRoutes } from "../routes/register-workflow-routes.js"; import { ApiError, sendErrorResponse } from "../api-error.js"; import { request } from "../test-request.js"; import { emitWorkflowSseEvent } from "../sse.js"; +import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./db-snapshot-helper.js"; vi.mock("../sse.js", () => ({ emitWorkflowSseEvent: vi.fn(), })); +// FNXC:DashboardTests 2026-06-25-16:30: amortize the ~129-migration store.init() +// cost across this file's in-memory TaskStore/AgentStore via one snapshot. +beforeAll(() => installInMemoryDbSnapshot()); +afterAll(() => clearInMemoryDbSnapshot()); + function linearIr(): WorkflowIr { return { version: "v1",