Skip to content

feat(db) Replace the hand-rolled migration runner with Alembic, and allow Postgres - #768

Open
ioanalytica wants to merge 16 commits into
karanhudia:mainfrom
ioanalytica:feat/alembic-and-postgres
Open

feat(db) Replace the hand-rolled migration runner with Alembic, and allow Postgres#768
ioanalytica wants to merge 16 commits into
karanhudia:mainfrom
ioanalytica:feat/alembic-and-postgres

Conversation

@ioanalytica

@ioanalytica ioanalytica commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Detailed write-up sent separately; this is the short version for review.

What changes

Startup schema management moves from the hand-rolled migration runner to Alembic with a single baseline revision (f6c46c665fd3, 48 tables). New schema changes are Alembic revisions from here on.

The 135 pre-Alembic migration scripts are kept, frozen, for one job: catching an existing database up (see "Existing installations"). They are no longer run at startup.

Why

The old runner had no record of what it had already applied, so it re-ran everything on every start; whether a given database ended up correct depended on how often the container had restarted. It also only ever spoke SQLite.

Verification you can reproduce

Building the schema both ways — Base.metadata.create_all() from models.py, and alembic upgrade head from the baseline — yields the same 48 tables with zero differences in columns, indexes and foreign keys.

That check matters because the baseline was not produced by alembic revision --autogenerate. models.py had drifted from the schema the migrations actually built, so autogenerating from it would have baked the drift in. The baseline describes the real schema, and models.py was corrected to match it first — that is what the separate reconcile commit does. The two agreeing is therefore a result, not a tautology.

Existing installations — direct upgrade from any version

db_upgrade.py performs a one-time move onto the baseline, run automatically by the entrypoint before the app starts:

  • A database that already has alembic_version is only ever moved forward by Alembic (alembic upgrade head) — never the legacy path. This is the durable fork: however many Alembic revisions accrue, a managed database follows Alembic alone.
  • A pre-Alembic database is first walked up the frozen legacy ladder on a throwaway copy, so the data transforms those migrations performed (an interval turned into a cron expression, a timeout split in two) are already done before its rows are copied. Any supported version upgrades directly to the latest — no manual stop on an intermediate release. The source is never touched; it stays as the rollback. A safety net refuses (source intact) only if the lenient ladder leaves a required transform undone.

Both databases exist at the same time, so nothing is serialised; every deviation between source and target is reported rather than absorbed.

Postgres

Opt-in, and it falls out of the above rather than being a separate feature: the same upgrade path with a different URL. SQLite remains the default and is unaffected. One behavioural fix was needed — .like() is case-sensitive on Postgres where SQLite is not, so name searches use .ilike().

For future migrations

A short guide under docs/engineering/ covers the handful of places where dialects actually differ — batch operations for SQLite's limited ALTER TABLE, booleans, autoincrement — so one migration file stays correct on both.

Validation

Run against a real 197 MB production SQLite database (227k rows): transferred onto the baseline with zero row loss. Tests cover the data transforms (interval→cron, the hook-timeout split), the full-ladder run, the safety net, and the Alembic-managed fork.

Self-contained commits in dependency order.

Summary by CodeRabbit

  • New Features

    • Case-insensitive backup plan name uniqueness.
    • Case-insensitive script/library search (name and description).
  • Bug Fixes

    • Database schema upgrade/initialization now runs reliably during startup before SSH key deployment.
    • Database URL handling now correctly supports Postgres environment settings (including credential percent-encoding) and avoids overriding Postgres with a default SQLite URL.
    • Admin/maintenance scripts now operate against the configured database instead of a hardcoded SQLite path.
  • Documentation

    • Added Alembic configuration and migration guidance for SQLite/Postgres.
  • Tests / CI

    • Expanded schema upgrade/startup concurrency tests and improved CI database setup (Postgres alongside Redis).

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This change introduces Alembic schema management, a baseline migration, SQLite-to-Postgres upgrades, environment-based database URL resolution, boot-time schema enforcement, database-agnostic scripts, expanded tests, model index updates, and case-insensitive API searches.

Changes

Database migration and storage transition

Layer / File(s) Summary
Database selection and boot wiring
.github/workflows/tests.yml, Dockerfile, app/config.py, app/main.py, entrypoint.sh, app/database/url_utils.py
Database URLs now support Postgres configuration, Docker no longer hardcodes SQLite, Postgres is provisioned for coverage tests, and startup requires schema readiness before database use.
Alembic baseline and model contracts
alembic.ini, app/database/alembic/*, app/database/database.py, app/database/models.py
Adds Alembic configuration and a baseline schema, plus naming conventions, indexes, foreign-key actions, database-side timestamps, and SQLite autoincrement handling.
Upgrade, transfer, and rollback engine
app/database/db_upgrade.py, app/database/migrations/*
Adds schema initialization, legacy data transfer, orphan and self-reference handling, Postgres sequence resets, rollback finalization, and removes legacy migration implementations.
Database-agnostic scripts
app/scripts/*
Migrates administrative and package-startup scripts to configured SQLAlchemy database access with missing-database guards and managed transactions.
Validation and API behavior
tests/unit/*, tests/fixtures/api.py, app/api/*
Adds coverage for database resolution, startup ordering, schema upgrades, rollback protection, Postgres behavior, script outcomes, and case-insensitive matching.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Entrypoint
  participant DatabaseConfig
  participant UpgradeEngine
  participant SQLite
  participant PostgreSQL
  Entrypoint->>DatabaseConfig: resolve database URL
  Entrypoint->>UpgradeEngine: run upgrade_from_settings
  UpgradeEngine->>SQLite: inspect or build baseline
  UpgradeEngine->>PostgreSQL: transfer rows when Postgres is selected
  UpgradeEngine->>SQLite: finalize rollback backup
  UpgradeEngine-->>Entrypoint: return upgrade report
Loading

Possibly related PRs

Suggested labels: symphony

Suggested reviewers: karanhudia

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main changes: migrating to Alembic and adding Postgres support.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/backup_plans.py (1)

195-205: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the uniqueness checks case-insensitive end to end.

ilike finds case-insensitive matches, but candidate not in existing_names and the suffix loop remain case-sensitive. If Nightly already exists, requesting nightly still returns nightly, allowing names that differ only by case. Normalize both the candidate and existing names before the membership checks.

Proposed fix
     existing_names = {
         name
         for (name,) in db.query(BackupPlan.name)
         .filter(BackupPlan.name.ilike(f"{candidate}%"))
         .all()
     }
-    if candidate not in existing_names:
+    normalized_names = {name.casefold() for name in existing_names}
+    if candidate.casefold() not in normalized_names:
         return candidate

     suffix = 2
-    while f"{candidate} ({suffix})" in existing_names:
+    while f"{candidate} ({suffix})".casefold() in normalized_names:
         suffix += 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/backup_plans.py` around lines 195 - 205, Update the uniqueness logic
in the backup-plan name generation flow to normalize both candidate and database
names consistently before membership checks. Ensure the initial candidate check
and the suffix loop in the surrounding name-generation function reject names
differing only by case while preserving the existing suffix behavior.
🧹 Nitpick comments (1)
tests/unit/test_main.py (1)

24-71: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: flatten to parenthesized context managers for consistency.

The other two tests in this class use a single parenthesized with (...) group; this one keeps the deeply nested style. Aligning them improves readability. Functionally correct as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_main.py` around lines 24 - 71, Optionally refactor the nested
patch context managers in the startup_event test around startup_event into one
parenthesized with group, matching the style used by the other tests in the
class. Preserve all patches, ordering, mocks, and assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/api/backup_plans.py`:
- Around line 195-205: Update the uniqueness logic in the backup-plan name
generation flow to normalize both candidate and database names consistently
before membership checks. Ensure the initial candidate check and the suffix loop
in the surrounding name-generation function reject names differing only by case
while preserving the existing suffix behavior.

---

Nitpick comments:
In `@tests/unit/test_main.py`:
- Around line 24-71: Optionally refactor the nested patch context managers in
the startup_event test around startup_event into one parenthesized with group,
matching the style used by the other tests in the class. Preserve all patches,
ordering, mocks, and assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2de1f8cd-a1a3-4d88-afbf-cf7110d285f2

📥 Commits

Reviewing files that changed from the base of the PR and between af65d69 and e90683b.

📒 Files selected for processing (160)
  • .github/workflows/tests.yml
  • Dockerfile
  • alembic.ini
  • app/api/backup_plans.py
  • app/api/scripts_library.py
  • app/config.py
  • app/database/alembic/README.md
  • app/database/alembic/env.py
  • app/database/alembic/script.py.mako
  • app/database/alembic/versions/f6c46c665fd3_baseline_schema.py
  • app/database/database.py
  • app/database/db_upgrade.py
  • app/database/migrations/004_add_progress_fields.py
  • app/database/migrations/005_add_backup_speed.py
  • app/database/migrations/006_add_scheduled_jobs.py
  • app/database/migrations/007_add_size_progress_fields.py
  • app/database/migrations/008_add_scheduled_job_id_to_backups.py
  • app/database/migrations/009_add_restore_jobs.py
  • app/database/migrations/010_add_backup_hooks.py
  • app/database/migrations/011_add_must_change_password.py
  • app/database/migrations/012_add_prune_compact_to_scheduled_jobs.py
  • app/database/migrations/013_add_maintenance_status_to_backup_jobs.py
  • app/database/migrations/014_add_check_compact_jobs.py
  • app/database/migrations/015_add_last_check_compact_to_repositories.py
  • app/database/migrations/016_add_max_duration_to_check_jobs.py
  • app/database/migrations/017_add_pid_tracking.py
  • app/database/migrations/018_remove_config_file.py
  • app/database/migrations/019_add_archive_name_template.py
  • app/database/migrations/020_add_repository_mode.py
  • app/database/migrations/021_add_custom_flags.py
  • app/database/migrations/022_add_ssh_key_default_path.py
  • app/database/migrations/023_add_notify_on_backup_start.py
  • app/database/migrations/024_add_hourly_quarterly_prune.py
  • app/database/migrations/025_add_check_notifications_and_scheduling.py
  • app/database/migrations/027_add_script_library.py
  • app/database/migrations/028_split_hook_timeouts.py
  • app/database/migrations/029_add_continue_on_error.py
  • app/database/migrations/030_add_repository_path_to_check_compact_jobs.py
  • app/database/migrations/031_add_prune_jobs_table.py
  • app/database/migrations/032_add_storage_and_mount_to_ssh_connections.py
  • app/database/migrations/033_add_log_management_settings.py
  • app/database/migrations/034_add_scheduled_prune_field.py
  • app/database/migrations/034_convert_check_interval_to_cron.py
  • app/database/migrations/035_add_prune_job_log_fields.py
  • app/database/migrations/036_add_compact_job_log_fields.py
  • app/database/migrations/037_add_multi_repo_schedules.py
  • app/database/migrations/038_add_keyfile_support.py
  • app/database/migrations/039_add_repository_notification_filters.py
  • app/database/migrations/040_add_cache_settings.py
  • app/database/migrations/041_add_redis_url.py
  • app/database/migrations/043_add_check_job_log_fields.py
  • app/database/migrations/044_add_remote_backup_support.py
  • app/database/migrations/045_add_repository_connection_id.py
  • app/database/migrations/046_add_use_new_wizard_setting.py
  • app/database/migrations/047_add_browse_memory_limits.py
  • app/database/migrations/048_fix_ssh_connection_cascade.py
  • app/database/migrations/049_add_delete_archive_jobs.py
  • app/database/migrations/050_add_source_connection_to_repositories.py
  • app/database/migrations/051_add_analytics_enabled.py
  • app/database/migrations/052_add_analytics_consent_given.py
  • app/database/migrations/053_add_operation_timeouts.py
  • app/database/migrations/054_add_stats_refresh_interval.py
  • app/database/migrations/055_add_bypass_lock.py
  • app/database/migrations/056_add_last_stats_refresh.py
  • app/database/migrations/057_add_script_parameters.py
  • app/database/migrations/057_populate_connection_id_for_legacy_repos.py
  • app/database/migrations/058_add_notification_enhancements.py
  • app/database/migrations/059_add_use_sftp_mode_to_ssh_connections.py
  • app/database/migrations/060_add_speed_eta_to_restore_jobs.py
  • app/database/migrations/061_add_bypass_lock_on_info.py
  • app/database/migrations/062_add_unique_constraint_schedule_repo.py
  • app/database/migrations/063_add_schedule_script_parameters.py
  • app/database/migrations/064_add_show_restore_tab.py
  • app/database/migrations/065_cleanup_schedule_duplicates.py
  • app/database/migrations/066_add_ssh_path_prefix.py
  • app/database/migrations/067_add_cascade_delete_to_script_executions.py
  • app/database/migrations/068_add_remote_restore_fields.py
  • app/database/migrations/069_add_bypass_lock_on_list.py
  • app/database/migrations/070_add_source_size_timeout.py
  • app/database/migrations/071_add_mqtt_beta_enabled.py
  • app/database/migrations/072_add_mqtt_base_topic.py
  • app/database/migrations/073_fix_delete_archive_jobs_cascade.py
  • app/database/migrations/074_add_use_sudo_to_ssh_connections.py
  • app/database/migrations/074_remove_mqtt_base_topic.py
  • app/database/migrations/075_add_skip_on_failure.py
  • app/database/migrations/075_fix_schedule_script_id_set_null.py
  • app/database/migrations/076_fix_package_install_jobs_cascade.py
  • app/database/migrations/077_fix_script_executions_fk_actions.py
  • app/database/migrations/078_fix_scripts_created_by_user_fk.py
  • app/database/migrations/079_add_borg_version.py
  • app/database/migrations/080_add_plan.py
  • app/database/migrations/081_add_role_and_api_tokens.py
  • app/database/migrations/082_add_user_profile_fields.py
  • app/database/migrations/083_add_deployment_profile.py
  • app/database/migrations/084_add_user_repository_permissions.py
  • app/database/migrations/085_add_all_repositories_role.py
  • app/database/migrations/086_add_licensing_state.py
  • app/database/migrations/087_add_repository_inline_script_parameters.py
  • app/database/migrations/088_add_metrics_settings.py
  • app/database/migrations/089_add_backup_job_archive_name.py
  • app/database/migrations/090_add_borg2_fast_browse_beta_enabled.py
  • app/database/migrations/091_add_totp_to_users.py
  • app/database/migrations/092_add_passkey_credentials.py
  • app/database/migrations/093_add_backup_warning_notifications.py
  • app/database/migrations/094_add_scheduler_concurrency_limits.py
  • app/database/migrations/095_add_schedule_timezones.py
  • app/database/migrations/096_add_oidc_settings.py
  • app/database/migrations/097_add_oidc_state_and_auth_events.py
  • app/database/migrations/098_add_auth_rate_limit_buckets.py
  • app/database/migrations/099_harden_oidc_exchange_and_admin_mapping.py
  • app/database/migrations/100_add_oidc_linking_and_token_auth.py
  • app/database/migrations/101_add_restore_check_jobs.py
  • app/database/migrations/102_add_restore_check_canary_enabled.py
  • app/database/migrations/103_add_restore_check_notifications.py
  • app/database/migrations/104_add_agent_machines.py
  • app/database/migrations/104_add_backup_plans.py
  • app/database/migrations/105_add_agent_jobs.py
  • app/database/migrations/105_add_dashboard_health_threshold_settings.py
  • app/database/migrations/106_add_agent_repository_links.py
  • app/database/migrations/106_add_schedule_enabled_flags.py
  • app/database/migrations/107_add_check_extra_flags.py
  • app/database/migrations/107_add_source_locations.py
  • app/database/migrations/108_add_managed_agents_beta_enabled.py
  • app/database/migrations/109_add_repository_wipe_jobs.py
  • app/database/migrations/110_add_repository_executor_type.py
  • app/database/migrations/111_add_backup_job_route_strategy.py
  • app/database/migrations/111_add_backup_monitoring_reports.py
  • app/database/migrations/112_add_backup_report_delivery_schedule.py
  • app/database/migrations/112_agent_delete_and_nullable_enrollment_expiry.py
  • app/database/migrations/113_add_rclone_storage.py
  • app/database/migrations/114_add_scheduled_rclone_mirror_jobs.py
  • app/database/migrations/115_add_rclone_oauth_provider_credentials.py
  • app/database/migrations/116_add_agent_default_path.py
  • app/database/migrations/117_add_backup_plan_database_template_id.py
  • app/database/migrations/117_add_rclone_sync_job_operation.py
  • app/database/migrations/118_add_backup_plan_scripts.py
  • app/database/migrations/119_add_backup_retry_lineage.py
  • app/database/migrations/120_add_lock_breaking_enabled.py
  • app/database/migrations/121_add_rclone_oauth_provider_credentials_table.py
  • app/database/migrations/122_add_rclone_remote_storage.py
  • app/database/migrations/122_add_remote_backend_clients.py
  • app/database/migrations/123_add_repository_upload_ratelimit.py
  • app/database/migrations/124_add_backup_plan_upload_ratelimit_policies.py
  • app/database/migrations/125_add_prune_keep_within.py
  • app/database/migrations/126_add_agent_script_to_plan_hooks.py
  • app/database/migrations/126_clear_persisted_archive_browse_listings.py
  • app/database/migrations/127_add_agent_job_to_script_executions.py
  • app/database/migrations/__init__.py
  • app/database/models.py
  • app/main.py
  • app/scripts/deploy_ssh_key.py
  • entrypoint.sh
  • requirements.txt
  • tests/fixtures/api.py
  • tests/unit/test_api_rclone.py
  • tests/unit/test_config.py
  • tests/unit/test_database_operations.py
  • tests/unit/test_db_upgrade.py
  • tests/unit/test_dockerfile_database_url.py
  • tests/unit/test_main.py
💤 Files with no reviewable changes (139)
  • app/database/migrations/038_add_keyfile_support.py
  • app/database/migrations/046_add_use_new_wizard_setting.py
  • app/database/migrations/init.py
  • app/database/migrations/055_add_bypass_lock.py
  • app/database/migrations/064_add_show_restore_tab.py
  • app/database/migrations/117_add_rclone_sync_job_operation.py
  • app/database/migrations/085_add_all_repositories_role.py
  • app/database/migrations/049_add_delete_archive_jobs.py
  • app/database/migrations/072_add_mqtt_base_topic.py
  • app/database/migrations/107_add_source_locations.py
  • app/database/migrations/045_add_repository_connection_id.py
  • app/database/migrations/125_add_prune_keep_within.py
  • app/database/migrations/004_add_progress_fields.py
  • app/database/migrations/111_add_backup_job_route_strategy.py
  • app/database/migrations/127_add_agent_job_to_script_executions.py
  • app/database/migrations/014_add_check_compact_jobs.py
  • app/database/migrations/110_add_repository_executor_type.py
  • app/database/migrations/071_add_mqtt_beta_enabled.py
  • app/database/migrations/017_add_pid_tracking.py
  • app/database/migrations/122_add_remote_backend_clients.py
  • app/database/migrations/107_add_check_extra_flags.py
  • app/database/migrations/086_add_licensing_state.py
  • app/database/migrations/043_add_check_job_log_fields.py
  • app/database/migrations/102_add_restore_check_canary_enabled.py
  • app/database/migrations/108_add_managed_agents_beta_enabled.py
  • app/database/migrations/057_populate_connection_id_for_legacy_repos.py
  • app/database/migrations/090_add_borg2_fast_browse_beta_enabled.py
  • app/database/migrations/020_add_repository_mode.py
  • app/database/migrations/006_add_scheduled_jobs.py
  • app/database/migrations/070_add_source_size_timeout.py
  • app/database/migrations/106_add_agent_repository_links.py
  • app/database/migrations/109_add_repository_wipe_jobs.py
  • app/database/migrations/022_add_ssh_key_default_path.py
  • app/database/migrations/032_add_storage_and_mount_to_ssh_connections.py
  • app/database/migrations/063_add_schedule_script_parameters.py
  • app/database/migrations/082_add_user_profile_fields.py
  • app/database/migrations/036_add_compact_job_log_fields.py
  • app/database/migrations/087_add_repository_inline_script_parameters.py
  • app/database/migrations/005_add_backup_speed.py
  • app/database/migrations/066_add_ssh_path_prefix.py
  • app/database/migrations/051_add_analytics_enabled.py
  • app/database/migrations/061_add_bypass_lock_on_info.py
  • app/database/migrations/103_add_restore_check_notifications.py
  • app/database/migrations/124_add_backup_plan_upload_ratelimit_policies.py
  • app/database/migrations/047_add_browse_memory_limits.py
  • app/database/migrations/118_add_backup_plan_scripts.py
  • app/database/migrations/029_add_continue_on_error.py
  • app/database/migrations/089_add_backup_job_archive_name.py
  • app/database/migrations/117_add_backup_plan_database_template_id.py
  • app/database/migrations/088_add_metrics_settings.py
  • app/database/migrations/104_add_agent_machines.py
  • app/database/migrations/104_add_backup_plans.py
  • app/database/migrations/091_add_totp_to_users.py
  • app/database/migrations/122_add_rclone_remote_storage.py
  • app/database/migrations/105_add_agent_jobs.py
  • app/database/migrations/096_add_oidc_settings.py
  • app/database/migrations/048_fix_ssh_connection_cascade.py
  • app/database/migrations/054_add_stats_refresh_interval.py
  • app/database/migrations/053_add_operation_timeouts.py
  • app/database/migrations/039_add_repository_notification_filters.py
  • app/database/migrations/021_add_custom_flags.py
  • app/database/migrations/093_add_backup_warning_notifications.py
  • app/database/migrations/030_add_repository_path_to_check_compact_jobs.py
  • app/database/migrations/013_add_maintenance_status_to_backup_jobs.py
  • app/database/migrations/097_add_oidc_state_and_auth_events.py
  • app/database/migrations/023_add_notify_on_backup_start.py
  • app/database/migrations/018_remove_config_file.py
  • app/database/migrations/111_add_backup_monitoring_reports.py
  • app/database/migrations/028_split_hook_timeouts.py
  • app/database/migrations/079_add_borg_version.py
  • app/database/migrations/123_add_repository_upload_ratelimit.py
  • app/database/migrations/080_add_plan.py
  • app/database/migrations/115_add_rclone_oauth_provider_credentials.py
  • app/database/migrations/092_add_passkey_credentials.py
  • app/database/migrations/009_add_restore_jobs.py
  • app/database/migrations/084_add_user_repository_permissions.py
  • app/database/migrations/114_add_scheduled_rclone_mirror_jobs.py
  • app/database/migrations/083_add_deployment_profile.py
  • app/database/migrations/116_add_agent_default_path.py
  • app/database/migrations/057_add_script_parameters.py
  • app/database/migrations/078_fix_scripts_created_by_user_fk.py
  • app/database/migrations/031_add_prune_jobs_table.py
  • app/database/migrations/105_add_dashboard_health_threshold_settings.py
  • app/database/migrations/074_add_use_sudo_to_ssh_connections.py
  • app/database/migrations/012_add_prune_compact_to_scheduled_jobs.py
  • app/database/migrations/019_add_archive_name_template.py
  • app/database/migrations/056_add_last_stats_refresh.py
  • app/database/migrations/112_add_backup_report_delivery_schedule.py
  • app/database/migrations/041_add_redis_url.py
  • app/database/migrations/094_add_scheduler_concurrency_limits.py
  • app/database/migrations/121_add_rclone_oauth_provider_credentials_table.py
  • app/database/migrations/011_add_must_change_password.py
  • app/database/migrations/040_add_cache_settings.py
  • app/database/migrations/025_add_check_notifications_and_scheduling.py
  • app/database/migrations/008_add_scheduled_job_id_to_backups.py
  • app/database/migrations/034_add_scheduled_prune_field.py
  • app/database/migrations/027_add_script_library.py
  • app/database/migrations/075_fix_schedule_script_id_set_null.py
  • app/database/migrations/033_add_log_management_settings.py
  • app/database/migrations/034_convert_check_interval_to_cron.py
  • app/database/migrations/062_add_unique_constraint_schedule_repo.py
  • app/database/migrations/126_add_agent_script_to_plan_hooks.py
  • app/database/migrations/126_clear_persisted_archive_browse_listings.py
  • app/database/migrations/099_harden_oidc_exchange_and_admin_mapping.py
  • app/database/migrations/076_fix_package_install_jobs_cascade.py
  • app/database/migrations/010_add_backup_hooks.py
  • app/database/migrations/077_fix_script_executions_fk_actions.py
  • app/database/migrations/058_add_notification_enhancements.py
  • app/database/migrations/073_fix_delete_archive_jobs_cascade.py
  • app/database/migrations/120_add_lock_breaking_enabled.py
  • app/database/migrations/095_add_schedule_timezones.py
  • app/database/migrations/067_add_cascade_delete_to_script_executions.py
  • app/database/migrations/015_add_last_check_compact_to_repositories.py
  • app/database/migrations/037_add_multi_repo_schedules.py
  • app/database/migrations/101_add_restore_check_jobs.py
  • app/database/migrations/052_add_analytics_consent_given.py
  • app/database/migrations/113_add_rclone_storage.py
  • app/database/migrations/081_add_role_and_api_tokens.py
  • app/database/migrations/050_add_source_connection_to_repositories.py
  • app/database/migrations/044_add_remote_backup_support.py
  • app/database/migrations/059_add_use_sftp_mode_to_ssh_connections.py
  • app/database/migrations/100_add_oidc_linking_and_token_auth.py
  • app/database/migrations/074_remove_mqtt_base_topic.py
  • app/database/migrations/069_add_bypass_lock_on_list.py
  • app/database/migrations/016_add_max_duration_to_check_jobs.py
  • app/database/migrations/065_cleanup_schedule_duplicates.py
  • app/database/migrations/068_add_remote_restore_fields.py
  • app/database/migrations/112_agent_delete_and_nullable_enrollment_expiry.py
  • app/database/migrations/060_add_speed_eta_to_restore_jobs.py
  • app/database/migrations/007_add_size_progress_fields.py
  • app/database/migrations/024_add_hourly_quarterly_prune.py
  • app/database/migrations/035_add_prune_job_log_fields.py
  • app/database/migrations/075_add_skip_on_failure.py
  • app/database/migrations/098_add_auth_rate_limit_buckets.py
  • app/database/migrations/106_add_schedule_enabled_flags.py
  • app/database/migrations/119_add_backup_retry_lineage.py
  • tests/unit/test_api_rclone.py
  • tests/unit/test_database_operations.py
  • app/main.py

@ioanalytica

Copy link
Copy Markdown
Contributor Author

Thanks — both findings looked at, one is answered with tests rather than a change, and CI turned up something real that is now fixed.

CI: smoke tests were failing, and rightly so

Not Postgres — every unit, integration, lint and frontend job passed. The three smoke jobs failed with Authentication failed for admin: 500, and they pass on main, so this PR caused it.

Cause: this branch moved schema creation into the container entrypoint, so the application no longer prepared its own database. The smoke jobs start it with uvicorn app.main:app, which does not go through the entrypoint — empty database, and every request that reads from it fails. That is not a CI artefact: anyone running the app outside the container hit the same thing.

Fixed in eba83279. Startup calls ensure_schema() before anything opens a session: cheap when there is nothing to do, builds the baseline when the database is empty, and deliberately leaves a pre-Alembic database untouched with a log line naming python -m app.database.db_upgrade — migrating one copies every row and can change where the database lives, which is a one-time operation with a rollback, not something to do behind a server that is already coming up.

Four tests, three on the function and one on the call, since the regression was a missing invocation rather than a broken function.

_unique_backup_plan_name — no change, tests instead

The observation is correct: the prefix query is case-insensitive while the comparisons that follow are exact. That is intended.

Plan names are unique case-sensitively (name = Column(String, unique=True)), and the function exists to produce a name that will not violate that constraint. With Nightly present, nightly is genuinely free — returning nightly (2) would suffix a name nobody is using. The proposed normalisation would make the helper stricter than the schema.

The ILIKE conversion also cannot change what this function returns: it only widens the candidate set, and an exact comparison decides. That is why the commit converting it says so explicitly.

Pinned in e64d8989 — four cases: a case variant is free, an exact collision is suffixed, suffixes skip what exists, and a case variant does not consume a suffix. Whether two plans differing only in case are desirable is a separate product question, and changing it would belong in its own PR.

test_main.py nesting

Left as is. The nested with block predates this PR; the cutover commit removed one level from it, which is why it shows up in the diff at all. Flattening it would pull fifty lines of unrelated test structure into a change that is already large.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/unit/test_db_upgrade_ensure_schema.py (1)

87-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer nested functions or mocks over lambda exception hacks.

Using an unstarted generator's .throw() method to raise an exception inside a lambda expression is a cryptic workaround. Using a standard nested function (or unittest.mock.Mock(side_effect=...)) achieves the same result transparently and aligns with Python and pytest idioms.

  • tests/unit/test_db_upgrade_ensure_schema.py#L87-L91: Replace the lambda with a nested def that raises RuntimeError("table already exists").
  • tests/unit/test_db_upgrade_ensure_schema.py#L100-L104: Replace the lambda with a nested def that raises RuntimeError("disk is full").
♻️ Proposed refactor for both tests

For test_a_lost_race_is_not_an_error:

-        monkeypatch.setattr(
-            db_upgrade,
-            "_upgrade_to_head",
-            lambda *a, **k: (_ for _ in ()).throw(RuntimeError("table already exists")),
-        )
+        def mock_upgrade_table_exists(*args, **kwargs):
+            raise RuntimeError("table already exists")
+
+        monkeypatch.setattr(db_upgrade, "_upgrade_to_head", mock_upgrade_table_exists)

For test_a_build_that_really_failed_still_raises:

-        monkeypatch.setattr(
-            db_upgrade,
-            "_upgrade_to_head",
-            lambda *a, **k: (_ for _ in ()).throw(RuntimeError("disk is full")),
-        )
+        def mock_upgrade_disk_full(*args, **kwargs):
+            raise RuntimeError("disk is full")
+
+        monkeypatch.setattr(db_upgrade, "_upgrade_to_head", mock_upgrade_disk_full)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_db_upgrade_ensure_schema.py` around lines 87 - 91, Replace
the exception-raising lambda patched into db_upgrade._upgrade_to_head in
tests/unit/test_db_upgrade_ensure_schema.py:87-91 with a nested function that
raises RuntimeError("table already exists"). Make the same change at
tests/unit/test_db_upgrade_ensure_schema.py:100-104, using a nested function
that raises RuntimeError("disk is full"); preserve both tests’ existing behavior
and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/unit/test_db_upgrade_ensure_schema.py`:
- Around line 87-91: Replace the exception-raising lambda patched into
db_upgrade._upgrade_to_head in tests/unit/test_db_upgrade_ensure_schema.py:87-91
with a nested function that raises RuntimeError("table already exists"). Make
the same change at tests/unit/test_db_upgrade_ensure_schema.py:100-104, using a
nested function that raises RuntimeError("disk is full"); preserve both tests’
existing behavior and assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 534610f2-8538-4c0d-b6a5-ceb966b24806

📥 Commits

Reviewing files that changed from the base of the PR and between e64d898 and 24a1d47.

📒 Files selected for processing (2)
  • app/database/db_upgrade.py
  • tests/unit/test_db_upgrade_ensure_schema.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/database/db_upgrade.py

@ioanalytica

Copy link
Copy Markdown
Contributor Author

CI is green now — including the three smoke jobs that caught this, which were the only failures before.

Two commits since the review:

  • eba83279 — startup builds the schema when it is missing, so the app no longer depends on the container entrypoint having run. This was the actual cause of the 500s.
  • 24a1d477 — that call runs per process, so with more than one worker two of them can find an empty database and both start building it. The loser now waits for the winner to finish rather than dying on "table already exists". A build that failed for a real reason still surfaces; there is a test feeding it a full disk to keep the guard from becoming a blanket catch.

Also e64d8989, the four tests pinning what unique plan names mean, as described above.

@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from 24a1d47 to 268ef6b Compare July 20, 2026 17:52
@ioanalytica

Copy link
Copy Markdown
Contributor Author

Taken — the generator .throw() was needlessly cryptic. Both are now Mock(side_effect=RuntimeError(...)), which is shorter than the nested def and reads plainly. Folded into 268ef6b9 rather than added on top.

Checked that the tests still discriminate after the rewrite, since a clearer test that no longer fails would be a bad trade: widening the guard to a blanket catch still produces DID NOT RAISE, and removing the startup call still produces Expected 'ensure_schema' to have been called once.

@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from 268ef6b to c772a31 Compare July 22, 2026 16:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/scripts/startup_packages.py (1)

40-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting the shared "SQLite file absent" guard. The same make_url(...) → backend == "sqlite" → not Path(url.database).exists() predicate is reimplemented in three scripts; a single shared helper would prevent the checks from drifting apart.

  • app/scripts/startup_packages.py#L40-L41: keep _database_absent() but have it delegate to a shared helper (e.g. in app.database).
  • app/scripts/reset_password.py#L55-L58: replace the inline guard with the shared helper.
  • app/scripts/delete_ssh_key.py#L34-L37: replace the inline guard with the shared helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/scripts/startup_packages.py` around lines 40 - 41, Extract the shared
SQLite-file-absent predicate into a helper in app.database, then update
_database_absent() in app/scripts/startup_packages.py to delegate to it. Replace
the inline make_url/backend/Path checks in app/scripts/reset_password.py lines
55-58 and app/scripts/delete_ssh_key.py lines 34-37 with the shared helper,
preserving the existing behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/scripts/startup_packages.py`:
- Around line 40-41: Extract the shared SQLite-file-absent predicate into a
helper in app.database, then update _database_absent() in
app/scripts/startup_packages.py to delegate to it. Replace the inline
make_url/backend/Path checks in app/scripts/reset_password.py lines 55-58 and
app/scripts/delete_ssh_key.py lines 34-37 with the shared helper, preserving the
existing behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a5275df7-052b-4374-81c6-f024cddeb61a

📥 Commits

Reviewing files that changed from the base of the PR and between 268ef6b and c772a31.

📒 Files selected for processing (12)
  • app/config.py
  • app/database/alembic/README.md
  • app/database/db_upgrade.py
  • app/main.py
  • app/scripts/delete_ssh_key.py
  • app/scripts/reset_password.py
  • app/scripts/startup_packages.py
  • tests/unit/test_admin_db_scripts.py
  • tests/unit/test_api_backup_plans.py
  • tests/unit/test_db_upgrade_ensure_schema.py
  • tests/unit/test_main.py
  • tests/unit/test_startup_packages.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/database/alembic/README.md
  • app/main.py
  • tests/unit/test_main.py
  • tests/unit/test_db_upgrade_ensure_schema.py
  • app/database/db_upgrade.py

@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from c772a31 to a619f99 Compare July 22, 2026 17:14
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 22, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
@ioanalytica

Copy link
Copy Markdown
Contributor Author

The shared guard now lives in app/database/url_utils.py (import-side-effect free); all three scripts delegate to it.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

@ioanalytica: Thanks for consolidating the guard. I’ll review the shared app/database/url_utils.py implementation and each script’s delegation, with particular attention to import safety and preserving the missing-SQLite-file behavior.


✏️ Learnings added
Learnt from: ioanalytica
URL: https://github.com/karanhudia/borg-ui/pull/768

Timestamp: 2026-07-22T17:15:08.578Z
Learning: In PR `#768`, database URL validation shared by the administrative/startup scripts is centralized in the import-side-effect-free module `app/database/url_utils.py`; all three affected scripts delegate to this shared guard.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/database/url_utils.py`:
- Around line 19-20: Update the URL-checking function around make_url and
get_backend_name to handle SQLite in-memory URLs before constructing
Path(url.database): return false for empty database values and the ":memory:"
database, while preserving the missing-file check for SQLite URLs with a
filesystem database.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e4e4dc4e-a0da-484f-bae5-84cee1489280

📥 Commits

Reviewing files that changed from the base of the PR and between 268ef6b and a619f99.

📒 Files selected for processing (13)
  • app/config.py
  • app/database/alembic/README.md
  • app/database/db_upgrade.py
  • app/database/url_utils.py
  • app/main.py
  • app/scripts/delete_ssh_key.py
  • app/scripts/reset_password.py
  • app/scripts/startup_packages.py
  • tests/unit/test_admin_db_scripts.py
  • tests/unit/test_api_backup_plans.py
  • tests/unit/test_db_upgrade_ensure_schema.py
  • tests/unit/test_main.py
  • tests/unit/test_startup_packages.py
🚧 Files skipped from review as they are similar to previous changes (10)
  • app/database/alembic/README.md
  • app/config.py
  • tests/unit/test_startup_packages.py
  • app/scripts/reset_password.py
  • tests/unit/test_main.py
  • app/main.py
  • app/scripts/delete_ssh_key.py
  • app/database/db_upgrade.py
  • app/scripts/startup_packages.py
  • tests/unit/test_db_upgrade_ensure_schema.py

Comment thread app/database/url_utils.py Outdated
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 22, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 22, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 22, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 22, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 23, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 23, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
ioanalytica added a commit to ioanalytica/borg-ui that referenced this pull request Jul 23, 2026
CodeRabbit on karanhudia#768: the same make_url -> backend == sqlite -> file
missing predicate was reimplemented in three scripts. One helper in
app/database/url_utils.py (import-side-effect free) now serves all of
them. Mirrors the amended commits on feat/alembic-and-postgres.
@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from a619f99 to 4de3fdb Compare July 24, 2026 06:27
@ioanalytica

Copy link
Copy Markdown
Contributor Author

Addressed the url_utils.py finding: sqlite_database_missing now short-circuits non-sqlite backends, and treats an empty path (sqlite://) and :memory: as present rather than calling Path(None) / a bogus filesystem check. Added dedicated unit tests for all four cases (existing file, absent file, non-sqlite, in-memory/empty).

@coderabbitai review

@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from 4de3fdb to 756ab75 Compare July 24, 2026 06:48
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

@ioanalytica: I’ll review the updated URL guard and its coverage for file-backed SQLite, absent files, non-SQLite URLs, and empty/in-memory SQLite URLs.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/test_main.py`:
- Around line 24-71: Mock app.database.db_upgrade.ensure_schema in each startup
test setup before invoking startup_event: tests/unit/test_main.py lines 24-71,
82-98, and 132-153. Keep the existing dependency mocks and assertions unchanged
so all startup tests avoid real schema initialization and database mutation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f8522d45-2bf3-444f-869d-bd9a88243aab

📥 Commits

Reviewing files that changed from the base of the PR and between a619f99 and 756ab75.

📒 Files selected for processing (169)
  • .github/workflows/tests.yml
  • Dockerfile
  • alembic.ini
  • app/api/backup_plans.py
  • app/api/scripts_library.py
  • app/config.py
  • app/database/alembic/README.md
  • app/database/alembic/env.py
  • app/database/alembic/script.py.mako
  • app/database/alembic/versions/f6c46c665fd3_baseline_schema.py
  • app/database/database.py
  • app/database/db_upgrade.py
  • app/database/migrations/004_add_progress_fields.py
  • app/database/migrations/005_add_backup_speed.py
  • app/database/migrations/006_add_scheduled_jobs.py
  • app/database/migrations/007_add_size_progress_fields.py
  • app/database/migrations/008_add_scheduled_job_id_to_backups.py
  • app/database/migrations/009_add_restore_jobs.py
  • app/database/migrations/010_add_backup_hooks.py
  • app/database/migrations/011_add_must_change_password.py
  • app/database/migrations/012_add_prune_compact_to_scheduled_jobs.py
  • app/database/migrations/013_add_maintenance_status_to_backup_jobs.py
  • app/database/migrations/014_add_check_compact_jobs.py
  • app/database/migrations/015_add_last_check_compact_to_repositories.py
  • app/database/migrations/016_add_max_duration_to_check_jobs.py
  • app/database/migrations/017_add_pid_tracking.py
  • app/database/migrations/018_remove_config_file.py
  • app/database/migrations/019_add_archive_name_template.py
  • app/database/migrations/020_add_repository_mode.py
  • app/database/migrations/021_add_custom_flags.py
  • app/database/migrations/022_add_ssh_key_default_path.py
  • app/database/migrations/023_add_notify_on_backup_start.py
  • app/database/migrations/024_add_hourly_quarterly_prune.py
  • app/database/migrations/025_add_check_notifications_and_scheduling.py
  • app/database/migrations/027_add_script_library.py
  • app/database/migrations/028_split_hook_timeouts.py
  • app/database/migrations/029_add_continue_on_error.py
  • app/database/migrations/030_add_repository_path_to_check_compact_jobs.py
  • app/database/migrations/031_add_prune_jobs_table.py
  • app/database/migrations/032_add_storage_and_mount_to_ssh_connections.py
  • app/database/migrations/033_add_log_management_settings.py
  • app/database/migrations/034_add_scheduled_prune_field.py
  • app/database/migrations/034_convert_check_interval_to_cron.py
  • app/database/migrations/035_add_prune_job_log_fields.py
  • app/database/migrations/036_add_compact_job_log_fields.py
  • app/database/migrations/037_add_multi_repo_schedules.py
  • app/database/migrations/038_add_keyfile_support.py
  • app/database/migrations/039_add_repository_notification_filters.py
  • app/database/migrations/040_add_cache_settings.py
  • app/database/migrations/041_add_redis_url.py
  • app/database/migrations/043_add_check_job_log_fields.py
  • app/database/migrations/044_add_remote_backup_support.py
  • app/database/migrations/045_add_repository_connection_id.py
  • app/database/migrations/046_add_use_new_wizard_setting.py
  • app/database/migrations/047_add_browse_memory_limits.py
  • app/database/migrations/048_fix_ssh_connection_cascade.py
  • app/database/migrations/049_add_delete_archive_jobs.py
  • app/database/migrations/050_add_source_connection_to_repositories.py
  • app/database/migrations/051_add_analytics_enabled.py
  • app/database/migrations/052_add_analytics_consent_given.py
  • app/database/migrations/053_add_operation_timeouts.py
  • app/database/migrations/054_add_stats_refresh_interval.py
  • app/database/migrations/055_add_bypass_lock.py
  • app/database/migrations/056_add_last_stats_refresh.py
  • app/database/migrations/057_add_script_parameters.py
  • app/database/migrations/057_populate_connection_id_for_legacy_repos.py
  • app/database/migrations/058_add_notification_enhancements.py
  • app/database/migrations/059_add_use_sftp_mode_to_ssh_connections.py
  • app/database/migrations/060_add_speed_eta_to_restore_jobs.py
  • app/database/migrations/061_add_bypass_lock_on_info.py
  • app/database/migrations/062_add_unique_constraint_schedule_repo.py
  • app/database/migrations/063_add_schedule_script_parameters.py
  • app/database/migrations/064_add_show_restore_tab.py
  • app/database/migrations/065_cleanup_schedule_duplicates.py
  • app/database/migrations/066_add_ssh_path_prefix.py
  • app/database/migrations/067_add_cascade_delete_to_script_executions.py
  • app/database/migrations/068_add_remote_restore_fields.py
  • app/database/migrations/069_add_bypass_lock_on_list.py
  • app/database/migrations/070_add_source_size_timeout.py
  • app/database/migrations/071_add_mqtt_beta_enabled.py
  • app/database/migrations/072_add_mqtt_base_topic.py
  • app/database/migrations/073_fix_delete_archive_jobs_cascade.py
  • app/database/migrations/074_add_use_sudo_to_ssh_connections.py
  • app/database/migrations/074_remove_mqtt_base_topic.py
  • app/database/migrations/075_add_skip_on_failure.py
  • app/database/migrations/075_fix_schedule_script_id_set_null.py
  • app/database/migrations/076_fix_package_install_jobs_cascade.py
  • app/database/migrations/077_fix_script_executions_fk_actions.py
  • app/database/migrations/078_fix_scripts_created_by_user_fk.py
  • app/database/migrations/079_add_borg_version.py
  • app/database/migrations/080_add_plan.py
  • app/database/migrations/081_add_role_and_api_tokens.py
  • app/database/migrations/082_add_user_profile_fields.py
  • app/database/migrations/083_add_deployment_profile.py
  • app/database/migrations/084_add_user_repository_permissions.py
  • app/database/migrations/085_add_all_repositories_role.py
  • app/database/migrations/086_add_licensing_state.py
  • app/database/migrations/087_add_repository_inline_script_parameters.py
  • app/database/migrations/088_add_metrics_settings.py
  • app/database/migrations/089_add_backup_job_archive_name.py
  • app/database/migrations/090_add_borg2_fast_browse_beta_enabled.py
  • app/database/migrations/091_add_totp_to_users.py
  • app/database/migrations/092_add_passkey_credentials.py
  • app/database/migrations/093_add_backup_warning_notifications.py
  • app/database/migrations/094_add_scheduler_concurrency_limits.py
  • app/database/migrations/095_add_schedule_timezones.py
  • app/database/migrations/096_add_oidc_settings.py
  • app/database/migrations/097_add_oidc_state_and_auth_events.py
  • app/database/migrations/098_add_auth_rate_limit_buckets.py
  • app/database/migrations/099_harden_oidc_exchange_and_admin_mapping.py
  • app/database/migrations/100_add_oidc_linking_and_token_auth.py
  • app/database/migrations/101_add_restore_check_jobs.py
  • app/database/migrations/102_add_restore_check_canary_enabled.py
  • app/database/migrations/103_add_restore_check_notifications.py
  • app/database/migrations/104_add_agent_machines.py
  • app/database/migrations/104_add_backup_plans.py
  • app/database/migrations/105_add_agent_jobs.py
  • app/database/migrations/105_add_dashboard_health_threshold_settings.py
  • app/database/migrations/106_add_agent_repository_links.py
  • app/database/migrations/106_add_schedule_enabled_flags.py
  • app/database/migrations/107_add_check_extra_flags.py
  • app/database/migrations/107_add_source_locations.py
  • app/database/migrations/108_add_managed_agents_beta_enabled.py
  • app/database/migrations/109_add_repository_wipe_jobs.py
  • app/database/migrations/110_add_repository_executor_type.py
  • app/database/migrations/111_add_backup_job_route_strategy.py
  • app/database/migrations/111_add_backup_monitoring_reports.py
  • app/database/migrations/112_add_backup_report_delivery_schedule.py
  • app/database/migrations/112_agent_delete_and_nullable_enrollment_expiry.py
  • app/database/migrations/113_add_rclone_storage.py
  • app/database/migrations/114_add_scheduled_rclone_mirror_jobs.py
  • app/database/migrations/115_add_rclone_oauth_provider_credentials.py
  • app/database/migrations/116_add_agent_default_path.py
  • app/database/migrations/117_add_backup_plan_database_template_id.py
  • app/database/migrations/117_add_rclone_sync_job_operation.py
  • app/database/migrations/118_add_backup_plan_scripts.py
  • app/database/migrations/119_add_backup_retry_lineage.py
  • app/database/migrations/120_add_lock_breaking_enabled.py
  • app/database/migrations/121_add_rclone_oauth_provider_credentials_table.py
  • app/database/migrations/122_add_rclone_remote_storage.py
  • app/database/migrations/122_add_remote_backend_clients.py
  • app/database/migrations/123_add_repository_upload_ratelimit.py
  • app/database/migrations/124_add_backup_plan_upload_ratelimit_policies.py
  • app/database/migrations/125_add_prune_keep_within.py
  • app/database/migrations/126_add_agent_script_to_plan_hooks.py
  • app/database/migrations/126_clear_persisted_archive_browse_listings.py
  • app/database/migrations/127_add_agent_job_to_script_executions.py
  • app/database/migrations/__init__.py
  • app/database/models.py
  • app/database/url_utils.py
  • app/main.py
  • app/scripts/delete_ssh_key.py
  • app/scripts/deploy_ssh_key.py
  • app/scripts/reset_password.py
  • app/scripts/startup_packages.py
  • entrypoint.sh
  • requirements.txt
  • tests/fixtures/api.py
  • tests/unit/test_admin_db_scripts.py
  • tests/unit/test_api_backup_plans.py
  • tests/unit/test_api_rclone.py
  • tests/unit/test_config.py
  • tests/unit/test_database_operations.py
  • tests/unit/test_db_upgrade.py
  • tests/unit/test_db_upgrade_ensure_schema.py
  • tests/unit/test_dockerfile_database_url.py
  • tests/unit/test_main.py
  • tests/unit/test_startup_packages.py
  • tests/unit/test_url_utils.py
💤 Files with no reviewable changes (138)
  • app/database/migrations/019_add_archive_name_template.py
  • app/database/migrations/014_add_check_compact_jobs.py
  • app/database/migrations/108_add_managed_agents_beta_enabled.py
  • app/database/migrations/047_add_browse_memory_limits.py
  • app/database/migrations/006_add_scheduled_jobs.py
  • app/database/migrations/038_add_keyfile_support.py
  • app/database/migrations/086_add_licensing_state.py
  • app/database/migrations/079_add_borg_version.py
  • app/database/migrations/066_add_ssh_path_prefix.py
  • app/database/migrations/012_add_prune_compact_to_scheduled_jobs.py
  • app/database/migrations/005_add_backup_speed.py
  • app/database/migrations/083_add_deployment_profile.py
  • app/database/migrations/092_add_passkey_credentials.py
  • app/database/migrations/117_add_backup_plan_database_template_id.py
  • app/database/migrations/074_remove_mqtt_base_topic.py
  • app/database/migrations/095_add_schedule_timezones.py
  • app/database/migrations/010_add_backup_hooks.py
  • app/database/migrations/118_add_backup_plan_scripts.py
  • app/database/migrations/init.py
  • app/database/migrations/111_add_backup_monitoring_reports.py
  • app/database/migrations/109_add_repository_wipe_jobs.py
  • app/database/migrations/093_add_backup_warning_notifications.py
  • app/database/migrations/057_populate_connection_id_for_legacy_repos.py
  • app/database/migrations/024_add_hourly_quarterly_prune.py
  • app/database/migrations/094_add_scheduler_concurrency_limits.py
  • app/database/migrations/041_add_redis_url.py
  • app/database/migrations/061_add_bypass_lock_on_info.py
  • app/database/migrations/106_add_agent_repository_links.py
  • app/database/migrations/072_add_mqtt_base_topic.py
  • app/database/migrations/023_add_notify_on_backup_start.py
  • app/database/migrations/046_add_use_new_wizard_setting.py
  • app/database/migrations/055_add_bypass_lock.py
  • app/database/migrations/008_add_scheduled_job_id_to_backups.py
  • app/database/migrations/028_split_hook_timeouts.py
  • app/database/migrations/063_add_schedule_script_parameters.py
  • app/database/migrations/085_add_all_repositories_role.py
  • app/database/migrations/020_add_repository_mode.py
  • app/database/migrations/074_add_use_sudo_to_ssh_connections.py
  • app/database/migrations/082_add_user_profile_fields.py
  • app/database/migrations/075_add_skip_on_failure.py
  • app/database/migrations/106_add_schedule_enabled_flags.py
  • app/database/migrations/070_add_source_size_timeout.py
  • app/database/migrations/107_add_source_locations.py
  • app/database/migrations/018_remove_config_file.py
  • app/database/migrations/104_add_backup_plans.py
  • app/database/migrations/051_add_analytics_enabled.py
  • app/database/migrations/105_add_dashboard_health_threshold_settings.py
  • app/database/migrations/004_add_progress_fields.py
  • app/database/migrations/125_add_prune_keep_within.py
  • app/database/migrations/043_add_check_job_log_fields.py
  • app/database/migrations/013_add_maintenance_status_to_backup_jobs.py
  • app/database/migrations/007_add_size_progress_fields.py
  • app/database/migrations/048_fix_ssh_connection_cascade.py
  • app/database/migrations/049_add_delete_archive_jobs.py
  • app/database/migrations/060_add_speed_eta_to_restore_jobs.py
  • app/database/migrations/121_add_rclone_oauth_provider_credentials_table.py
  • app/database/migrations/054_add_stats_refresh_interval.py
  • app/database/migrations/045_add_repository_connection_id.py
  • app/database/migrations/100_add_oidc_linking_and_token_auth.py
  • app/database/migrations/103_add_restore_check_notifications.py
  • app/database/migrations/099_harden_oidc_exchange_and_admin_mapping.py
  • app/database/migrations/009_add_restore_jobs.py
  • app/database/migrations/062_add_unique_constraint_schedule_repo.py
  • app/database/migrations/021_add_custom_flags.py
  • app/database/migrations/031_add_prune_jobs_table.py
  • app/database/migrations/052_add_analytics_consent_given.py
  • app/database/migrations/017_add_pid_tracking.py
  • app/database/migrations/119_add_backup_retry_lineage.py
  • app/database/migrations/126_add_agent_script_to_plan_hooks.py
  • app/database/migrations/034_convert_check_interval_to_cron.py
  • app/database/migrations/111_add_backup_job_route_strategy.py
  • app/database/migrations/116_add_agent_default_path.py
  • app/database/migrations/076_fix_package_install_jobs_cascade.py
  • app/database/migrations/034_add_scheduled_prune_field.py
  • app/database/migrations/114_add_scheduled_rclone_mirror_jobs.py
  • app/database/migrations/065_cleanup_schedule_duplicates.py
  • app/database/migrations/033_add_log_management_settings.py
  • app/database/migrations/077_fix_script_executions_fk_actions.py
  • app/database/migrations/032_add_storage_and_mount_to_ssh_connections.py
  • app/database/migrations/027_add_script_library.py
  • app/database/migrations/112_add_backup_report_delivery_schedule.py
  • app/database/migrations/029_add_continue_on_error.py
  • app/database/migrations/124_add_backup_plan_upload_ratelimit_policies.py
  • app/database/migrations/050_add_source_connection_to_repositories.py
  • app/database/migrations/057_add_script_parameters.py
  • app/database/migrations/096_add_oidc_settings.py
  • app/database/migrations/069_add_bypass_lock_on_list.py
  • app/database/migrations/115_add_rclone_oauth_provider_credentials.py
  • app/database/migrations/039_add_repository_notification_filters.py
  • app/database/migrations/071_add_mqtt_beta_enabled.py
  • app/database/migrations/107_add_check_extra_flags.py
  • app/database/migrations/123_add_repository_upload_ratelimit.py
  • app/database/migrations/068_add_remote_restore_fields.py
  • app/database/migrations/089_add_backup_job_archive_name.py
  • app/database/migrations/090_add_borg2_fast_browse_beta_enabled.py
  • app/database/migrations/081_add_role_and_api_tokens.py
  • app/database/migrations/064_add_show_restore_tab.py
  • app/database/migrations/101_add_restore_check_jobs.py
  • app/database/migrations/073_fix_delete_archive_jobs_cascade.py
  • app/database/migrations/037_add_multi_repo_schedules.py
  • app/database/migrations/044_add_remote_backup_support.py
  • app/database/migrations/011_add_must_change_password.py
  • app/database/migrations/122_add_rclone_remote_storage.py
  • app/database/migrations/035_add_prune_job_log_fields.py
  • app/database/migrations/091_add_totp_to_users.py
  • app/database/migrations/102_add_restore_check_canary_enabled.py
  • app/database/migrations/056_add_last_stats_refresh.py
  • app/database/migrations/059_add_use_sftp_mode_to_ssh_connections.py
  • app/database/migrations/030_add_repository_path_to_check_compact_jobs.py
  • app/database/migrations/080_add_plan.py
  • app/database/migrations/113_add_rclone_storage.py
  • app/database/migrations/110_add_repository_executor_type.py
  • app/database/migrations/127_add_agent_job_to_script_executions.py
  • app/database/migrations/036_add_compact_job_log_fields.py
  • app/database/migrations/040_add_cache_settings.py
  • app/database/migrations/122_add_remote_backend_clients.py
  • app/database/migrations/087_add_repository_inline_script_parameters.py
  • app/database/migrations/015_add_last_check_compact_to_repositories.py
  • app/database/migrations/016_add_max_duration_to_check_jobs.py
  • app/database/migrations/075_fix_schedule_script_id_set_null.py
  • app/database/migrations/104_add_agent_machines.py
  • app/database/migrations/067_add_cascade_delete_to_script_executions.py
  • app/database/migrations/105_add_agent_jobs.py
  • app/database/migrations/053_add_operation_timeouts.py
  • app/database/migrations/025_add_check_notifications_and_scheduling.py
  • app/database/migrations/126_clear_persisted_archive_browse_listings.py
  • app/database/migrations/078_fix_scripts_created_by_user_fk.py
  • app/database/migrations/097_add_oidc_state_and_auth_events.py
  • app/database/migrations/084_add_user_repository_permissions.py
  • app/database/migrations/120_add_lock_breaking_enabled.py
  • app/database/migrations/022_add_ssh_key_default_path.py
  • app/database/migrations/058_add_notification_enhancements.py
  • app/database/migrations/117_add_rclone_sync_job_operation.py
  • app/database/migrations/112_agent_delete_and_nullable_enrollment_expiry.py
  • app/database/migrations/088_add_metrics_settings.py
  • app/database/migrations/098_add_auth_rate_limit_buckets.py
  • tests/unit/test_database_operations.py
  • tests/unit/test_api_rclone.py
🚧 Files skipped from review as they are similar to previous changes (25)
  • alembic.ini
  • tests/unit/test_dockerfile_database_url.py
  • app/database/url_utils.py
  • Dockerfile
  • app/database/alembic/script.py.mako
  • requirements.txt
  • app/database/database.py
  • app/api/scripts_library.py
  • app/api/backup_plans.py
  • app/database/alembic/env.py
  • entrypoint.sh
  • app/database/alembic/README.md
  • tests/fixtures/api.py
  • app/scripts/deploy_ssh_key.py
  • app/main.py
  • app/scripts/delete_ssh_key.py
  • app/config.py
  • app/scripts/reset_password.py
  • tests/unit/test_db_upgrade_ensure_schema.py
  • tests/unit/test_startup_packages.py
  • app/database/alembic/versions/f6c46c665fd3_baseline_schema.py
  • tests/unit/test_db_upgrade.py
  • app/scripts/startup_packages.py
  • app/database/models.py
  • app/database/db_upgrade.py

Comment thread tests/unit/test_main.py Outdated
@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from 756ab75 to 0dbb9c7 Compare July 24, 2026 07:11
@ioanalytica

Copy link
Copy Markdown
Contributor Author

Addressed the test-isolation finding: the three startup unit tests (mqtt, cache, licensing) now patch app.database.db_upgrade.ensure_schema, so startup_event() no longer runs real schema initialization against the configured database. The dedicated test that asserts ensure_schema runs before the session is unaffected (it patches it deliberately).

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

@ioanalytica: Thanks—this directly isolates the three startup tests from real schema initialization while retaining the focused call-order regression test. I’ll review the update.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ioanalytica

Copy link
Copy Markdown
Contributor Author

Heads-up on the red Frontend Security (npm audit) check: it is not introduced by this PR — this branch touches zero frontend files (package.json / package-lock.json diff against main is empty). The gate (npm audit --omit=dev --audit-level=moderate) fails on main and every open PR alike, from three moderate react-router advisories (GHSA-wrjc-x8rr-h8h6, GHSA-h8fp-f39c-q6mh, GHSA-337j-9hxr-rhxg) published after the dependency was pinned.

Rather than pull an unrelated dependency bump into this Alembic/Postgres PR, I've opened #776 to fix it repo-wide — a single minor bump react-router-dom@^7.18.1 that clears the gate (verified: audit exits 0, full frontend suite green). Once #776 lands, this check goes green here too.

@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from c4685cc to 0debf13 Compare July 24, 2026 11:11
@ioanalytica

Copy link
Copy Markdown
Contributor Author

Thanks for merging #776 and syncing this branch! I've replaced the Merge branch 'main' commit with a rebase of our series onto current main (now including #776), so the PR keeps a clean linear history — it's easier to review commit-by-commit, to bisect, and to carry in our downstream integration branch without merge bubbles.

The code state is byte-for-byte identical to the merge — I verified the resulting tree hashes match exactly (git diff between the two tips is empty). The only change is history shape, not content. The Frontend Security (npm audit) gate should now be green here too, since #776 is in the base.

(force-pushed to our fork branch; nothing else changed.)

SQLite's LIKE is case-insensitive by default, Postgres' is not. The script
library search therefore returned no matches on Postgres for any query whose
case did not match the stored name. The backup-plan name lookup is a prefilter
compared exactly in Python afterwards, so its result is unaffected either way;
it is converted for consistency, keeping "no raw LIKE" a greppable rule.
…mbic

models.py described a schema no install runs: create_all alone vs create_all +
migrations x2 differed in 44 semantic ways (the migration-built schema
reproduces a live install with 0 diffs, so the model is what was wrong).

- declares the 28 indexes the migrations create and the model never mentioned,
  plus ON DELETE SET NULL on scheduled_jobs.pre/post_backup_script_id and a
  func.now() server_default on scheduled_job_repositories.created_at
- AUTOINCREMENT on every single-integer-PK table (46 of 48), derived from the PK
  so a new model cannot forget it; verified alembic autogenerate renders it
- a naming_convention on the metadata so every constraint has a deliberate name
  (SQLite cannot ALTER an unnamed constraint; batch migrations need the names)

Left as deliberate repairs: 8 columns no model class has, and a duplicate index.
Changes nothing for existing installs (create_all skips existing tables); it
only shapes what the Alembic baseline is cut from.
The baseline is generated from the reconciled models.py: 48 tables, 131 indexes,
77 named foreign keys, verified to produce a schema identical to create_all's,
and to apply unchanged to both SQLite and Postgres 16 (alembic check reports no
drift against either). It lives under app/ because the image only copies app/;
env.py takes the URL from settings and renders every migration as batch so one
body serves SQLite (table rebuild) and Postgres (plain ALTER) alike.

alembic_init(sqlite_db, postgres_conn=None) is the one-time upgrade: both
databases exist at once, rows are copied by column name, the source is renamed
to *_bak as the rollback. Postgres is just a different URL on the same path.
Self-references transfer in two passes, tables go in topological order (Postgres
checks every FK at insert), sequences are advanced past the data (SQLite derives
the next id itself; a SQLite-only test can never catch a missing setval, so the
unit tests run against a real Postgres in CI). The transfer engine enforces
foreign keys on both dialects, so the SQLite target cannot accept what Postgres
rejects. Verified against a real 98k-row database into both dialects.
…tions

Startup replayed all 135 migrations on every boot and built the schema with
create_all at import time. Both are gone. The entrypoint brings the database to
the alembic head once, before the app is imported (gunicorn forks workers that
would race over the same database), as the user that will own the file, and
fails the boot instead of starting on a database in an unknown state.

The legacy migrations are deleted: nothing reaches them any more, and the
baseline was cut from the schema they actually produced. With them goes their
whole class of problem -- a schema that depended on restart count, migrations
hardcoding a primary-key form the model never asked for, and one silently
switching foreign-key enforcement off for the life of the process.

The upgrade target is built with synchronous=OFF: it is disposable until the
swap, so durability while building it buys nothing, and over NFS the fsync per
CREATE INDEX was the whole cost. The live database keeps FULL. Progress is
logged to stdout (schema build, per-table row counts, alembic's own output) so
kubectl logs shows a migration running instead of a silent pod.
The server can store its own database in Postgres instead of the SQLite file.
resolve_database_url assembles the DSN from DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/
DB_NAME (quoting the password, which a generated one needs), DATABASE_URL still
wins where set, and DB_HOST without credentials fails loudly rather than
silently running on SQLite. The image no longer bakes a DATABASE_URL default,
which would otherwise always win over DB_HOST; the app derives the SQLite URL
from DATA_DIR itself.

Boot is decided by the alembic stamp: once Postgres is at head it IS the
database, whatever SQLite file sits in the data dir (a kept rollback, or one an
entrypoint script recreated empty) -- so a restart never stalls on it. The
database is upgraded before the SSH-key deploy that reads from it, and
deploy_ssh_key goes through the ORM so it follows DATABASE_URL to Postgres and
creates no stray SQLite file.

Verified end to end on a real cluster: SQLite migrates into Postgres, the app
reports the postgres dialect and reads the transferred rows.
The startup script opened /data/borg.db with raw sqlite3 and wrote back
with SQLite-only SQL (datetime('now')). With an external database there
is no such file: package auto-install silently never ran, and any stray
empty borg.db turned every boot into a "no such table:
installed_packages" traceback. Route the script through the
application's own engine instead. A fresh SQLite install still skips
without creating the database file, and a present-but-empty database
degrades to "no packages" rather than an error.
reset_password and delete_ssh_key opened /data/borg.db with raw sqlite3,
so on installs using DATABASE_URL they silently targeted the wrong (or a
nonexistent) database. Same treatment as the startup packages script:
route them through the application's engine. reset_password keeps
BORG_DB_PATH as a legacy SQLite override; both refuse to run against a
missing SQLite file instead of implicitly creating one, and the
delete_ssh_key mutation now runs in a single transaction.
deploy_ssh_key needed nothing - the Postgres series already moved it to
the ORM.
The Settings Config carried a v1-style fields={...} block mapping CORS_ORIGINS /
TRUSTED_PROXIES / OIDC_ALLOWED_RETURN_ORIGINS onto private fields. pydantic v2
removed that config key: it warned on every settings import and ignored the
mapping, so those env vars already had no effect (verified). Removing it
silences the warning and changes nothing at runtime; a comment records that
wiring them back up needs Field(validation_alias=) on public fields.
Moving schema creation into the container entrypoint left the application
unable to prepare its own database. Started any other way — `uvicorn
app.main:app`, a development run, a test harness — it came up against an empty
database and failed every request that read from it, which is what the smoke
tests found: authentication returned 500, because there was no users table.

Startup now calls ensure_schema() before anything opens a session. It is cheap
when there is nothing to do — one query for the recorded revision — and builds
the baseline when the database is empty.

A database that predates Alembic is deliberately left untouched, with a log
line naming the tool. Migrating one copies every row and can change where the
database lives; that is a one-time operation with a rollback, not something to
do behind a server that is already coming up.

Three tests cover ensure_schema itself and one covers the call, since the
regression was not a broken function but a missing invocation: without it the
new test fails with "Expected 'ensure_schema' to have been called once".
The prefix query in _unique_backup_plan_name was converted from LIKE to ILIKE
so both dialects behave alike — SQLite's LIKE is already case-insensitive,
Postgres' is not. Review asked whether that leaves the following comparisons
inconsistent, since they are exact.

It does not, and these tests say why: plan names are unique case-sensitively
(`name = Column(String, unique=True)`), so a name differing only in case is
genuinely free and suffixing it would be wrong. Widening the candidate set
cannot change an answer decided by exact comparison.

Four cases: a case variant is free, an exact collision is suffixed, suffixes
skip what exists, and a case variant does not consume a suffix.
ensure_schema() runs per process, so with more than one worker two of them can
find an empty database and both start building it. One wins; the other used to
take its worker down with "table already exists".

The loser now waits for the winner to finish before deciding, because a
database mid-build is not yet at head and a single check would call a won race
lost. Only a build that is still absent after that surfaces — a failure with a
real cause, a full disk say, is not swallowed.

The entrypoint still builds the schema once before gunicorn forks, so workers
normally find it already there. This is for the starts that do not go through
it, and for --workers > 1 later.
@ioanalytica
ioanalytica force-pushed the feat/alembic-and-postgres branch from 0debf13 to 4a2eac6 Compare July 24, 2026 14:23
…losing data

The one-time upgrade copies rows by column name; it does not re-run the old
migrations. That is safe for schema drift, but some old migrations transform data
and drop the source column -- e.g. 034 converts check_interval_days into a cron
expression and drops it. A database that skipped those has the data in the old
shape, and a structural copy would drop the column and silently lose it.

Guard against it: before copying, refuse a source that still carries a
data-transform's dropped column, naming what would be lost and pointing at the
supported path (upgrade to a 2.2.5 release first, which runs the full migration
chain). A curated sentinel list keeps this to the destructive cases; the report
still lists every dropped column for the rest. This touches no migration and no
already-migrated database -- it only turns a silent, version-skipping data loss
into an actionable error.
@ioanalytica ioanalytica changed the title Replace the hand-rolled migration runner with Alembic, and allow Postgres feat(db) Replace the hand-rolled migration runner with Alembic, and allow Postgres Jul 29, 2026
Copying rows by column name cannot reproduce the data transforms old migrations
performed (an interval became a cron expression, a timeout was split), so copying
a pre-2.2.5 database straight onto the baseline would silently drop that data.

Keep the 135 pre-Alembic migrations as a frozen catch-up ladder and run them,
once, on a throwaway copy of a legacy database before its rows are copied. Any
supported version now upgrades directly to the latest -- and into Postgres in the
same step -- with no manual intermediate release. The source is never touched; it
stays as the rollback. The old guard is kept as a safety net: if the lenient
ladder leaves a required transform undone, the upgrade refuses rather than lose
data.

Add the durable fork the long coexistence needs: a database that already has
alembic_version is only ever moved forward by Alembic, never dragged through the
legacy ladder -- the one correct behaviour once a second Alembic revision exists.
…ransform

A faithful "database at version N" cannot be reconstructed: the pre-Alembic
startup built its schema with create_all before running migrations, so a
historical schema is not recoverable from the current models alone. Cover the
real risk surface instead.

- Run the whole 135-migration ladder against a representative schema and assert
  none raises -- the automated twin of running the upgrade against a real 197 MB
  production database, which reproduced it with zero row loss.
- Prove the 028 hook-timeout split is backfilled from a pre-028 source. An audit
  of every migration that drops a column found 034 (interval->cron) is the only
  transform that moves data into a column the baseline dropped, so it stays the
  sole sentinel; 028 keeps its source column, so that data survives regardless.
@ioanalytica

Copy link
Copy Markdown
Contributor Author

Update addressing your feedback on the intermediate-version step.

The upgrade no longer refuses a pre-2.2.5 database and asks the operator to stop on an intermediate release. db_upgrade now keeps the 135 pre-Alembic migrations as a frozen catch-up ladder and runs them, once, on a throwaway copy of a legacy database before copying its rows onto the baseline — so any supported version upgrades directly to the latest, with no manual stop on an intermediate release (SQLite stays the default; the same run lands in Postgres instead only if DATABASE_URL opts in). The old guard stays only as a safety net (refuses, source untouched, if the ladder can't complete a required transform).

It also adds the durable fork the long coexistence needs: once a database has alembic_version it is moved forward by Alembic alone and never dragged through the ladder — correct the moment a second revision exists.

Validated end-to-end against a real 197 MB production database (227k rows → transferred, zero row loss). Key commits: 9680389d (catch-up + durable fork), ec1d4eeb (version-axis tests).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant