feat: add legacy database migration support and new database schema

- Introduced functions to handle legacy database stamping and migration.
- Added a new Alembic migration script for initializing the database schema.
- Enhanced the migration process to check for unversioned databases and apply necessary stamps before upgrades.
- Created new migration files for adding a theme column to presentations.
This commit is contained in:
sudipnext 2026-03-26 15:33:49 +05:45
parent 82a8a9c9a6
commit f050064771
7 changed files with 244 additions and 12 deletions

View file

@ -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",

View file

@ -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();

View file

@ -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()

View file

@ -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),

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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()