Files
notify-bridge/packages/server/src/notify_bridge_server/database/migrations.py
T
alexei.dolgolyov 10d30fc956 feat: production readiness — security, perf, bug fixes, bridge self-monitoring
Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.

## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
  event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
  acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
  featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession

## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
  cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch

## Database
- UNIQUE indexes on service_provider.webhook_token,
  telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
  telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
  partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
  delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified

## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
  identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
  on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events

## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
  oauth/client_secret/webhook_secret/csrf in both header filter and
  template extras filter

## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
  $effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
  now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
  goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
  Telegram bot toggles

## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
  (wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
  HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
  event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
  procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
  test_planka_parser (6), test_immich_change_detector (6),
  test_backup_roundtrip (1)

## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
  failures), bridge_self_deferred_backlog (pending count crosses
  threshold), bridge_self_target_failures (consecutive 5xx/network
  failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
  provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
  thresholds (logged only) — wire to your own Telegram/Email/Matrix to
  get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
  with backfill migration, frontend descriptor (excluded from "create
  provider" wizard since auto-managed)

Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
  receive failure alerts

Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:16:49 +03:00

1881 lines
86 KiB
Python

"""Data migrations for schema changes.
Handles converting legacy JSON-array relationships to proper junction tables,
and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
"""
import json
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IDENT_RE = __import__("re").compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _assert_ident(ident: str, kind: str = "identifier") -> str:
"""Guard against SQL injection in dynamically interpolated identifiers.
All table/column names flow through here before being embedded into f-strings,
so attacker-controlled values cannot break out even if they reach this layer.
"""
if not isinstance(ident, str) or not _IDENT_RE.match(ident):
raise ValueError(f"Unsafe {kind}: {ident!r}")
return ident
async def _has_column(conn, table: str, column: str) -> bool:
"""Check if a column exists in a SQLite table."""
_assert_ident(table, "table")
cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text(f"PRAGMA table_info('{table}')")
).fetchall()
]
)
return column in cols
async def _has_table(conn, table: str) -> bool:
"""Check if a table exists in the SQLite database."""
result = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name=:name"
),
{"name": table},
).fetchone()
)
return result is not None
# ---------------------------------------------------------------------------
# Legacy schema migrations (pre-Phase 1)
# ---------------------------------------------------------------------------
async def migrate_schema(engine: AsyncEngine) -> None:
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
async with engine.begin() as conn:
# --- Tracker table (may still be named "tracker" or already renamed) ---
tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker"
if await _has_table(conn, tracker_table):
# NULL default = adaptive polling disabled for existing trackers.
# Operators who want the old back-off behavior can set a positive
# value per tracker from the UI.
if not await _has_column(conn, tracker_table, "adaptive_max_skip"):
await conn.execute(
text(f"ALTER TABLE {tracker_table} ADD COLUMN adaptive_max_skip INTEGER")
)
logger.info("Added adaptive_max_skip column to %s table", tracker_table)
# Add enriched fields to event_log if missing
if await _has_table(conn, "event_log"):
for col, sql in [
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
("command_tracker_id", "ALTER TABLE event_log ADD COLUMN command_tracker_id INTEGER"),
("command_tracker_name", "ALTER TABLE event_log ADD COLUMN command_tracker_name TEXT DEFAULT ''"),
("telegram_bot_id", "ALTER TABLE event_log ADD COLUMN telegram_bot_id INTEGER"),
("bot_name", "ALTER TABLE event_log ADD COLUMN bot_name TEXT DEFAULT ''"),
]:
if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
# Explicit indexes on the dashboard-query columns. SQLModel's
# ``index=True`` is emitted by ``create_all`` on *new* installs,
# but ALTER TABLE ADD COLUMN doesn't create them on upgrades —
# so the first boot after upgrade would leave these unindexed
# and status.py ``WHERE user_id=...`` would table-scan. The
# indexes are redundant-but-safe once create_all also runs.
for idx_name, col in [
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
("ix_event_log_command_tracker_id", "command_tracker_id"),
("ix_event_log_telegram_bot_id", "telegram_bot_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
)
# Backfill user_id from notification_tracker for legacy rows.
# Safe to run repeatedly: only touches rows where user_id is still NULL.
await conn.execute(text("""
UPDATE event_log
SET user_id = (
SELECT user_id FROM notification_tracker
WHERE notification_tracker.id = event_log.notification_tracker_id
)
WHERE event_log.user_id IS NULL
AND event_log.notification_tracker_id IS NOT NULL
"""))
# Add commands_config to telegram_bot if missing
if await _has_table(conn, "telegram_bot"):
if not await _has_column(conn, "telegram_bot", "commands_config"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
)
logger.info("Added commands_config column to telegram_bot table")
# Add webhook_path_id to telegram_bot if missing
if not await _has_column(conn, "telegram_bot", "webhook_path_id"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
)
logger.info("Added webhook_path_id column to telegram_bot table")
# Backfill existing bots with unique IDs
import uuid
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
for bot in bots:
await conn.execute(
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
{"wid": uuid.uuid4().hex, "bid": bot[0]},
)
if bots:
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
# Add update_mode to telegram_bot if missing.
# Existing bots pre-date this feature and were implicitly polling;
# preserve that behavior. New bots default to "none" via the
# SQLModel field default on fresh schemas.
if not await _has_column(conn, "telegram_bot", "update_mode"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
)
logger.info("Added update_mode column to telegram_bot table")
# Add command_template_config_id to command_config if missing
if await _has_table(conn, "command_config"):
if not await _has_column(conn, "command_config", "command_template_config_id"):
await conn.execute(
text("ALTER TABLE command_config ADD COLUMN command_template_config_id INTEGER")
)
logger.info("Added command_template_config_id column to command_config table")
# Add allowed_album_ids (per-chat album scope) to command_tracker_listener
if await _has_table(conn, "command_tracker_listener"):
if not await _has_column(conn, "command_tracker_listener", "allowed_album_ids"):
await conn.execute(
text("ALTER TABLE command_tracker_listener ADD COLUMN allowed_album_ids TEXT")
)
logger.info("Added allowed_album_ids column to command_tracker_listener table")
# Add date_only_format to template_config if missing
if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"):
await conn.execute(
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
)
logger.info("Added date_only_format column to template_config table")
# Add memory_source to tracking_config if missing
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "memory_source"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
)
logger.info("Added memory_source column to tracking_config table")
# Add filters JSON column to notification_tracker if missing
if await _has_table(conn, tracker_table):
if not await _has_column(conn, tracker_table, "filters"):
await conn.execute(
text(f"ALTER TABLE {tracker_table} ADD COLUMN filters TEXT DEFAULT '{{}}'")
)
logger.info("Added filters column to %s table", tracker_table)
# Drop legacy batch_duration column from notification_tracker.
# The field was removed from the SQLModel class but the column still
# exists as NOT NULL in older DBs, so INSERTs from the new code fail
# with "NOT NULL constraint failed: notification_tracker.batch_duration".
if await _has_table(conn, tracker_table):
if await _has_column(conn, tracker_table, "batch_duration"):
_assert_ident(tracker_table, "table")
await conn.execute(
text(f"ALTER TABLE {tracker_table} DROP COLUMN batch_duration")
)
logger.info(
"Dropped legacy batch_duration column from %s table",
tracker_table,
)
# Add Gitea tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
gitea_flags = [
("track_push", "INTEGER DEFAULT 1"),
("track_issue_opened", "INTEGER DEFAULT 1"),
("track_issue_closed", "INTEGER DEFAULT 1"),
("track_issue_commented", "INTEGER DEFAULT 0"),
("track_pr_opened", "INTEGER DEFAULT 1"),
("track_pr_closed", "INTEGER DEFAULT 1"),
("track_pr_merged", "INTEGER DEFAULT 1"),
("track_pr_commented", "INTEGER DEFAULT 0"),
("track_release_published", "INTEGER DEFAULT 1"),
("track_scheduled_message", "INTEGER DEFAULT 1"),
]
for col_name, col_type in gitea_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add Planka tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
planka_flags = [
("track_card_created", "INTEGER DEFAULT 1"),
("track_card_updated", "INTEGER DEFAULT 0"),
("track_card_moved", "INTEGER DEFAULT 1"),
("track_card_deleted", "INTEGER DEFAULT 0"),
("track_card_commented", "INTEGER DEFAULT 1"),
("track_comment_updated", "INTEGER DEFAULT 0"),
("track_board_created", "INTEGER DEFAULT 1"),
("track_board_updated", "INTEGER DEFAULT 0"),
("track_board_deleted", "INTEGER DEFAULT 1"),
("track_list_created", "INTEGER DEFAULT 0"),
("track_list_updated", "INTEGER DEFAULT 0"),
("track_list_deleted", "INTEGER DEFAULT 0"),
("track_attachment_created", "INTEGER DEFAULT 1"),
("track_card_label_added", "INTEGER DEFAULT 0"),
("track_task_completed", "INTEGER DEFAULT 1"),
]
for col_name, col_type in planka_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add NUT (UPS) tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
nut_flags = [
("track_ups_online", "INTEGER DEFAULT 1"),
("track_ups_on_battery", "INTEGER DEFAULT 1"),
("track_ups_low_battery", "INTEGER DEFAULT 1"),
("track_ups_battery_restored", "INTEGER DEFAULT 1"),
("track_ups_comms_lost", "INTEGER DEFAULT 1"),
("track_ups_comms_restored", "INTEGER DEFAULT 1"),
("track_ups_replace_battery", "INTEGER DEFAULT 1"),
("track_ups_overload", "INTEGER DEFAULT 1"),
]
for col_name, col_type in nut_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add Generic Webhook tracking flag to tracking_config if missing
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "track_webhook_received"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN track_webhook_received INTEGER DEFAULT 1")
)
logger.info("Added track_webhook_received column to tracking_config table")
# Add Home Assistant tracking flags to tracking_config if missing.
# state_changed defaults ON to match the canonical "watch the state bus"
# use case; the other three are loud and opt-in (defaults 0).
if await _has_table(conn, "tracking_config"):
ha_flags = [
("track_ha_state_changed", "INTEGER DEFAULT 1"),
("track_ha_automation_triggered", "INTEGER DEFAULT 0"),
("track_ha_service_called", "INTEGER DEFAULT 0"),
("track_ha_event_fired", "INTEGER DEFAULT 0"),
]
for col_name, col_type in ha_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add Bridge self-monitoring tracking flags to tracking_config if missing.
# All three default ON — the bridge_self provider exists specifically
# to surface these conditions, so silencing one would defeat the point.
if await _has_table(conn, "tracking_config"):
bridge_self_flags = [
("track_bridge_self_poll_failures", "INTEGER DEFAULT 1"),
("track_bridge_self_deferred_backlog", "INTEGER DEFAULT 1"),
("track_bridge_self_target_failures", "INTEGER DEFAULT 1"),
]
for col_name, col_type in bridge_self_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add quiet hours to tracking_config if missing.
# Start/end are nullable HH:MM strings; quiet_hours_enabled gates them.
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "quiet_hours_enabled"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN quiet_hours_enabled INTEGER DEFAULT 0")
)
logger.info("Added quiet_hours_enabled column to tracking_config table")
for col_name in ("quiet_hours_start", "quiet_hours_end"):
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} TEXT")
)
logger.info("Added %s column to tracking_config table", col_name)
# Drop legacy template content columns from template_config
# (template content moved to template_slot child rows)
if await _has_table(conn, "template_config"):
legacy_cols = [
"message_assets_added", "message_assets_removed",
"message_collection_renamed", "message_collection_deleted",
"message_sharing_changed", "periodic_summary_message",
"scheduled_assets_message", "memory_mode_message",
]
for col_name in legacy_cols:
if await _has_column(conn, "template_config", col_name):
await conn.execute(
text(f"ALTER TABLE template_config DROP COLUMN {col_name}")
)
logger.info("Dropped legacy column %s from template_config", col_name)
# Add collection_name and shared to tracker_state if missing
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
if await _has_table(conn, state_table):
if not await _has_column(conn, state_table, "collection_name"):
await conn.execute(
text(f"ALTER TABLE {state_table} ADD COLUMN collection_name TEXT DEFAULT ''")
)
logger.info("Added collection_name column to %s table", state_table)
if not await _has_column(conn, state_table, "shared"):
await conn.execute(
text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0")
)
logger.info("Added shared column to %s table", state_table)
# meta_fingerprint — small JSON blob captured from the provider's
# cheap meta probe. An empty default means "unknown, do a full
# fetch next tick" so existing rows don't wrongly skip detection.
if not await _has_column(conn, state_table, "meta_fingerprint"):
await conn.execute(
text(f"ALTER TABLE {state_table} ADD COLUMN meta_fingerprint TEXT DEFAULT '{{}}'")
)
logger.info("Added meta_fingerprint column to %s table", state_table)
# Add language_code to telegram_chat if missing
if await _has_table(conn, "telegram_chat"):
if not await _has_column(conn, "telegram_chat", "language_code"):
await conn.execute(
text("ALTER TABLE telegram_chat ADD COLUMN language_code TEXT DEFAULT ''")
)
logger.info("Added language_code column to telegram_chat table")
# Add language_override to telegram_chat if missing
if not await _has_column(conn, "telegram_chat", "language_override"):
await conn.execute(
text("ALTER TABLE telegram_chat ADD COLUMN language_override TEXT DEFAULT ''")
)
logger.info("Added language_override column to telegram_chat table")
# Add locale to target_receiver if missing
if await _has_table(conn, "target_receiver"):
if not await _has_column(conn, "target_receiver", "locale"):
await conn.execute(
text("ALTER TABLE target_receiver ADD COLUMN locale TEXT DEFAULT ''")
)
logger.info("Added locale column to target_receiver table")
# Add commands_enabled to telegram_chat if missing (default disabled)
if not await _has_column(conn, "telegram_chat", "commands_enabled"):
await conn.execute(
text("ALTER TABLE telegram_chat ADD COLUMN commands_enabled INTEGER DEFAULT 0")
)
logger.info("Added commands_enabled column to telegram_chat table")
# Add webhook_token to service_provider if missing
if await _has_table(conn, "service_provider"):
if not await _has_column(conn, "service_provider", "webhook_token"):
await conn.execute(
text("ALTER TABLE service_provider ADD COLUMN webhook_token TEXT DEFAULT ''")
)
logger.info("Added webhook_token column to service_provider table")
# Backfill existing providers with unique tokens
import uuid
providers = (await conn.execute(text("SELECT id FROM service_provider"))).fetchall()
for row in providers:
await conn.execute(
text("UPDATE service_provider SET webhook_token = :tok WHERE id = :pid"),
{"tok": uuid.uuid4().hex, "pid": row[0]},
)
if providers:
logger.info("Backfilled webhook_token for %d existing providers", len(providers))
# ---------------------------------------------------------------------------
# Legacy tracker_target migration (pre-Phase 1)
# ---------------------------------------------------------------------------
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
Also migrates:
- Tracker.tracking_config_id → TrackerTarget.tracking_config_id
- Tracker.quiet_hours_* → TrackerTarget.quiet_hours_*
- NotificationTarget.template_config_id → TrackerTarget.template_config_id
- TelegramBot.commands_config → TrackerTarget.commands_config (for telegram targets)
Idempotent: skips if legacy columns don't exist or data already migrated.
"""
async with engine.begin() as conn:
# Determine which table name exists (pre- or post-rename)
if await _has_table(conn, "tracker"):
tracker_table = "tracker"
tt_table = "tracker_target"
tracker_id_col = "tracker_id"
elif await _has_table(conn, "notification_tracker"):
tracker_table = "notification_tracker"
tt_table = "notification_tracker_target"
tracker_id_col = "notification_tracker_id"
else:
logger.debug("No tracker table found — skipping migration")
return
# Check if legacy target_ids column exists
if not await _has_column(conn, tracker_table, "target_ids"):
logger.debug("No legacy target_ids column found — skipping migration")
return
# Check if junction table already has data
if await _has_table(conn, tt_table):
tt_count = (
await conn.execute(text(f"SELECT COUNT(*) FROM {tt_table}"))
).scalar()
if tt_count and tt_count > 0:
logger.debug(
"%s table already has %d rows — skipping migration",
tt_table, tt_count,
)
return
# Load legacy data
trackers = (
await conn.execute(
text(
f"SELECT id, target_ids, tracking_config_id, "
f"quiet_hours_start, quiet_hours_end FROM {tracker_table}"
)
)
).fetchall()
if not trackers:
logger.debug("No trackers to migrate")
return
# Load template_config_id from targets (legacy field)
target_template_map: dict[int, int | None] = {}
if await _has_column(conn, "notification_target", "template_config_id"):
targets = (
await conn.execute(
text("SELECT id, template_config_id FROM notification_target")
)
).fetchall()
for t in targets:
target_template_map[t[0]] = t[1]
# Load commands_config from telegram_bots (legacy field)
bot_commands_map: dict[int, str | None] = {}
if await _has_column(conn, "telegram_bot", "commands_config"):
bots = (
await conn.execute(
text("SELECT id, commands_config FROM telegram_bot")
)
).fetchall()
for b in bots:
bot_commands_map[b[0]] = b[1]
# Build target → bot mapping for commands_config migration
target_bot_map: dict[int, int] = {}
if bot_commands_map:
tgt_rows = (
await conn.execute(
text("SELECT id, config FROM notification_target WHERE type='telegram'")
)
).fetchall()
for tgt in tgt_rows:
try:
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
if cfg and "bot_token" in cfg:
for bot_id, _ in bot_commands_map.items():
bot_token_row = (
await conn.execute(
text("SELECT token FROM telegram_bot WHERE id=:bid"),
{"bid": bot_id},
)
).fetchone()
if bot_token_row and bot_token_row[0] == cfg.get("bot_token"):
target_bot_map[tgt[0]] = bot_id
except Exception:
logger.warning(
"Failed to match bot token for target %s", tgt[0],
exc_info=True,
)
# Create junction rows
migrated = 0
for tracker in trackers:
tracker_id = tracker[0]
raw_target_ids = tracker[1]
tracking_config_id = tracker[2]
quiet_hours_start = tracker[3]
quiet_hours_end = tracker[4]
if isinstance(raw_target_ids, str):
try:
target_ids = json.loads(raw_target_ids)
except (json.JSONDecodeError, TypeError):
target_ids = []
elif isinstance(raw_target_ids, list):
target_ids = raw_target_ids
else:
target_ids = []
for target_id in target_ids:
template_config_id = target_template_map.get(target_id)
commands_config = None
if target_id in target_bot_map:
bot_id = target_bot_map[target_id]
raw_cmd = bot_commands_map.get(bot_id)
if raw_cmd:
commands_config = (
raw_cmd if isinstance(raw_cmd, str) else json.dumps(raw_cmd)
)
await conn.execute(
text(
f"INSERT INTO {tt_table} "
f"({tracker_id_col}, target_id, tracking_config_id, "
f"template_config_id, enabled, quiet_hours_start, "
f"quiet_hours_end, commands_config) "
f"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
),
{
"tid": tracker_id,
"tgtid": target_id,
"tcid": tracking_config_id,
"tmplid": template_config_id,
"qhs": quiet_hours_start,
"qhe": quiet_hours_end,
"cmd": commands_config,
},
)
migrated += 1
logger.info("Migrated %d tracker-target links", migrated)
# ---------------------------------------------------------------------------
# Phase 1: Entity refactor migration
# ---------------------------------------------------------------------------
async def migrate_entity_refactor(engine: AsyncEngine) -> None:
"""Phase 1 entity refactor — rename tables, add columns, create new tables.
Fully idempotent: every operation checks preconditions before acting.
"""
async with engine.begin() as conn:
# ------------------------------------------------------------------
# 1. Rename table: tracker → notification_tracker
# ------------------------------------------------------------------
if await _has_table(conn, "tracker") and not await _has_table(conn, "notification_tracker"):
await conn.execute(text("ALTER TABLE tracker RENAME TO notification_tracker"))
logger.info("Renamed table tracker → notification_tracker")
# ------------------------------------------------------------------
# 2. Rename table: tracker_target → notification_tracker_target
# and rename column tracker_id → notification_tracker_id
# ------------------------------------------------------------------
if await _has_table(conn, "tracker_target") and not await _has_table(conn, "notification_tracker_target"):
# SQLite doesn't support RENAME COLUMN in older versions, so we
# recreate the table with the new column name.
await conn.execute(text(
"CREATE TABLE notification_tracker_target ("
" id INTEGER PRIMARY KEY,"
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
" target_id INTEGER REFERENCES notification_target(id),"
" tracking_config_id INTEGER REFERENCES tracking_config(id),"
" template_config_id INTEGER REFERENCES template_config(id),"
" enabled INTEGER DEFAULT 1,"
" quiet_hours_start TEXT,"
" quiet_hours_end TEXT,"
" commands_config TEXT,"
" created_at TIMESTAMP"
")"
))
await conn.execute(text(
"INSERT INTO notification_tracker_target "
"(id, notification_tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
"commands_config, created_at) "
"SELECT id, tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
"commands_config, created_at "
"FROM tracker_target"
))
await conn.execute(text("DROP TABLE tracker_target"))
logger.info("Renamed table tracker_target → notification_tracker_target (with column rename tracker_id → notification_tracker_id)")
# ------------------------------------------------------------------
# 3. Rename table: tracker_state → notification_tracker_state
# and rename column tracker_id → notification_tracker_id
# ------------------------------------------------------------------
if await _has_table(conn, "tracker_state") and not await _has_table(conn, "notification_tracker_state"):
await conn.execute(text(
"CREATE TABLE notification_tracker_state ("
" id INTEGER PRIMARY KEY,"
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
" collection_id TEXT,"
" collection_name TEXT DEFAULT '',"
" shared INTEGER DEFAULT 0,"
" asset_ids TEXT,"
" pending_asset_ids TEXT,"
" last_updated TIMESTAMP"
")"
))
await conn.execute(text(
"INSERT INTO notification_tracker_state "
"(id, notification_tracker_id, collection_id, collection_name, "
"shared, asset_ids, pending_asset_ids, last_updated) "
"SELECT id, tracker_id, collection_id, collection_name, "
"shared, asset_ids, pending_asset_ids, last_updated "
"FROM tracker_state"
))
await conn.execute(text("DROP TABLE tracker_state"))
logger.info("Renamed table tracker_state → notification_tracker_state (with column rename tracker_id → notification_tracker_id)")
# ------------------------------------------------------------------
# 4. Add chat_action column to notification_target
# ------------------------------------------------------------------
if await _has_table(conn, "notification_target"):
if not await _has_column(conn, "notification_target", "chat_action"):
await conn.execute(
text("ALTER TABLE notification_target ADD COLUMN chat_action TEXT")
)
logger.info("Added chat_action column to notification_target table")
# ------------------------------------------------------------------
# 5. Rename tracker_id → notification_tracker_id in event_log
# ------------------------------------------------------------------
if await _has_table(conn, "event_log"):
if await _has_column(conn, "event_log", "tracker_id") and not await _has_column(conn, "event_log", "notification_tracker_id"):
# Recreate event_log with renamed column
await conn.execute(text(
"CREATE TABLE event_log_new ("
" id INTEGER PRIMARY KEY,"
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
" tracker_name TEXT DEFAULT '',"
" provider_id INTEGER,"
" provider_name TEXT DEFAULT '',"
" event_type TEXT,"
" collection_id TEXT,"
" collection_name TEXT,"
" assets_count INTEGER DEFAULT 0,"
" details TEXT,"
" created_at TIMESTAMP"
")"
))
await conn.execute(text(
"INSERT INTO event_log_new "
"(id, notification_tracker_id, tracker_name, provider_id, "
"provider_name, event_type, collection_id, collection_name, "
"assets_count, details, created_at) "
"SELECT id, tracker_id, tracker_name, provider_id, "
"provider_name, event_type, collection_id, collection_name, "
"assets_count, details, created_at "
"FROM event_log"
))
await conn.execute(text("DROP TABLE event_log"))
await conn.execute(text("ALTER TABLE event_log_new RENAME TO event_log"))
logger.info("Renamed column tracker_id → notification_tracker_id in event_log")
# ------------------------------------------------------------------
# 6. Create command_config table
# ------------------------------------------------------------------
if not await _has_table(conn, "command_config"):
await conn.execute(text(
"CREATE TABLE command_config ("
" id INTEGER PRIMARY KEY,"
" user_id INTEGER NOT NULL REFERENCES user(id),"
" provider_type TEXT NOT NULL,"
" name TEXT NOT NULL,"
" icon TEXT DEFAULT '',"
" enabled_commands TEXT DEFAULT '[]',"
" locale TEXT DEFAULT 'en',"
" response_mode TEXT DEFAULT 'media',"
" default_count INTEGER DEFAULT 5,"
" rate_limits TEXT DEFAULT '{}',"
" created_at TIMESTAMP"
")"
))
logger.info("Created command_config table")
else:
# Backfill locale column for tables created before locale was on the model
if not await _has_column(conn, "command_config", "locale"):
await conn.execute(
text("ALTER TABLE command_config ADD COLUMN locale TEXT DEFAULT 'en'")
)
logger.info("Added locale column to command_config table")
# ------------------------------------------------------------------
# 7. Create command_tracker table
# ------------------------------------------------------------------
if not await _has_table(conn, "command_tracker"):
await conn.execute(text(
"CREATE TABLE command_tracker ("
" id INTEGER PRIMARY KEY,"
" user_id INTEGER NOT NULL REFERENCES user(id),"
" provider_id INTEGER NOT NULL REFERENCES service_provider(id),"
" command_config_id INTEGER NOT NULL REFERENCES command_config(id),"
" name TEXT NOT NULL,"
" icon TEXT DEFAULT '',"
" enabled INTEGER DEFAULT 1,"
" created_at TIMESTAMP"
")"
))
logger.info("Created command_tracker table")
# ------------------------------------------------------------------
# 8. Create command_tracker_listener table
# ------------------------------------------------------------------
if not await _has_table(conn, "command_tracker_listener"):
await conn.execute(text(
"CREATE TABLE command_tracker_listener ("
" id INTEGER PRIMARY KEY,"
" command_tracker_id INTEGER NOT NULL REFERENCES command_tracker(id),"
" listener_type TEXT NOT NULL,"
" listener_id INTEGER NOT NULL,"
" created_at TIMESTAMP,"
" UNIQUE(command_tracker_id, listener_type, listener_id)"
")"
))
logger.info("Created command_tracker_listener table")
# ------------------------------------------------------------------
# 9. Migrate TelegramBot.commands_config → CommandConfig rows
# ------------------------------------------------------------------
if await _has_table(conn, "telegram_bot") and await _has_column(conn, "telegram_bot", "commands_config"):
# Only migrate if command_config table is empty (idempotent)
cc_count = (await conn.execute(text("SELECT COUNT(*) FROM command_config"))).scalar()
if cc_count == 0:
bots = (await conn.execute(text(
"SELECT id, user_id, commands_config FROM telegram_bot"
))).fetchall()
migrated = 0
for bot in bots:
bot_id, user_id, raw_config = bot[0], bot[1], bot[2]
if not raw_config:
continue
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else raw_config
except (json.JSONDecodeError, TypeError):
continue
# Skip empty/default configs
if not cfg or cfg == {}:
continue
# Extract fields from legacy commands_config
enabled_commands = json.dumps(cfg.get("enabled_commands", []))
locale = cfg.get("locale", "en")
response_mode = cfg.get("response_mode", "media")
default_count = cfg.get("default_count", 5)
rate_limits = json.dumps(cfg.get("rate_limits", {}))
provider_type = cfg.get("provider_type", "immich")
await conn.execute(
text(
"INSERT INTO command_config "
"(user_id, provider_type, name, enabled_commands, locale, "
"response_mode, default_count, rate_limits, created_at) "
"VALUES (:uid, :pt, :name, :ec, :locale, :rm, :dc, :rl, CURRENT_TIMESTAMP)"
),
{
"uid": user_id,
"pt": provider_type,
"name": f"Bot #{bot_id} Commands",
"ec": enabled_commands,
"locale": locale,
"rm": response_mode,
"dc": default_count,
"rl": rate_limits,
},
)
migrated += 1
if migrated:
logger.info("Migrated %d bot commands_config → command_config rows", migrated)
# NOTE: We intentionally do NOT drop commands_config from telegram_bot
# or notification_tracker_target. SQLite doesn't support DROP COLUMN in
# all versions, and SQLModel will simply ignore columns not defined on
# the model class. The columns will remain in the DB but are unused.
# ---------------------------------------------------------------------------
# Template slot migration
# ---------------------------------------------------------------------------
# Old column names that existed on template_config before the slot refactor
_LEGACY_TEMPLATE_COLUMNS = [
"message_assets_added",
"message_assets_removed",
"message_collection_renamed",
"message_collection_deleted",
"message_sharing_changed",
"periodic_summary_message",
"scheduled_assets_message",
"memory_mode_message",
]
async def migrate_template_slots(engine: AsyncEngine) -> None:
"""Migrate legacy TemplateConfig column-based templates to TemplateSlot rows.
Reads the old per-column template values via raw SQL (since they're no longer
on the SQLModel class) and inserts them as TemplateSlot rows.
Idempotent: skips if template_slot table already has data or legacy columns
don't exist.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "template_config"):
return
# Check if the legacy columns still exist in the DB
has_legacy = await _has_column(conn, "template_config", "message_assets_added")
if not has_legacy:
logger.debug("No legacy template columns found — skipping slot migration")
return
# Check if template_slot table exists and already has data
if await _has_table(conn, "template_slot"):
slot_count = (await conn.execute(text("SELECT COUNT(*) FROM template_slot"))).scalar()
if slot_count and slot_count > 0:
logger.debug("template_slot table already has %d rows — skipping migration", slot_count)
return
# Create template_slot table if it doesn't exist yet
# (SQLModel.metadata.create_all may have already created it, but be safe)
if not await _has_table(conn, "template_slot"):
await conn.execute(text(
"CREATE TABLE template_slot ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES template_config(id),"
" slot_name TEXT NOT NULL,"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name)"
")"
))
logger.info("Created template_slot table")
# Read all template configs with their legacy column values
col_list = ", ".join(_LEGACY_TEMPLATE_COLUMNS)
rows = (await conn.execute(
text(f"SELECT id, {col_list} FROM template_config")
)).fetchall()
migrated = 0
for row in rows:
config_id = row[0]
for i, col_name in enumerate(_LEGACY_TEMPLATE_COLUMNS):
template_text = row[i + 1] or ""
if template_text.strip():
await conn.execute(
text(
"INSERT INTO template_slot (config_id, slot_name, template) "
"VALUES (:cid, :sn, :tmpl)"
),
{"cid": config_id, "sn": col_name, "tmpl": template_text},
)
migrated += 1
if migrated:
logger.info("Migrated %d template slots from legacy columns", migrated)
# ---------------------------------------------------------------------------
# Target receiver migration
# ---------------------------------------------------------------------------
async def migrate_target_receivers(engine: AsyncEngine) -> None:
"""Migrate single chat_id/url from NotificationTarget.config to TargetReceiver rows.
For each existing target that has a chat_id or url in its config JSON and
no receivers yet, creates a TargetReceiver row.
Idempotent: skips targets that already have receivers.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
# Create target_receiver table if it doesn't exist yet
if not await _has_table(conn, "target_receiver"):
await conn.execute(text(
"CREATE TABLE target_receiver ("
" id INTEGER PRIMARY KEY,"
" target_id INTEGER NOT NULL REFERENCES notification_target(id),"
" name TEXT DEFAULT '',"
" config TEXT DEFAULT '{}',"
" receiver_key TEXT DEFAULT '',"
" enabled INTEGER DEFAULT 1,"
" created_at TIMESTAMP,"
" UNIQUE(target_id, receiver_key)"
")"
))
logger.info("Created target_receiver table")
# Check if any receivers already exist
if await _has_table(conn, "target_receiver"):
recv_count = (await conn.execute(text("SELECT COUNT(*) FROM target_receiver"))).scalar()
if recv_count and recv_count > 0:
logger.debug("target_receiver already has %d rows — skipping migration", recv_count)
return
# Read all targets
targets = (await conn.execute(
text("SELECT id, type, config FROM notification_target")
)).fetchall()
migrated = 0
for row in targets:
target_id, target_type, raw_config = row[0], row[1], row[2]
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
except (json.JSONDecodeError, TypeError):
cfg = {}
receiver_key = ""
receiver_config = {}
receiver_name = ""
if target_type == "telegram":
chat_id = cfg.get("chat_id", "")
if chat_id:
receiver_key = str(chat_id)
receiver_config = {"chat_id": str(chat_id)}
receiver_name = f"Chat {chat_id}"
elif target_type == "webhook":
url = cfg.get("url", "")
if url:
receiver_key = url
receiver_config = {"url": url, "headers": cfg.get("headers", {})}
receiver_name = url[:50]
if receiver_key:
await conn.execute(
text(
"INSERT INTO target_receiver (target_id, name, config, receiver_key, enabled, created_at) "
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
),
{
"tid": target_id,
"name": receiver_name,
"cfg": json.dumps(receiver_config),
"rk": receiver_key,
},
)
migrated += 1
if migrated:
logger.info("Migrated %d target receivers from legacy config", migrated)
async def migrate_receivers_from_config(engine: AsyncEngine) -> None:
"""Extract delivery endpoint fields from target.config into TargetReceiver rows.
For each NotificationTarget that still has a delivery field (chat_id, url,
webhook_url, email, topic, room_id) in its config JSON:
1. Create a TargetReceiver row (if one with the same key doesn't exist)
2. Remove the delivery field(s) from the config JSON
Idempotent: checks for existing receiver before creating; only strips fields
that are still present in config.
"""
# Mapping: target_type -> (delivery field in config, receiver config builder)
_DELIVERY_FIELDS: dict[str, dict[str, str]] = {
"telegram": {"chat_id": "chat_id"},
"webhook": {"url": "url"},
"email": {"email": "email"},
"discord": {"webhook_url": "webhook_url"},
"slack": {"webhook_url": "webhook_url"},
"ntfy": {"topic": "topic"},
"matrix": {"room_id": "room_id"},
}
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_table(conn, "target_receiver"):
return
targets = (await conn.execute(
text("SELECT id, type, config FROM notification_target")
)).fetchall()
created = 0
cleaned = 0
for row in targets:
target_id, target_type, raw_config = row[0], row[1], row[2]
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
except (json.JSONDecodeError, TypeError):
cfg = {}
field_map = _DELIVERY_FIELDS.get(target_type, {})
if not field_map:
continue
# Check if any delivery field is present in config
delivery_field = list(field_map.keys())[0] # e.g. "chat_id", "url"
delivery_value = cfg.get(delivery_field)
if not delivery_value:
continue
# Build receiver config
receiver_config: dict[str, Any] = {delivery_field: delivery_value}
# For webhook, also move headers to receiver config
if target_type == "webhook" and "headers" in cfg:
receiver_config["headers"] = cfg["headers"]
receiver_key = str(delivery_value)
# Check if receiver already exists
existing = (await conn.execute(
text(
"SELECT id FROM target_receiver "
"WHERE target_id = :tid AND receiver_key = :rk"
),
{"tid": target_id, "rk": receiver_key},
)).fetchone()
if not existing:
# Derive a name for the receiver
if target_type == "telegram":
name = f"Chat {delivery_value}"
elif target_type == "webhook":
name = str(delivery_value)[:50]
elif target_type == "email":
name = str(delivery_value)
else:
name = str(delivery_value)[:50]
await conn.execute(
text(
"INSERT INTO target_receiver "
"(target_id, name, config, receiver_key, enabled, created_at) "
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
),
{
"tid": target_id,
"name": name,
"cfg": json.dumps(receiver_config),
"rk": receiver_key,
},
)
created += 1
# Remove delivery fields from config
new_cfg = dict(cfg)
new_cfg.pop(delivery_field, None)
# For webhook, also remove headers (moved to receiver)
if target_type == "webhook":
new_cfg.pop("headers", None)
if new_cfg != cfg:
await conn.execute(
text(
"UPDATE notification_target SET config = :cfg WHERE id = :tid"
),
{"cfg": json.dumps(new_cfg), "tid": target_id},
)
cleaned += 1
if created:
logger.info("Created %d receiver rows from target config delivery fields", created)
if cleaned:
logger.info("Cleaned delivery fields from %d target configs", cleaned)
async def migrate_template_locale(engine: AsyncEngine) -> None:
"""Add locale column to template_config and command_template_config.
Backfill locale from name: "(RU)" -> "ru", else "en" for system-owned rows.
"""
async with engine.begin() as conn:
for table in ("template_config", "command_template_config"):
if await _has_column(conn, table, "locale"):
continue
logger.info("Adding locale column to %s", table)
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN locale TEXT DEFAULT ''"))
# Backfill system-owned rows
await conn.execute(text(
f"UPDATE {table} SET locale = 'ru' WHERE user_id = 0 AND name LIKE '%(RU)%'"
))
await conn.execute(text(
f"UPDATE {table} SET locale = 'en' WHERE user_id = 0 AND locale = ''"
))
async def migrate_command_slot_locale(engine: AsyncEngine) -> None:
"""Add locale column to command_template_slot and merge system EN/RU configs.
1. Recreate command_template_slot with locale column and new unique constraint
2. Backfill locale from parent config's locale (or 'en')
3. Merge "Default Commands (RU)" slots into "Default Commands (EN)" with locale='ru'
4. Rename merged config, update references, delete orphan RU config
"""
async with engine.begin() as conn:
if not await _has_table(conn, "command_template_slot"):
return
# Skip if locale column already exists (idempotent)
if await _has_column(conn, "command_template_slot", "locale"):
return
logger.info("Adding locale column to command_template_slot and merging system configs")
# Step 1: Recreate table with locale column and new unique constraint
await conn.execute(text(
"CREATE TABLE command_template_slot_new ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES command_template_config(id),"
" slot_name TEXT NOT NULL,"
" locale TEXT NOT NULL DEFAULT 'en',"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name, locale)"
")"
))
# Step 2: Copy existing data, deriving locale from parent config
await conn.execute(text(
"INSERT INTO command_template_slot_new (id, config_id, slot_name, locale, template) "
"SELECT s.id, s.config_id, s.slot_name, "
" CASE WHEN c.locale != '' THEN c.locale ELSE 'en' END, "
" s.template "
"FROM command_template_slot s "
"LEFT JOIN command_template_config c ON s.config_id = c.id"
))
await conn.execute(text("DROP TABLE command_template_slot"))
await conn.execute(text(
"ALTER TABLE command_template_slot_new RENAME TO command_template_slot"
))
# Step 3: Merge system EN/RU configs into one
# Find the system EN and RU config IDs
en_row = (await conn.execute(text(
"SELECT id FROM command_template_config "
"WHERE user_id = 0 AND (locale = 'en' OR name LIKE '%(EN)%') "
"LIMIT 1"
))).fetchone()
ru_row = (await conn.execute(text(
"SELECT id FROM command_template_config "
"WHERE user_id = 0 AND (locale = 'ru' OR name LIKE '%(RU)%') "
"LIMIT 1"
))).fetchone()
if en_row and ru_row and en_row[0] != ru_row[0]:
en_id, ru_id = en_row[0], ru_row[0]
# Move RU slots to the EN config (they already have locale='ru')
await conn.execute(text(
"UPDATE command_template_slot SET config_id = :en_id "
"WHERE config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Update any command_config references from RU to EN
if await _has_table(conn, "command_config"):
await conn.execute(text(
"UPDATE command_config SET command_template_config_id = :en_id "
"WHERE command_template_config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Delete the orphan RU config
await conn.execute(text(
"DELETE FROM command_template_config WHERE id = :ru_id"
), {"ru_id": ru_id})
# Rename the merged config
await conn.execute(text(
"UPDATE command_template_config SET name = 'Default Commands', "
"description = 'Default Immich command templates', locale = '' "
"WHERE id = :en_id"
), {"en_id": en_id})
logger.info(
"Merged system command template configs (EN=%d, RU=%d) into single config %d",
en_id, ru_id, en_id,
)
async def migrate_notification_slot_locale(engine: AsyncEngine) -> None:
"""Add locale column to template_slot and merge system EN/RU configs per provider.
1. Recreate template_slot with locale column and new unique constraint
2. Backfill locale from parent config's locale (or 'en')
3. For each provider: merge "Default X (RU)" slots into "Default X (EN)" with locale='ru'
4. Rename merged configs, update references, delete orphan RU configs
"""
async with engine.begin() as conn:
if not await _has_table(conn, "template_slot"):
return
# Skip if locale column already exists (idempotent)
if await _has_column(conn, "template_slot", "locale"):
return
logger.info("Adding locale column to template_slot and merging system configs")
# Step 1: Recreate table with locale column and new unique constraint
await conn.execute(text(
"CREATE TABLE template_slot_new ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES template_config(id),"
" slot_name TEXT NOT NULL,"
" locale TEXT NOT NULL DEFAULT 'en',"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name, locale)"
")"
))
# Step 2: Copy existing data, deriving locale from parent config
await conn.execute(text(
"INSERT INTO template_slot_new (id, config_id, slot_name, locale, template) "
"SELECT s.id, s.config_id, s.slot_name, "
" CASE WHEN c.locale != '' THEN c.locale ELSE 'en' END, "
" s.template "
"FROM template_slot s "
"LEFT JOIN template_config c ON s.config_id = c.id"
))
await conn.execute(text("DROP TABLE template_slot"))
await conn.execute(text(
"ALTER TABLE template_slot_new RENAME TO template_slot"
))
# Step 3: Merge system EN/RU configs per provider type
providers = (await conn.execute(text(
"SELECT DISTINCT provider_type FROM template_config WHERE user_id = 0"
))).fetchall()
for (provider_type,) in providers:
en_row = (await conn.execute(text(
"SELECT id FROM template_config "
"WHERE user_id = 0 AND provider_type = :pt "
" AND (locale = 'en' OR name LIKE '%(EN)%') "
"LIMIT 1"
), {"pt": provider_type})).fetchone()
ru_row = (await conn.execute(text(
"SELECT id FROM template_config "
"WHERE user_id = 0 AND provider_type = :pt "
" AND (locale = 'ru' OR name LIKE '%(RU)%') "
"LIMIT 1"
), {"pt": provider_type})).fetchone()
if en_row and ru_row and en_row[0] != ru_row[0]:
en_id, ru_id = en_row[0], ru_row[0]
# Move RU slots to the EN config (they already have locale='ru')
await conn.execute(text(
"UPDATE template_slot SET config_id = :en_id "
"WHERE config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Update notification_tracker_target references from RU to EN
if await _has_table(conn, "notification_tracker_target"):
await conn.execute(text(
"UPDATE notification_tracker_target SET template_config_id = :en_id "
"WHERE template_config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Delete the orphan RU config
await conn.execute(text(
"DELETE FROM template_config WHERE id = :ru_id"
), {"ru_id": ru_id})
# Rename the merged config (strip locale suffix)
label = provider_type.capitalize()
await conn.execute(text(
"UPDATE template_config SET name = :name, "
"description = :desc, locale = '' "
"WHERE id = :en_id"
), {"name": f"Default {label}", "desc": f"Default {label} templates", "en_id": en_id})
logger.info(
"Merged system notification template configs for %s (EN=%d, RU=%d) into %d",
provider_type, en_id, ru_id, en_id,
)
async def migrate_user_token_version(engine: AsyncEngine) -> None:
"""Add token_version column to user for JWT revocation on password change."""
async with engine.begin() as conn:
if not await _has_table(conn, "user"):
return
if not await _has_column(conn, "user", "token_version"):
await conn.execute(
text("ALTER TABLE user ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1")
)
logger.info("Added token_version column to user table")
# ---------------------------------------------------------------------------
# Performance indexes — covers every FK / owner column the list endpoints
# and the webhook hot-path filter on. All use CREATE INDEX IF NOT EXISTS so
# they are safe to re-run on every boot.
# ---------------------------------------------------------------------------
_INDEXES: list[tuple[str, str, str]] = [
# (index_name, table, columns)
("ix_service_provider_user_id", "service_provider", "user_id"),
("ix_telegram_bot_user_id", "telegram_bot", "user_id"),
("ix_matrix_bot_user_id", "matrix_bot", "user_id"),
("ix_email_bot_user_id", "email_bot", "user_id"),
("ix_telegram_chat_bot_id", "telegram_chat", "bot_id"),
("ix_tracking_config_user_id", "tracking_config", "user_id"),
("ix_tracking_config_provider_type", "tracking_config", "provider_type"),
("ix_notification_target_user_id", "notification_target", "user_id"),
("ix_notification_target_type", "notification_target", "type"),
("ix_notification_tracker_user_id", "notification_tracker", "user_id"),
("ix_notification_tracker_provider_id", "notification_tracker", "provider_id"),
# Composite for the webhook hot path: WHERE provider_id = ? AND enabled = true
(
"ix_notification_tracker_provider_enabled",
"notification_tracker",
"provider_id, enabled",
),
("ix_command_config_user_id", "command_config", "user_id"),
("ix_command_template_config_user_id", "command_template_config", "user_id"),
("ix_command_tracker_user_id", "command_tracker", "user_id"),
("ix_command_tracker_provider_id", "command_tracker", "provider_id"),
("ix_action_user_id", "action", "user_id"),
("ix_action_provider_id", "action", "provider_id"),
# Dashboard: SELECT event_log WHERE user_id = ? ORDER BY created_at DESC
("ix_event_log_user_created", "event_log", "user_id, created_at DESC"),
# Dashboard "events of type X for me, recent first" filter.
(
"ix_event_log_user_event_type_created",
"event_log",
"user_id, event_type, created_at DESC",
),
("ix_event_log_provider_id", "event_log", "provider_id"),
("ix_event_log_notification_tracker_id", "event_log", "notification_tracker_id"),
("ix_event_log_action_id", "event_log", "action_id"),
# Webhook log hot path: WHERE provider_id = ? ORDER BY created_at DESC
(
"ix_webhook_payload_log_provider_created",
"webhook_payload_log",
"provider_id, created_at DESC",
),
# Notification tracker join tables
(
"ix_notification_tracker_target_notification_tracker_id",
"notification_tracker_target",
"notification_tracker_id",
),
(
"ix_notification_tracker_target_target_id",
"notification_tracker_target",
"target_id",
),
("ix_target_receiver_target_id", "target_receiver", "target_id"),
("ix_template_slot_config_id", "template_slot", "config_id"),
("ix_command_template_slot_config_id", "command_template_slot", "config_id"),
("ix_action_rule_action_id", "action_rule", "action_id"),
("ix_action_execution_action_started", "action_execution", "action_id, started_at DESC"),
# Deferred-dispatch drain: WHERE status = 'pending' AND fire_at <= ?
# ORDER BY fire_at. The composite (status, fire_at) is the only access
# pattern; an individual fire_at index isn't needed.
("ix_deferred_dispatch_status_fire_at", "deferred_dispatch", "status, fire_at"),
("ix_deferred_dispatch_link_id", "deferred_dispatch", "link_id"),
("ix_deferred_dispatch_event_log_id", "deferred_dispatch", "event_log_id"),
]
async def migrate_performance_indexes(engine: AsyncEngine) -> None:
"""Create missing performance indexes on hot query paths.
Every index is created with IF NOT EXISTS so the migration is safe to
replay on every boot. We only create the index when the table exists —
early boots before other migrations land would otherwise raise.
"""
async with engine.begin() as conn:
for name, table, columns in _INDEXES:
_assert_ident(name, "index")
_assert_ident(table, "table")
# Columns list is a trusted literal constructed above — never user input.
if not await _has_table(conn, table):
continue
try:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({columns})")
)
except Exception: # pragma: no cover — log and continue
logger.warning(
"Failed to create index %s on %s(%s)",
name, table, columns, exc_info=True,
)
async def migrate_deferred_dispatch_event_log_fk(engine: AsyncEngine) -> None:
"""Rebuild ``deferred_dispatch`` if its event_log FK lacks ON DELETE SET NULL.
Early builds of this feature created the table with a default ``NO ACTION``
FK on ``event_log_id``. The daily event_log cleanup deletes rows past the
retention horizon — with SQLite's enforced foreign_keys PRAGMA, a pending
DeferredDispatch row pointing at an aging-out event_log row would block
the cleanup with an FK violation.
SQLite can't ALTER a constraint without rebuilding the table. The table
has zero rows in any prod install old enough to need this fix (the
feature shipped in the same release as this migration), so a drop +
recreate via ``create_all`` is safe.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
# Read the original CREATE TABLE SQL to see whether SET NULL is wired.
row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type='table' AND name='deferred_dispatch'"
)
).fetchone()
)
ddl = (row[0] or "") if row else ""
if "ON DELETE SET NULL" in ddl.upper():
return
# Confirm there's nothing to migrate — refuse to drop a populated
# table even though the schema was wrong. Better to leave a warning
# than to lose state.
count_row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text("SELECT COUNT(*) FROM deferred_dispatch")
).fetchone()
)
if count_row and count_row[0]:
logger.warning(
"deferred_dispatch FK is missing ON DELETE SET NULL but the "
"table holds %d rows; not auto-dropping. Inspect manually.",
count_row[0],
)
return
await conn.execute(text("DROP TABLE deferred_dispatch"))
logger.info(
"Dropped deferred_dispatch (empty) so create_all rebuilds it "
"with ON DELETE SET NULL on event_log_id",
)
# Recreate the table from the SQLModel metadata in this same txn.
from sqlmodel import SQLModel
# Ensure the model is registered on metadata before we ask create_all
# to build it. Lazy import to avoid a circular at module load time.
from .models import DeferredDispatch # noqa: F401
await conn.run_sync(
SQLModel.metadata.create_all,
tables=[SQLModel.metadata.tables["deferred_dispatch"]],
)
async def migrate_deferred_dispatch_unique_pending(engine: AsyncEngine) -> None:
"""Add a partial unique index preventing duplicate pending defers.
Without this, two webhook handlers (or a webhook racing the watcher)
can both call ``_find_pending_asset_rows`` and find nothing, then both
INSERT — defeating coalescing. The partial index makes the second
INSERT raise ``IntegrityError`` and the caller's transaction abort,
after which a retry will see the now-visible row.
SQLite has supported ``CREATE UNIQUE INDEX ... WHERE ...`` since 3.8.
Once the table exists this is safe to run on every boot.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
try:
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"ux_deferred_dispatch_pending "
"ON deferred_dispatch(link_id, collection_id, event_type) "
"WHERE status = 'pending'"
))
except Exception: # pragma: no cover — log and continue
logger.warning(
"Failed to create partial unique index on deferred_dispatch",
exc_info=True,
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
Earlier versions of the frontend stored ``chat_action`` inside
``notification_target.config``; the dedicated ``chat_action`` column
was rarely set or held a stale default. The dispatcher's resolver
overrode the config value with the (stale) column, so a user's UI
choice silently had no effect on outgoing chat actions.
This backfill takes the config value as authoritative (it's what the
UI was writing) and copies it to the column, then strips it from
config so the column becomes the single source of truth. Idempotent:
a second run finds nothing to migrate.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_column(conn, "notification_target", "chat_action"):
return
# Copy config["chat_action"] → column where present.
await conn.execute(text(
"UPDATE notification_target "
"SET chat_action = json_extract(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
# Strip the legacy key so the column is unambiguous going forward.
await conn.execute(text(
"UPDATE notification_target "
"SET config = json_remove(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
logger.info("Migrated chat_action from config JSON to column where present")
# ---------------------------------------------------------------------------
# Uniqueness + dedupe migrations for webhook hot paths.
#
# These backfill missing UNIQUE indexes on webhook tokens, webhook path IDs,
# bot_id (with sentinel guard), (bot_id, chat_id), and tracker-target links.
# Every CREATE UNIQUE INDEX is preceded by a dedupe pass that keeps the
# canonical row (lowest id, or oldest created_at where specified) and removes
# the rest, logging a WARNING with the dropped count so operators can audit.
# ---------------------------------------------------------------------------
async def _dedupe_by_columns(
conn,
table: str,
cols: list[str],
*,
keep: str = "min_id",
label: str = "",
) -> int:
"""Delete duplicate rows leaving one survivor per ``cols`` group.
``keep`` chooses the survivor:
- ``"min_id"`` keeps the row with the lowest ``id`` (default — used
when there is no semantic "first" row to preserve).
- ``"min_created_at"`` keeps the row with the oldest ``created_at``,
falling back to the lowest id on ties — preferred for tracker-target
links so the original link wins.
Returns the number of rows deleted. All identifiers flow through
``_assert_ident`` to neutralise SQL injection from any caller mistake.
"""
_assert_ident(table, "table")
for c in cols:
_assert_ident(c, "column")
group_by = ", ".join(cols)
where_cols = " AND ".join(f"{c} = g.{c}" for c in cols)
if keep == "min_created_at":
# Tie-break on id so the survivor is deterministic even if two rows
# share the same created_at (insert-batches commonly do).
survivor_sql = (
f"SELECT id FROM {table} "
f"WHERE {where_cols} "
f"ORDER BY created_at ASC, id ASC LIMIT 1"
)
elif keep == "min_id":
survivor_sql = f"SELECT MIN(id) FROM {table} WHERE {where_cols}"
else:
raise ValueError(f"Unknown keep strategy: {keep!r}")
delete_sql = (
f"DELETE FROM {table} WHERE id IN ("
f" SELECT t.id FROM {table} t "
f" JOIN ("
f" SELECT {group_by} FROM {table} "
f" GROUP BY {group_by} HAVING COUNT(*) > 1"
f" ) g ON {' AND '.join(f't.{c} = g.{c}' for c in cols)} "
f" WHERE t.id NOT IN ({survivor_sql})"
f")"
)
result = await conn.execute(text(delete_sql))
deleted = int(getattr(result, "rowcount", 0) or 0)
if deleted:
logger.warning(
"Removed %d duplicate row(s) from %s on (%s)%s",
deleted, table, ", ".join(cols),
f" — {label}" if label else "",
)
return deleted
async def migrate_uniqueness_constraints(engine: AsyncEngine) -> None:
"""Backfill missing UNIQUE indexes on webhook hot paths.
SQLite cannot ALTER an existing column to add a UNIQUE constraint, but
a UNIQUE INDEX is functionally equivalent and can be created with
``IF NOT EXISTS`` on every boot. Each index is preceded by a dedupe
pass so the index creation does not fail on existing duplicates.
Indexes added:
- service_provider.webhook_token (full unique)
- telegram_bot.webhook_path_id (full unique)
- telegram_bot.bot_id (partial unique WHERE bot_id != 0; 0 is a
sentinel meaning "not yet validated")
- telegram_chat (bot_id, chat_id) (full unique composite)
- notification_tracker_target (notification_tracker_id, target_id)
(full unique composite)
"""
# Skip on non-SQLite engines — they enforce UNIQUE via the model
# metadata (create_all) and don't have sqlite_master introspection.
if not str(engine.url).startswith("sqlite"):
return
async with engine.begin() as conn:
# service_provider.webhook_token
if await _has_table(conn, "service_provider") and await _has_column(
conn, "service_provider", "webhook_token",
):
await _dedupe_by_columns(
conn, "service_provider", ["webhook_token"],
keep="min_id", label="webhook_token uniqueness",
)
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"uq_service_provider_webhook_token "
"ON service_provider(webhook_token)"
))
# telegram_bot.webhook_path_id (full unique)
# telegram_bot.bot_id (partial unique excluding sentinel 0)
if await _has_table(conn, "telegram_bot"):
if await _has_column(conn, "telegram_bot", "webhook_path_id"):
await _dedupe_by_columns(
conn, "telegram_bot", ["webhook_path_id"],
keep="min_id", label="webhook_path_id uniqueness",
)
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"uq_telegram_bot_webhook_path_id "
"ON telegram_bot(webhook_path_id)"
))
if await _has_column(conn, "telegram_bot", "bot_id"):
# Dedupe only non-sentinel rows. Two unverified bots both
# carrying bot_id=0 is legitimate — only collisions among
# validated bot_ids signal a real corruption to clean up.
deleted = await conn.execute(text(
"DELETE FROM telegram_bot WHERE id IN ("
" SELECT t.id FROM telegram_bot t "
" JOIN ("
" SELECT bot_id FROM telegram_bot "
" WHERE bot_id != 0 GROUP BY bot_id HAVING COUNT(*) > 1"
" ) g ON t.bot_id = g.bot_id "
" WHERE t.id NOT IN ("
" SELECT MIN(id) FROM telegram_bot WHERE bot_id = g.bot_id"
" )"
")"
))
rc = int(getattr(deleted, "rowcount", 0) or 0)
if rc:
logger.warning(
"Removed %d duplicate telegram_bot row(s) on bot_id "
"(non-sentinel collisions)", rc,
)
# Plain INDEX for the lookup-by-bot_id path.
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_telegram_bot_bot_id "
"ON telegram_bot(bot_id)"
))
# Partial UNIQUE excluding the sentinel.
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"uq_telegram_bot_bot_id_nonzero "
"ON telegram_bot(bot_id) WHERE bot_id != 0"
))
# telegram_chat (bot_id, chat_id) — keep the survivor with the oldest
# discovered_at so the original discovery row wins. _dedupe_by_columns
# only handles created_at; do this one inline.
if await _has_table(conn, "telegram_chat"):
res = await conn.execute(text(
"DELETE FROM telegram_chat WHERE id IN ("
" SELECT t.id FROM telegram_chat t "
" JOIN ("
" SELECT bot_id, chat_id FROM telegram_chat "
" GROUP BY bot_id, chat_id HAVING COUNT(*) > 1"
" ) g ON t.bot_id = g.bot_id AND t.chat_id = g.chat_id "
" WHERE t.id NOT IN ("
" SELECT id FROM telegram_chat "
" WHERE bot_id = g.bot_id AND chat_id = g.chat_id "
" ORDER BY discovered_at ASC, id ASC LIMIT 1"
" )"
")"
))
rc = int(getattr(res, "rowcount", 0) or 0)
if rc:
logger.warning(
"Removed %d duplicate telegram_chat row(s) on (bot_id, chat_id)",
rc,
)
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_telegram_chat_bot_chat "
"ON telegram_chat(bot_id, chat_id)"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_telegram_chat_bot_chat "
"ON telegram_chat(bot_id, chat_id)"
))
# notification_tracker_target (notification_tracker_id, target_id)
# — keep the oldest created_at link so the original wins.
if await _has_table(conn, "notification_tracker_target") and await _has_column(
conn, "notification_tracker_target", "notification_tracker_id",
):
await _dedupe_by_columns(
conn,
"notification_tracker_target",
["notification_tracker_id", "target_id"],
keep="min_created_at",
label="tracker-target link uniqueness",
)
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_ntt_tracker_target "
"ON notification_tracker_target(notification_tracker_id, target_id)"
))
# service_provider partial unique on (user_id) WHERE type='bridge_self'.
# Bridge-self is special: exactly one row per user, auto-seeded at boot,
# at user-create, and on /setup. Without this guard, a concurrent boot
# backfill + POST /api/users could double-insert. Dedupe keeps the
# oldest row so any user-customised thresholds on it survive.
if await _has_table(conn, "service_provider"):
res = await conn.execute(text(
"DELETE FROM service_provider WHERE id IN ("
" SELECT t.id FROM service_provider t "
" JOIN ("
" SELECT user_id FROM service_provider "
" WHERE type='bridge_self' GROUP BY user_id HAVING COUNT(*) > 1"
" ) g ON t.user_id = g.user_id "
" WHERE t.type='bridge_self' AND t.id NOT IN ("
" SELECT MIN(id) FROM service_provider "
" WHERE type='bridge_self' AND user_id = g.user_id"
" )"
")"
))
rc = int(getattr(res, "rowcount", 0) or 0)
if rc:
logger.warning(
"Removed %d duplicate bridge_self service_provider row(s) "
"on user_id", rc,
)
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"uq_service_provider_bridge_self_per_user "
"ON service_provider(user_id) WHERE type='bridge_self'"
))
async def migrate_eventlog_provider_fk(engine: AsyncEngine) -> None:
"""Document the EventLog.provider_id FK situation.
SQLite cannot ALTER a column to add a foreign-key constraint without
rebuilding the table. The model annotation now declares
``ondelete=SET NULL`` which only takes effect on freshly created
tables (i.e. brand-new installs). For existing installs we rely on
application-side cleanup in ``api/providers.delete_provider`` to NULL
out ``event_log.provider_id`` rows before deleting the provider row.
This migration is intentionally a no-op aside from the log line — it
exists so the migration order is explicit and operators see in the
logs that the FK strategy was reviewed on this boot.
"""
if not str(engine.url).startswith("sqlite"):
return
async with engine.begin() as conn:
if not await _has_table(conn, "event_log"):
return
# No DDL change. Application code in api/providers.delete_provider
# is the source of truth for the SET NULL semantic on existing tables.
logger.debug(
"event_log.provider_id FK enforcement deferred to application "
"code on existing SQLite tables (model declares ondelete=SET NULL "
"which applies to fresh schemas only)."
)
# ---------------------------------------------------------------------------
# Schema version tracking — lightweight alternative to Alembic while the
# hand-rolled idempotent migrations remain the source of truth. Gives
# operators a single-row answer to "what schema is this DB at" and lets
# future upgrades short-circuit migrations that already ran.
# ---------------------------------------------------------------------------
CURRENT_SCHEMA_VERSION = 1
async def migrate_schema_version(engine: AsyncEngine) -> None:
"""Create schema_version table and bump it to CURRENT_SCHEMA_VERSION."""
async with engine.begin() as conn:
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS schema_version ("
" id INTEGER PRIMARY KEY CHECK (id = 1),"
" version INTEGER NOT NULL,"
" applied_at TEXT NOT NULL"
")"
)
)
row = await conn.run_sync(
lambda sc: sc.execute(
text("SELECT version FROM schema_version WHERE id = 1")
).fetchone()
)
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
if row is None:
await conn.execute(
text(
"INSERT INTO schema_version (id, version, applied_at) "
"VALUES (1, :v, :t)"
),
{"v": CURRENT_SCHEMA_VERSION, "t": now},
)
logger.info("Initialized schema_version at %d", CURRENT_SCHEMA_VERSION)
elif int(row[0]) < CURRENT_SCHEMA_VERSION:
await conn.execute(
text(
"UPDATE schema_version SET version = :v, applied_at = :t "
"WHERE id = 1"
),
{"v": CURRENT_SCHEMA_VERSION, "t": now},
)
logger.info(
"Bumped schema_version from %s to %d",
row[0], CURRENT_SCHEMA_VERSION,
)