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:
parent
82a8a9c9a6
commit
f050064771
7 changed files with 244 additions and 12 deletions
|
|
@ -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",
|
||||
|
|
|
|||
89
electron/scripts/prepare_fastapi_migrations.js
Normal file
89
electron/scripts/prepare_fastapi_migrations.js
Normal 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();
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue