diff --git a/electron/package.json b/electron/package.json index a967a074..10dd69d2 100644 --- a/electron/package.json +++ b/electron/package.json @@ -40,7 +40,7 @@ "fetch:export-runtime": "node sync_export_runtime.js --force", "fetch:export-runtime:latest": "EXPORT_RUNTIME_VERSION=latest node sync_export_runtime.js --force", "build:nextjs": "rm -rf resources/nextjs && rm -rf servers/nextjs/.next-build && cd servers/nextjs && cross-env BUILD_TARGET=electron npm run build && cp -r .next-build ../../resources/nextjs && cp -r app/presentation-templates ../../resources/nextjs/presentation-templates", - "build:fastapi": "rm -rf resources/fastapi && npm run build:vectorstore && (cp ../servers/fastapi/alembic/versions/*.py servers/fastapi/alembic/versions/ 2>/dev/null || true) && cd servers/fastapi && uv run python -m PyInstaller --distpath ../../resources server.spec", + "build:fastapi": "rm -rf resources/fastapi && npm run build:vectorstore && node scripts/prepare_fastapi_migrations.js && cd servers/fastapi && uv run python -m PyInstaller --distpath ../../resources server.spec", "generate:version": "node generate_update.js", "build:electron": "npm run generate:version && npm run build:export-runtime && rm -rf app_dist && tsc && node build.js", "build:all": "npm run clean:build && npm run setup:env && npm run build:ts && npm run install:pyinstaller && npm run build:nextjs && npm run build:fastapi && npm run build:electron", diff --git a/electron/scripts/prepare_fastapi_migrations.js b/electron/scripts/prepare_fastapi_migrations.js new file mode 100644 index 00000000..3dab5469 --- /dev/null +++ b/electron/scripts/prepare_fastapi_migrations.js @@ -0,0 +1,89 @@ +const fs = require("fs"); +const path = require("path"); + +const projectRoot = path.resolve(__dirname, ".."); +const sourceDir = path.join(projectRoot, "..", "servers", "fastapi", "alembic", "versions"); +const targetDir = path.join(projectRoot, "servers", "fastapi", "alembic", "versions"); + +function listPythonMigrations(dirPath) { + return fs + .readdirSync(dirPath, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".py")) + .map((entry) => entry.name) + .sort(); +} + +function extractRevisionInfo(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + const revisionMatch = content.match(/revision:\s*str\s*=\s*['"]([^'"]+)['"]/); + const downRevisionMatch = content.match( + /down_revision:\s*[^=]*=\s*(None|['"][^'"]+['"])/ + ); + + if (!revisionMatch) { + throw new Error(`Missing revision id in ${filePath}`); + } + if (!downRevisionMatch) { + throw new Error(`Missing down_revision in ${filePath}`); + } + + const revision = revisionMatch[1]; + const downRaw = downRevisionMatch[1]; + const downRevision = downRaw === "None" ? null : downRaw.slice(1, -1); + return { revision, downRevision }; +} + +function validateSingleHead(dirPath, filenames) { + const revisions = new Map(); + const downRevisions = new Set(); + + for (const filename of filenames) { + const filePath = path.join(dirPath, filename); + const { revision, downRevision } = extractRevisionInfo(filePath); + if (revisions.has(revision)) { + throw new Error(`Duplicate revision id ${revision} in ${filename}`); + } + revisions.set(revision, filename); + if (downRevision) { + downRevisions.add(downRevision); + } + } + + const heads = [...revisions.keys()].filter((revision) => !downRevisions.has(revision)); + if (heads.length !== 1) { + throw new Error( + `Expected exactly one Alembic head, found ${heads.length}: ${heads.join(", ")}` + ); + } +} + +function syncMigrations() { + if (!fs.existsSync(sourceDir)) { + throw new Error(`Source migrations directory not found: ${sourceDir}`); + } + if (!fs.existsSync(targetDir)) { + throw new Error(`Target migrations directory not found: ${targetDir}`); + } + + const sourceFiles = listPythonMigrations(sourceDir); + if (sourceFiles.length === 0) { + throw new Error(`No migration files found in source directory: ${sourceDir}`); + } + + for (const filename of listPythonMigrations(targetDir)) { + fs.unlinkSync(path.join(targetDir, filename)); + } + + for (const filename of sourceFiles) { + fs.copyFileSync(path.join(sourceDir, filename), path.join(targetDir, filename)); + } + + const targetFiles = listPythonMigrations(targetDir); + validateSingleHead(targetDir, targetFiles); + + console.log( + `Synced ${targetFiles.length} migration files and verified a single Alembic head.` + ); +} + +syncMigrations(); diff --git a/electron/servers/fastapi/migrations.py b/electron/servers/fastapi/migrations.py index d6356f2c..c2b75984 100644 --- a/electron/servers/fastapi/migrations.py +++ b/electron/servers/fastapi/migrations.py @@ -5,6 +5,8 @@ from alembic import command from alembic.config import Config from alembic.script import ScriptDirectory from sqlalchemy import create_engine, inspect, text +from alembic.script import ScriptDirectory +from sqlalchemy import create_engine, inspect, text from utils.db_utils import get_database_url_and_connect_args from utils.get_env import get_migrate_database_on_startup_env @@ -35,6 +37,12 @@ async def migrate_database_on_startup() -> None: raise +def run_migrations_sync() -> None: + """Apply Alembic migrations to head (for CLI/scripts; no env gate).""" + _run_migrations() + raise + + def run_migrations_sync() -> None: """Apply Alembic migrations to head (for CLI/scripts; no env gate).""" _run_migrations() diff --git a/servers/fastapi/alembic/versions/fd2ab04834cc_init.py b/servers/fastapi/alembic/versions/00b3c27a13bc_init.py similarity index 97% rename from servers/fastapi/alembic/versions/fd2ab04834cc_init.py rename to servers/fastapi/alembic/versions/00b3c27a13bc_init.py index cc863720..17fb3cc2 100644 --- a/servers/fastapi/alembic/versions/fd2ab04834cc_init.py +++ b/servers/fastapi/alembic/versions/00b3c27a13bc_init.py @@ -1,8 +1,8 @@ """init -Revision ID: fd2ab04834cc +Revision ID: 00b3c27a13bc Revises: -Create Date: 2026-03-08 19:10:59.637680 +Create Date: 2026-03-08 19:12:45.478149 """ from typing import Sequence, Union @@ -13,7 +13,7 @@ import sqlmodel # revision identifiers, used by Alembic. -revision: str = 'fd2ab04834cc' +revision: str = '00b3c27a13bc' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -76,7 +76,6 @@ def upgrade() -> None: sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column('layout', sa.JSON(), nullable=True), sa.Column('structure', sa.JSON(), nullable=True), - sa.Column('theme', sa.JSON(), nullable=True), sa.Column('instructions', sa.String(), nullable=True), sa.Column('tone', sa.String(), nullable=True), sa.Column('verbosity', sa.String(), nullable=True), diff --git a/servers/fastapi/alembic/versions/82abdbc476a7_add_theme_column_to_presentations.py b/servers/fastapi/alembic/versions/82abdbc476a7_add_theme_column_to_presentations.py new file mode 100644 index 00000000..c7282a6e --- /dev/null +++ b/servers/fastapi/alembic/versions/82abdbc476a7_add_theme_column_to_presentations.py @@ -0,0 +1,31 @@ +"""add theme column to presentations + +Revision ID: 82abdbc476a7 +Revises: f42ad4074449 +Create Date: 2026-03-24 12:42:46.220359 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '82abdbc476a7' +down_revision: Union[str, None] = 'f42ad4074449' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/servers/fastapi/alembic/versions/f42ad4074449_add_theme_column_to_presentations.py b/servers/fastapi/alembic/versions/f42ad4074449_add_theme_column_to_presentations.py new file mode 100644 index 00000000..4cc8c46f --- /dev/null +++ b/servers/fastapi/alembic/versions/f42ad4074449_add_theme_column_to_presentations.py @@ -0,0 +1,31 @@ +"""add theme column to presentations + +Revision ID: f42ad4074449 +Revises: 00b3c27a13bc +Create Date: 2026-03-24 12:42:32.369006 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'f42ad4074449' +down_revision: Union[str, None] = '00b3c27a13bc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('presentations', sa.Column('theme', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('presentations', 'theme') + # ### end Alembic commands ### diff --git a/servers/fastapi/migrations.py b/servers/fastapi/migrations.py index 0b2b578e..4cb75de0 100644 --- a/servers/fastapi/migrations.py +++ b/servers/fastapi/migrations.py @@ -3,11 +3,16 @@ from pathlib import Path from alembic import command from alembic.config import Config +from alembic.script import ScriptDirectory +from sqlalchemy import create_engine, inspect, text from utils.db_utils import get_database_url_and_connect_args from utils.get_env import get_migrate_database_on_startup_env +LEGACY_BASELINE_REVISION = "00b3c27a13bc" + + async def migrate_database_on_startup() -> None: if get_migrate_database_on_startup_env() not in ["true", "True"]: return @@ -17,6 +22,18 @@ async def migrate_database_on_startup() -> None: print("Migrations run successfully", flush=True) except Exception as exc: print(f"Error running migrations: {exc}", flush=True) + raise + + +def _to_sync_database_url(database_url: str) -> str: + # Preserve slash counts for sqlite URLs so Windows paths stay valid. + if database_url.startswith("sqlite+aiosqlite:///"): + return "sqlite:///" + database_url[len("sqlite+aiosqlite:///") :] + if database_url.startswith("postgresql+asyncpg://"): + return "postgresql://" + database_url[len("postgresql+asyncpg://") :] + if database_url.startswith("mysql+aiomysql://"): + return "mysql://" + database_url[len("mysql+aiomysql://") :] + return database_url def _run_migrations() -> None: @@ -29,12 +46,69 @@ def _run_migrations() -> None: database_url, _ = get_database_url_and_connect_args() # Alembic uses synchronous engines; strip async driver prefixes. - database_url = ( - database_url - .replace("sqlite+aiosqlite://", "sqlite:///") - .replace("postgresql+asyncpg://", "postgresql://") - .replace("mysql+aiomysql://", "mysql://") - ) + database_url = _to_sync_database_url(database_url) config.set_main_option("sqlalchemy.url", database_url) - command.upgrade(config, "head") + _stamp_legacy_database_if_needed(config, database_url) + + try: + command.upgrade(config, "head") + except Exception: + # Safety net for edge cases; legacy DBs are stamped proactively above. + if _is_unversioned_populated_database(database_url): + _stamp_legacy_database_if_needed(config, database_url) + command.upgrade(config, "head") + return + raise + + +def _stamp_legacy_database_if_needed(config: Config, database_url: str) -> None: + """ + If the DB has app tables but no migration reference in alembic_version, + treat it as a legacy DB and stamp baseline before upgrading. + """ + if not _is_unversioned_populated_database(database_url): + return + + script = ScriptDirectory.from_config(config) + known_revisions = {rev.revision for rev in script.walk_revisions()} + baseline_revision = ( + LEGACY_BASELINE_REVISION + if LEGACY_BASELINE_REVISION in known_revisions + else script.get_base() + ) + print( + "Detected legacy database without migration reference. " + f"Stamping revision to {baseline_revision} before upgrading.", + flush=True, + ) + command.stamp(config, baseline_revision) + + +def _is_unversioned_populated_database(database_url: str) -> bool: + known_app_tables = { + "presentations", + "slides", + "templates", + "keyvaluesqlmodel", + "imageasset", + "presentation_layout_codes", + "async_presentation_generation_tasks", + "webhook_subscriptions", + } + engine = create_engine(database_url) + try: + with engine.connect() as connection: + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + has_alembic_version_table = "alembic_version" in table_names + has_applied_revision = False + if has_alembic_version_table: + revision_count = connection.execute( + text("SELECT COUNT(*) FROM alembic_version") + ).scalar_one() + has_applied_revision = revision_count > 0 + has_known_app_tables = len(table_names.intersection(known_app_tables)) > 0 + return has_known_app_tables and not has_applied_revision + finally: + engine.dispose()