diff --git a/infrastructure/scripts/dump-notebook-fixtures b/infrastructure/scripts/dump-notebook-fixtures new file mode 100755 index 000000000..9d015b6cb --- /dev/null +++ b/infrastructure/scripts/dump-notebook-fixtures @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Dumps all analysis documents and their referenced models and diagrams from a database as a JSON object. +# +# Usage: dump-notebook-fixtures +# Example: dump-notebook-fixtures catcolab@backend-next.catcolab.org fixtures.json +# +# Output format: +# { +# "analyses": [...], // Array of analysis documents +# "models": [...], // Array of model documents referenced by analyses (directly or via diagrams) +# "diagrams": [...] // Array of diagram documents referenced by diagram analyses +# } + +set -euo pipefail + +ssh_target="${1:?Usage: dump-notebook-fixtures }" +output_file="${2:?Usage: dump-notebook-fixtures }" + +echo "Dumping analysis documents, models, and diagrams from $ssh_target to $output_file..." >&2 + +# Run the query remotely via SSH. The remote script: +# 1. Extracts DATABASE_URL from the agenix secrets file +# 2. Queries PostgreSQL to get all non-deleted analysis documents +# 3. Queries PostgreSQL to get all model documents referenced by model analyses +# 4. Queries PostgreSQL to get all diagram documents referenced by diagram analyses +# 5. Queries PostgreSQL to get all model documents referenced by those diagrams +# 6. Returns a JSON object with all three arrays +ssh "$ssh_target" bash -s > "$output_file" <<'REMOTE_SCRIPT' +set -euo pipefail + +# Read DATABASE_URL from the agenix-decrypted secrets file +if [[ ! -f /run/agenix/catcolabSecrets ]]; then + echo "Error: /run/agenix/catcolabSecrets not found on remote host" >&2 + exit 1 +fi + +db_url=$(grep '^DATABASE_URL=' /run/agenix/catcolabSecrets | cut -d= -f2-) + +if [[ -z "$db_url" ]]; then + echo "Error: DATABASE_URL not found in /run/agenix/catcolabSecrets" >&2 + exit 1 +fi + +# Query for all analysis documents and their referenced models and diagrams +psql "$db_url" -t -A -c " + WITH analysis_docs AS ( + SELECT jsonb_set(s.content, '{_refId}', to_jsonb(r.id::text)) AS content + FROM snapshots s + JOIN refs r ON r.current_snapshot = s.id + WHERE r.deleted_at IS NULL + AND s.content->>'type' = 'analysis' + ), + -- Model documents referenced directly by model analyses + model_analysis_refs AS ( + SELECT DISTINCT content->'analysisOf'->>'_id' AS model_ref_id + FROM analysis_docs + WHERE content->>'analysisType' = 'model' + AND content->'analysisOf'->>'_id' IS NOT NULL + ), + -- Diagram documents referenced by diagram analyses + diagram_refs AS ( + SELECT DISTINCT content->'analysisOf'->>'_id' AS diagram_ref_id + FROM analysis_docs + WHERE content->>'analysisType' = 'diagram' + AND content->'analysisOf'->>'_id' IS NOT NULL + ), + diagram_docs AS ( + SELECT jsonb_set(s.content, '{_refId}', to_jsonb(r.id::text)) AS content + FROM diagram_refs dr + JOIN refs r ON r.id = dr.diagram_ref_id::uuid + JOIN snapshots s ON s.id = r.current_snapshot + WHERE r.deleted_at IS NULL + AND s.content->>'type' = 'diagram' + ), + -- Model documents referenced by diagrams (via diagramIn._id) + diagram_model_refs AS ( + SELECT DISTINCT content->'diagramIn'->>'_id' AS model_ref_id + FROM diagram_docs + WHERE content->'diagramIn'->>'_id' IS NOT NULL + ), + -- All model documents: those referenced by model analyses + those referenced by diagrams + all_model_refs AS ( + SELECT model_ref_id FROM model_analysis_refs + UNION + SELECT model_ref_id FROM diagram_model_refs + ), + model_docs AS ( + SELECT jsonb_set(s.content, '{_refId}', to_jsonb(r.id::text)) AS content + FROM all_model_refs mr + JOIN refs r ON r.id = mr.model_ref_id::uuid + JOIN snapshots s ON s.id = r.current_snapshot + WHERE r.deleted_at IS NULL + AND s.content->>'type' = 'model' + ) + SELECT json_build_object( + 'analyses', COALESCE((SELECT json_agg(content) FROM analysis_docs), '[]'::json), + 'models', COALESCE((SELECT json_agg(content) FROM model_docs), '[]'::json), + 'diagrams', COALESCE((SELECT json_agg(content) FROM diagram_docs), '[]'::json) + ); +" +REMOTE_SCRIPT + +analysis_count=$(jq '.analyses | length' "$output_file") +model_count=$(jq '.models | length' "$output_file") +diagram_count=$(jq '.diagrams | length' "$output_file") +echo "Dumped $analysis_count analysis documents, $model_count model documents, and $diagram_count diagram documents to $output_file" >&2 diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 94d962a6b..bf49ea752 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,6 +20,7 @@ "doc": "typedoc --entryPointStrategy expand ./src", "test": "vitest --mode=development", "test:ci": "vitest --mode=development --cache=false --configLoader=runner --run", + "test:db-dump": "vitest --mode=development --config vitest.db-dump.config.ts --run", "dev": "pnpm run build:deps && vite --host", "staging": "pnpm run build:deps && vite --mode staging" }, diff --git a/packages/frontend/src/analysis/document.ts b/packages/frontend/src/analysis/document.ts index 7fc5f1391..803c02372 100644 --- a/packages/frontend/src/analysis/document.ts +++ b/packages/frontend/src/analysis/document.ts @@ -13,6 +13,7 @@ import { type Api, type DocRef, findAndMigrate, type LiveDoc, makeLiveDoc } from import { getLiveDiagram, getLiveDiagramFromRepo, type LiveDiagramDoc } from "../diagram"; import type { LiveModelDoc, ModelLibrary } from "../model"; import { assertExhaustive } from "../util/assert_exhaustive"; +import { migrateAnalysis } from "./migrate"; /** A document defining an analysis. */ export type AnalysisDocument = Document & { type: "analysis" }; @@ -114,7 +115,12 @@ export async function getLiveAnalysis( throw new Error(`Unknown analysis type: ${doc.analysisType}`); } - migrateAnalysis(liveAnalysis); + const theory = theoryForLiveAnalysis(liveAnalysis); + if (theory) { + liveAnalysis.liveDoc.changeDoc((doc) => { + migrateAnalysis(doc.notebook, theory, liveAnalysis.analysisType); + }); + } return { liveAnalysis, docRef }; } @@ -153,19 +159,7 @@ export async function getLiveAnalysisFromRepo( throw new Error(`Unknown analysis type: ${doc.analysisType}`); } - migrateAnalysis(liveAnalysis); - return liveAnalysis; -} - -/** Migrate content of formal cells in analysis document. - -This is a stop-gap (read: hacky) method to migrate the content of analyses when -the set of fields changes. It allow new fields to be added. Renaming or removing -existing fields is *not* supported. - */ -function migrateAnalysis(liveAnalysis: LiveAnalysisDoc) { const theory = theoryForLiveAnalysis(liveAnalysis); - const getAnalysisMeta = (analysisId: string) => { switch (liveAnalysis.analysisType) { case "model": @@ -175,7 +169,6 @@ function migrateAnalysis(liveAnalysis: LiveAnalysisDoc) { } }; - const doc = liveAnalysis.liveDoc.doc; for (const cell of Nb.getFormalCells(doc.notebook)) { const meta = getAnalysisMeta(cell.content.id); if (!meta) { @@ -192,6 +185,12 @@ function migrateAnalysis(liveAnalysis: LiveAnalysisDoc) { } } } + if (theory) { + liveAnalysis.liveDoc.changeDoc((doc) => { + migrateAnalysis(doc.notebook, theory, liveAnalysis.analysisType); + }); + } + return liveAnalysis; } /** Gets the theory associated with a live analysis. */ diff --git a/packages/frontend/src/analysis/migrate.ts b/packages/frontend/src/analysis/migrate.ts new file mode 100644 index 000000000..46e2f09b3 --- /dev/null +++ b/packages/frontend/src/analysis/migrate.ts @@ -0,0 +1,40 @@ +import { Nb } from "catcolab-document-methods"; +import type { Analysis, AnalysisType, Notebook } from "catlog-wasm"; +import type { AnalysisMeta, Theory } from "../theory"; + +/** Migrate content of formal cells in an analysis notebook. + * + * This is a stop-gap (read: hacky) method to migrate the content of analyses when + * the set of fields changes. It allows new fields to be added. Renaming or removing + * existing fields is *not* supported. + * + * Fills in missing fields from analysis defaults. Mutates the notebook in place. + */ +export function migrateAnalysis( + notebook: Notebook, + theory: Theory, + analysisType: AnalysisType, +): void { + for (const cell of Nb.getFormalCells(notebook)) { + const analysis = cell.content; + let meta: AnalysisMeta | undefined; + switch (analysisType) { + case "model": + meta = theory.modelAnalysis(analysis.id); + break; + case "diagram": + meta = theory.diagramAnalysis(analysis.id); + break; + } + if (!meta) { + continue; + } + const initialContent = meta.initialContent() as Record; + const cellContent = analysis.content; + for (const key in initialContent) { + if (!(key in cellContent)) { + cellContent[key] = initialContent[key]; + } + } + } +} diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 2bb74794c..dccc41254 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -26,6 +26,10 @@ export const decapodes = ( options: AnalysisOptions, ): DiagramAnalysisMeta => ({ ...options, + run: (diagram, model) => ({ + diagram: diagram.presentation(), + model: model.presentation(), + }), component: (props) => , initialContent: () => ({ domain: null, @@ -43,6 +47,7 @@ export const diagramGraph = ( options: AnalysisOptions, ): DiagramAnalysisMeta => ({ ...options, + run: (diagram) => diagram.presentation(), component: (props) => , initialContent: GraphLayoutConfig.defaultConfig, }); @@ -53,6 +58,10 @@ export const tabularView = ( options: AnalysisOptions, ): DiagramAnalysisMeta> => ({ ...options, + run: (diagram, model) => ({ + diagram: diagram.presentation(), + model: model.presentation(), + }), component: (props) => , initialContent: () => ({}), }); @@ -81,6 +90,7 @@ export function kuramoto( name, description, help, + run: simulate, component: (props) => ( , initialContent: () => ({ coefficients: {}, @@ -150,6 +161,7 @@ export function lotkaVolterra( name, description, help, + run: simulate, component: (props) => , initialContent: () => ({ interactionCoefficients: {}, @@ -175,6 +187,7 @@ export function massAction( name = "Mass-action dynamics", description = "Simulate the system using the law of mass action", help = "mass-action", + simulate, ...otherOptions } = options; return { @@ -182,7 +195,10 @@ export function massAction( name, description, help, - component: (props) => , + run: simulate, + component: (props) => ( + + ), initialContent: () => ({ massConservationType: { type: "Balanced" }, rates: {}, @@ -238,6 +254,7 @@ export function stochasticMassAction( name = "Stochastic mass-action dynamics", description = "Simulate the system using stochastic mass-action dynamics", help = "stochastic-mass-action", + simulate, ...otherOptions } = options; return { @@ -245,7 +262,10 @@ export function stochasticMassAction( name, description, help, - component: (props) => , + run: simulate, + component: (props) => ( + + ), initialContent: () => ({ rates: {}, initialValues: {}, @@ -322,6 +342,7 @@ export function reachability( name, description, help, + run: otherOptions.check, component: (props) => , initialContent: () => ({ tokens: {}, forbidden: {} }), }; diff --git a/packages/frontend/src/stdlib/notebook_backwards_compat.db-dump-test.ts b/packages/frontend/src/stdlib/notebook_backwards_compat.db-dump-test.ts new file mode 100644 index 000000000..e8e9de10e --- /dev/null +++ b/packages/frontend/src/stdlib/notebook_backwards_compat.db-dump-test.ts @@ -0,0 +1,343 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +import { Nb } from "catcolab-document-methods"; +import { + type Analysis, + DblModelMap, + type DiagramJudgment, + type DiagramNotebook, + elaborateDiagram, + elaborateModel, + type ModelNotebook, + migrateDocument, + type Notebook, +} from "catlog-wasm"; +import { migrateAnalysis } from "../analysis/migrate"; +import { stdTheories } from "./theories"; + +/** Path to a JSON file containing analysis documents and their referenced + * models and diagrams. Set via the NOTEBOOK_FIXTURES_PATH environment variable. + * This path is required — the test will fail if not set. + * You can create the JSON using infrastructure/scripts/dump-notebook-fixtures. + */ +const fixturesPath = process.env.NOTEBOOK_FIXTURES_PATH; + +/** Path to a CSV file containing UUIDs of analysis documents to skip. + * The first line is a header and is ignored. Each subsequent line starts + * with the UUID. The rest of the columns are ignored. Set via the + * NOTEBOOK_SKIPLIST_PATH environment variable. This path is required — the + * test will fail if not set. + */ +const skiplistPath = process.env.NOTEBOOK_SKIPLIST_PATH; + +const skippedIds: Set = (() => { + if (!skiplistPath) { + return new Set(); + } + const content = readFileSync(skiplistPath, "utf-8"); + const set = new Set(); + const lines = content.split("\n"); + for (let i = 1; i < lines.length; i++) { + const line = lines[i]?.trim(); + if (!line || line.length === 0) { + continue; + } + const commaIdx = line.indexOf(","); + set.add(commaIdx === -1 ? line : line.slice(0, commaIdx).trim()); + } + return set; +})(); + +describe("Database dump backward compatibility", () => { + // Fail immediately if required environment variables are not provided + test("NOTEBOOK_FIXTURES_PATH must be set", () => { + if (!fixturesPath) { + // oxlint-disable-next-line no-conditional-expect + expect.fail( + "NOTEBOOK_FIXTURES_PATH environment variable is not set. " + + "This test requires a JSON file with analysis documents, models, and diagrams.", + ); + } + expect(fixturesPath).toBeTruthy(); + }); + + test("NOTEBOOK_SKIPLIST_PATH must be set", () => { + if (!skiplistPath) { + // oxlint-disable-next-line no-conditional-expect + expect.fail( + "NOTEBOOK_SKIPLIST_PATH environment variable is not set. " + + "This test requires a CSV file with UUIDs of analysis documents to skip.", + ); + } + expect(skiplistPath).toBeTruthy(); + }); + + // Load fixtures from the file path + const allData: { analyses?: unknown[]; models?: unknown[]; diagrams?: unknown[] } = fixturesPath + ? JSON.parse(readFileSync(fixturesPath, "utf-8")) + : {}; + + const allAnalyses = allData.analyses ?? []; + const allModels = allData.models ?? []; + const allDiagrams = allData.diagrams ?? []; + + // Filter out corrupted analysis documents that are missing the analysisType field. + // These are genuinely broken documents that cannot be migrated without + // manually determining whether they're model or diagram analyses. + const analyses = allAnalyses.filter((doc) => { + const d = doc as Record; + return d.analysisType !== undefined; + }); + + // Separate model analyses from diagram analyses. + const modelAnalyses = analyses.filter( + (doc) => (doc as Record).analysisType === "model", + ); + const diagramAnalyses = analyses.filter( + (doc) => (doc as Record).analysisType === "diagram", + ); + + // Build a map of model ID -> migrated model document + const modelById = new Map>(); + for (const modelDoc of allModels) { + const doc = modelDoc as Record; + const refId = doc._refId as string | undefined; + if (!refId) { + continue; + } + + const migrated = migrateDocument(doc) as Record; + modelById.set(refId, migrated); + } + + // Build a map of diagram ID -> migrated diagram document + const diagramById = new Map>(); + for (const diagramDoc of allDiagrams) { + const doc = diagramDoc as Record; + const refId = doc._refId as string | undefined; + if (!refId) { + continue; + } + + const migrated = migrateDocument(doc) as Record; + diagramById.set(refId, migrated); + } + + test("fixtures should be loaded", () => { + expect(analyses.length).toBeGreaterThan(0); + }); + + // Run one test per model analysis document: migrate, compile model, and run analysis functions. + const indexedModelAnalyses = modelAnalyses.map((doc, i) => ({ + doc: doc as Record, + i, + })); + const skiplistedModelAnalyses = indexedModelAnalyses.filter(({ doc }) => { + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + return skippedIds.has(docRefId); + }); + const runnableModelAnalyses = indexedModelAnalyses.filter(({ doc }) => { + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + return !skippedIds.has(docRefId); + }); + + for (const { doc, i } of skiplistedModelAnalyses) { + const docName = (doc.name as string | undefined) ?? "unnamed"; + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + // oxlint-disable-next-line no-disabled-tests + test.skip(`model analysis ${i}: "${docName}" [${docRefId}] (skiplisted)`, () => {}); + } + + for (const { doc, i } of runnableModelAnalyses) { + const docName = (doc.name as string | undefined) ?? "unnamed"; + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + test(`model analysis ${i}: "${docName}" [${docRefId}]`, async () => { + // Step 1: Migrate the analysis document + const migratedAnalysis = migrateDocument(doc) as Record; + + // Step 2: Get the referenced model + const analysisOf = migratedAnalysis.analysisOf as { _id?: string } | undefined; + const modelRefId = analysisOf?._id; + if (!modelRefId) { + // oxlint-disable-next-line no-conditional-expect + expect.fail(`analysis: ${docRefId} | Analysis is missing analysisOf._id`); + } + + const migratedModel = modelById.get(modelRefId); + if (!migratedModel) { + // Model may have been deleted while the analysis still references it. + return; + } + + // Step 3: Get the theory and compile the model + const theoryId = migratedModel.theory as string; + const theory = await stdTheories.get(theoryId); + + const instantiated = new DblModelMap(); + const compiledModel = elaborateModel( + migratedModel.notebook as ModelNotebook, + instantiated, + theory.theory, + modelRefId, + ); + + // Step 3.5: Migrate analysis content — fill in missing fields from defaults. + // This uses the same function as the real app (migrateAnalysis). + migrateAnalysis(migratedAnalysis.notebook as Notebook, theory, "model"); + + // Step 4: Run each analysis cell through the real WASM functions + runAnalysisCells( + migratedAnalysis, + (id) => theory.modelAnalysis(id), + (spec, content) => spec.run?.(compiledModel, content), + theoryId, + ); + }); + } + + // Run one test per diagram analysis document: migrate, compile diagram + model, and run + // analysis functions. + const indexedDiagramAnalyses = diagramAnalyses.map((doc, i) => ({ + doc: doc as Record, + i, + })); + const skiplistedDiagramAnalyses = indexedDiagramAnalyses.filter(({ doc }) => { + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + return skippedIds.has(docRefId); + }); + const runnableDiagramAnalyses = indexedDiagramAnalyses.filter(({ doc }) => { + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + return !skippedIds.has(docRefId); + }); + + for (const { doc, i } of skiplistedDiagramAnalyses) { + const docName = (doc.name as string | undefined) ?? "unnamed"; + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + // oxlint-disable-next-line no-disabled-tests + test.skip(`diagram analysis ${i}: "${docName}" [${docRefId}] (skiplisted)`, () => {}); + } + + for (const { doc, i } of runnableDiagramAnalyses) { + const docName = (doc.name as string | undefined) ?? "unnamed"; + const docRefId = (doc._refId as string | undefined) ?? "no-ref-id"; + test(`diagram analysis ${i}: "${docName}" [${docRefId}]`, async () => { + // Step 1: Migrate the analysis document + const migratedAnalysis = migrateDocument(doc) as Record; + + // Step 2: Get the referenced diagram + const analysisOf = migratedAnalysis.analysisOf as { _id?: string } | undefined; + const diagramRefId = analysisOf?._id; + if (!diagramRefId) { + // oxlint-disable-next-line no-conditional-expect + expect.fail(`analysis: ${docRefId} | Analysis is missing analysisOf._id`); + } + + const migratedDiagram = diagramById.get(diagramRefId); + if (!migratedDiagram) { + // Diagram may have been deleted while the analysis still references it. + return; + } + + // Step 3: Get the parent model referenced by the diagram + const diagramIn = migratedDiagram.diagramIn as { _id?: string } | undefined; + const modelRefId = diagramIn?._id; + if (!modelRefId) { + // oxlint-disable-next-line no-conditional-expect + expect.fail( + `analysis: ${docRefId} | Diagram ${diagramRefId} is missing diagramIn._id`, + ); + } + + const migratedModel = modelById.get(modelRefId); + if (!migratedModel) { + // Parent model may have been deleted. + return; + } + + // Step 4: Get the theory, compile the model, and elaborate the diagram + const theoryId = migratedModel.theory as string; + const theory = await stdTheories.get(theoryId); + + const instantiated = new DblModelMap(); + const compiledModel = elaborateModel( + migratedModel.notebook as ModelNotebook, + instantiated, + theory.theory, + modelRefId, + ); + + // Validate the model before elaborating the diagram + const modelValidation = compiledModel.validate(); + if (modelValidation.tag !== "Ok") { + // Model is invalid — diagram cannot be validated against it. + // This is not a backward compatibility failure in the analysis. + return; + } + + const diagramJudgments: DiagramJudgment[] = Nb.getFormalContent( + migratedDiagram.notebook as DiagramNotebook, + ); + + const compiledDiagram = elaborateDiagram(diagramJudgments, theory.theory); + + compiledDiagram.inferMissingFrom(compiledModel); + + // Step 4.5: Migrate analysis content — fill in missing fields from defaults. + migrateAnalysis(migratedAnalysis.notebook as Notebook, theory, "diagram"); + + // Step 5: Run each analysis cell through the real WASM functions + runAnalysisCells( + migratedAnalysis, + (id) => theory.diagramAnalysis(id), + (spec, content) => spec.run?.(compiledDiagram, compiledModel, content), + theoryId, + ); + }); + } +}); + +/** Run all formal analysis cells in a migrated analysis document. + * + * Throws immediately on any failure. + */ +function runAnalysisCells unknown }>( + migratedAnalysis: Record, + getAnalysisSpec: (id: string) => S | undefined, + runSpec: (spec: S, content: unknown) => unknown, + theoryId: string, +): void { + const notebook = migratedAnalysis.notebook as { + cellContents: Record< + string, + { + tag: string; + content?: { id: string; content: unknown }; + } + >; + }; + + for (const [cellId, cell] of Object.entries(notebook.cellContents)) { + if (cell.tag !== "formal" || !cell.content) { + continue; + } + const analysisCell = cell.content; + const analysisId = analysisCell.id; + + // Look up the analysis spec from the theory + const analysisSpec = getAnalysisSpec(analysisId); + if (!analysisSpec) { + throw new Error( + `cell ${cellId} | analysis: ${analysisId} | theory: ${theoryId} | Analysis not found in theory`, + ); + } + + if (!analysisSpec.run) { + // Analysis type has no run function (e.g., visualization-only). + // These don't deserialize content through WASM, so nothing to test. + continue; + } + + runSpec(analysisSpec, analysisCell.content); + } +} diff --git a/packages/frontend/src/theory/theory.ts b/packages/frontend/src/theory/theory.ts index d46c29505..2eb95892a 100644 --- a/packages/frontend/src/theory/theory.ts +++ b/packages/frontend/src/theory/theory.ts @@ -1,7 +1,7 @@ import type { Component } from "solid-js"; import type { KbdKey } from "catcolab-ui-components"; -import type { DblModel, DblTheory, MorType, ObOp, ObType } from "catlog-wasm"; +import type { DblModel, DblModelDiagram, DblTheory, MorType, ObOp, ObType } from "catlog-wasm"; import type { DiagramAnalysisComponent, ModelAnalysisComponent } from "../analysis"; import type { EditorVariantOverrides, @@ -354,6 +354,14 @@ export type AnalysisMeta = { export type ModelAnalysisMeta = AnalysisMeta & { /** Component that renders the analysis. */ component: ModelAnalysisComponent; + + /** Optional run function for testing backward compatibility. + + When present, this function takes a compiled model and analysis content and + runs the analysis. It exercises the same WASM deserialization path as the + component would at runtime. + */ + run?: (model: DblModel, data: T) => unknown; }; /** Specifies a diagram analysis with descriptive metadata. */ @@ -361,4 +369,12 @@ export type ModelAnalysisMeta = AnalysisMeta & { export type DiagramAnalysisMeta = AnalysisMeta & { /** Component that renders the analysis. */ component: DiagramAnalysisComponent; + + /** Optional run function for testing backward compatibility. + + When present, this function takes a compiled diagram, its parent model, and + analysis content and runs the analysis. It exercises the same WASM + deserialization path as the component would at runtime. + */ + run?: (diagram: DblModelDiagram, model: DblModel, data: T) => unknown; }; diff --git a/packages/frontend/vitest.db-dump.config.ts b/packages/frontend/vitest.db-dump.config.ts new file mode 100644 index 000000000..fc23685ca --- /dev/null +++ b/packages/frontend/vitest.db-dump.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, mergeConfig } from "vitest/config"; + +import baseConfig from "./vite.config.ts"; + +// This config is used only for database dump tests (*.db-dump-test.ts). +// It extends the base vite config but overrides the test include pattern +// to only run database dump tests. +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ["**/*.db-dump-test.ts"], + testTimeout: 30_000, + }, + }), +);