Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions infrastructure/scripts/dump-notebook-fixtures

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jason wrote:

infrastructure/scripts/dump-notebook-fixtures uses bash and the JSON document snapshots in the DB. We'd like to be moving away from both. It feels like something that ought to be re-written in rust, since that would get rid of the bash and allow us to operate on the automerge documents instead of the snapshots.

I won't block the PR on it, but I agree with Jason's comment. Another advantage to writing it in Rust is that we'd check that the SQL queries are valid at compile time.

Original file line number Diff line number Diff line change
@@ -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 <ssh-target> <output-file>
# 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 <ssh-target> <output-file>}"
output_file="${2:?Usage: dump-notebook-fixtures <ssh-target> <output-file>}"

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
1 change: 1 addition & 0 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 13 additions & 14 deletions packages/frontend/src/analysis/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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":
Expand All @@ -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) {
Expand All @@ -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. */
Expand Down
40 changes: 40 additions & 0 deletions packages/frontend/src/analysis/migrate.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Analysis>(
notebook: Notebook<T>,
theory: Theory,
analysisType: AnalysisType,
): void {
for (const cell of Nb.getFormalCells(notebook)) {
const analysis = cell.content;
let meta: AnalysisMeta<unknown> | 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<string, unknown>;
const cellContent = analysis.content;
for (const key in initialContent) {
if (!(key in cellContent)) {
cellContent[key] = initialContent[key];
}
}
}
}
25 changes: 23 additions & 2 deletions packages/frontend/src/stdlib/analyses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const decapodes = (
options: AnalysisOptions,
): DiagramAnalysisMeta<Simulators.DecapodesAnalysisContent> => ({
...options,
run: (diagram, model) => ({
diagram: diagram.presentation(),
model: model.presentation(),
}),
component: (props) => <Decapodes {...props} />,
initialContent: () => ({
domain: null,
Expand All @@ -43,6 +47,7 @@ export const diagramGraph = (
options: AnalysisOptions,
): DiagramAnalysisMeta<GraphLayoutConfig.Config> => ({
...options,
run: (diagram) => diagram.presentation(),
component: (props) => <DiagramGraph title={options.name} {...props} />,
initialContent: GraphLayoutConfig.defaultConfig,
});
Expand All @@ -53,6 +58,10 @@ export const tabularView = (
options: AnalysisOptions,
): DiagramAnalysisMeta<Record<string, never>> => ({
...options,
run: (diagram, model) => ({
diagram: diagram.presentation(),
model: model.presentation(),
}),
component: (props) => <TabularView title={options.name} {...props} />,
initialContent: () => ({}),
});
Expand Down Expand Up @@ -81,6 +90,7 @@ export function kuramoto(
name,
description,
help,
run: simulate,
component: (props) => (
<Kuramoto
simulate={simulate}
Expand Down Expand Up @@ -122,6 +132,7 @@ export function linearODE(
name,
description,
help,
run: simulate,
component: (props) => <LinearODE simulate={simulate} title={name} {...props} />,
initialContent: () => ({
coefficients: {},
Expand Down Expand Up @@ -150,6 +161,7 @@ export function lotkaVolterra(
name,
description,
help,
run: simulate,
component: (props) => <LotkaVolterra simulate={simulate} title={name} {...props} />,
initialContent: () => ({
interactionCoefficients: {},
Expand All @@ -175,14 +187,18 @@ export function massAction(
name = "Mass-action dynamics",
description = "Simulate the system using the law of mass action",
help = "mass-action",
simulate,
...otherOptions
} = options;
return {
id,
name,
description,
help,
component: (props) => <MassAction title={name} {...otherOptions} {...props} />,
run: simulate,
component: (props) => (
<MassAction title={name} simulate={simulate} {...otherOptions} {...props} />
),
initialContent: () => ({
massConservationType: { type: "Balanced" },
rates: {},
Expand Down Expand Up @@ -238,14 +254,18 @@ 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 {
id,
name,
description,
help,
component: (props) => <StochasticMassAction title={name} {...otherOptions} {...props} />,
run: simulate,
component: (props) => (
<StochasticMassAction title={name} simulate={simulate} {...otherOptions} {...props} />
),
initialContent: () => ({
rates: {},
initialValues: {},
Expand Down Expand Up @@ -322,6 +342,7 @@ export function reachability(
name,
description,
help,
run: otherOptions.check,
component: (props) => <Reachability title={name} {...otherOptions} {...props} />,
initialContent: () => ({ tokens: {}, forbidden: {} }),
};
Expand Down
Loading
Loading