2026-06-19 11:30:38 +03:00
""" Database initialization: create all tables and seed defaults.
Idempotent : safe to call from multiple processes ( backend lifespan + worker
startup ) thanks to a PostgreSQL advisory lock that serializes the seeding
phase . ` create_all ` itself is already ` CREATE TABLE IF NOT EXISTS ` , so the
only race is on seed inserts — guarded by ` pg_advisory_xact_lock ` plus
per - row IntegrityError handling .
"""
2026-06-19 11:28:04 +03:00
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from sqlalchemy import select , text
2026-06-19 11:30:38 +03:00
from sqlalchemy . exc import IntegrityError
2026-06-19 11:28:04 +03:00
from app . db import AsyncSessionLocal , Base , engine
from app . models import GlossaryEntry , Preset , Setting , User
from app . config import settings
from app . logging_setup import get_logger , setup_logging
from app . core . security import hash_password
from app . prompts . fantasy_preset import FANTASY_PRESET_RU , FANTASY_PRESET_EN
log = get_logger ( " migrations " )
2026-06-19 11:30:38 +03:00
# Stable advisory lock key so backend + worker don't race on seeding.
# (key1, key2) — arbitrary 64-bit integers, kept constant across runs.
_ADVISORY_LOCK_KEY = ( 42424201 , 1 )
2026-06-19 11:28:04 +03:00
DEFAULT_SETTINGS = [
( " llm.base_url " , settings . default_llm_base_url , " OpenAI-compatible base URL " ) ,
( " llm.api_key " , settings . default_llm_api_key , " API key for LLM endpoint " ) ,
( " llm.model " , settings . default_llm_model , " Default model name " ) ,
( " llm.temperature " , 0.7 , " Temperature for orchestrator " ) ,
( " llm.step_temperature " , 0.85 , " Temperature for narrative step writer " ) ,
( " llm.summary_temperature " , 0.3 , " Temperature for summarizer " ) ,
( " llm.max_tokens " , 1024 , " Max tokens per LLM response " ) ,
( " llm.request_timeout " , 120 , " LLM request timeout, seconds " ) ,
( " llm.streaming " , True , " Whether to use streaming responses " ) ,
( " context.recent_messages " , settings . default_recent_messages , " Guaranteed recent messages in prompt " ) ,
( " context.compress_threshold " , settings . default_compress_threshold , " Trigger compression at this count " ) ,
( " context.summary_messages " , settings . default_summary_messages , " Number of messages per summary block " ) ,
( " context.max_tokens_total " , 6000 , " Soft token budget for context window (small models) " ) ,
2026-06-19 16:31:45 +03:00
( " triggers.enabled " , True , " Enable trigger firing on in-game time changes " ) ,
2026-06-19 11:28:04 +03:00
# Embeddings / RAG
( " embedding.provider " , settings . default_embedding_provider , " Embeddings provider: ' hash ' (offline fallback) or ' openai ' (real semantic embeddings) " ) ,
( " embedding.base_url " , settings . default_embedding_base_url , " OpenAI-compatible embeddings base URL. Empty = reuse llm.base_url " ) ,
( " embedding.api_key " , settings . default_embedding_api_key , " API key for embeddings endpoint. Empty = reuse llm.api_key " ) ,
( " embedding.model " , settings . default_embedding_model , " Embedding model name (e.g. text-embedding-3-small, bge-m3, nomic-embed-text) " ) ,
( " embedding.dim " , settings . default_embedding_dim , " Vector dimension. 0 = auto-probe from endpoint on first use " ) ,
( " embedding.request_timeout " , settings . default_embedding_request_timeout , " Embeddings request timeout, seconds " ) ,
2026-06-19 19:14:27 +03:00
# UI customization
( " ui.logo_url " , " /logo.png " , " Logo image URL or path shown in navbar, home page, and favicon. Use a URL (https://...), an absolute path (/logo.png), or a data: URI. Default is the bundled Mikan logo. " ) ,
2026-06-19 11:28:04 +03:00
]
2026-06-19 17:10:17 +03:00
# NOTE: env-derived defaults (llm.base_url, llm.api_key, llm.model,
# embedding.* etc.) are ONLY applied on the very first run via _seed_settings.
# After that, the admin panel is the source of truth — restarting the
# container will NOT overwrite admin-configured values with .env values.
# To force a re-seed, drop the `settings` table or delete the relevant rows.
2026-06-19 16:31:45 +03:00
2026-06-19 11:28:04 +03:00
async def init_db ( ) - > None :
setup_logging ( )
log . info ( " creating_tables " )
2026-06-19 11:30:38 +03:00
# CREATE TABLE IF NOT EXISTS — safe to run concurrently.
2026-06-19 11:28:04 +03:00
async with engine . begin ( ) as conn :
await conn . run_sync ( Base . metadata . create_all )
log . info ( " tables_ready " )
2026-06-19 11:30:38 +03:00
# Seed phase: serialize across processes via PG advisory transaction lock.
# On non-PG backends (SQLite for tests) the lock statement is a no-op
# (we catch the error and proceed without locking).
try :
async with AsyncSessionLocal ( ) as session :
await session . execute (
text ( " SELECT pg_advisory_xact_lock(:k1, :k2) " ) . bindparams (
k1 = _ADVISORY_LOCK_KEY [ 0 ] , k2 = _ADVISORY_LOCK_KEY [ 1 ]
)
)
await _seed_settings ( session )
await _seed_builtin_presets ( session )
2026-06-19 11:28:04 +03:00
await session . commit ( )
2026-06-19 11:30:38 +03:00
except Exception as e :
# Non-PG backend (SQLite) or transient error — retry without the lock.
log . warning ( " advisory_lock_unavailable_proceeding " , error = f " { type ( e ) . __name__ } : { e } " )
async with AsyncSessionLocal ( ) as session :
await _seed_settings ( session )
await _seed_builtin_presets ( session )
2026-06-19 11:28:04 +03:00
await session . commit ( )
2026-06-19 16:31:45 +03:00
# Ensure admin_setup_token is set and print it on every startup.
#
# The admin-setup endpoint refuses to create a second admin (see app/api/auth.py),
# so it's safe to always print the token — even after an admin exists, the token
# is useless. We print on every startup (not just first run) so the operator can
# always find the URL in the logs without having to dig through old logs.
2026-06-19 11:28:04 +03:00
token = settings . admin_setup_token . strip ( )
if not token :
2026-06-19 16:31:45 +03:00
# No token forced via env — generate one and persist it (idempotent).
2026-06-19 11:28:04 +03:00
import secrets as _s
token = _s . token_urlsafe ( 24 )
async with AsyncSessionLocal ( ) as session :
existing = await session . execute ( select ( Setting ) . where ( Setting . key == " admin.setup_token " ) )
existing_obj = existing . scalars ( ) . first ( )
if existing_obj is None :
session . add ( Setting ( key = " admin.setup_token " , value = token , description = " One-time token for /admin/setup " ) )
2026-06-19 11:30:38 +03:00
try :
await session . commit ( )
except IntegrityError :
2026-06-19 16:31:45 +03:00
# Another process inserted it concurrently — re-read.
await session . rollback ( )
2026-06-19 11:30:38 +03:00
await session . rollback ( )
2026-06-19 16:31:45 +03:00
existing = await session . execute ( select ( Setting ) . where ( Setting . key == " admin.setup_token " ) )
existing_obj = existing . scalars ( ) . first ( )
if existing_obj is not None :
token = str ( existing_obj . value )
2026-06-19 11:28:04 +03:00
else :
2026-06-19 16:31:45 +03:00
# Use the persisted token (env was empty, DB has one).
token = str ( existing_obj . value )
# Always print — operator convenience.
print ( " = " * 60 )
print ( " ADMIN SETUP URL: " )
print ( f " /admin/setup " )
print ( " ADMIN SETUP TOKEN: " )
print ( f " { token } " )
print ( " = " * 60 )
log . info ( " admin_setup_token_printed " )
2026-06-19 11:28:04 +03:00
2026-06-19 11:30:38 +03:00
async def _seed_settings ( session ) - > None :
""" Insert default settings that don ' t yet exist (per-row, race-safe). """
result = await session . execute ( select ( Setting ) . limit ( 1 ) )
if result . scalars ( ) . first ( ) is not None :
log . info ( " settings_already_exist " )
return
seeded = 0
for key , value , desc in DEFAULT_SETTINGS :
# Check existence per-row to avoid IntegrityError on concurrent inserts
existing = await session . execute ( select ( Setting ) . where ( Setting . key == key ) )
if existing . scalars ( ) . first ( ) is not None :
continue
session . add ( Setting ( key = key , value = value , description = desc ) )
seeded + = 1
if seeded :
try :
await session . commit ( )
log . info ( " settings_seeded " , count = seeded )
except IntegrityError :
await session . rollback ( )
log . info ( " settings_seed_skipped_concurrent " )
else :
log . info ( " settings_already_exist " )
async def _seed_builtin_presets ( session ) - > None :
""" Insert built-in presets if none exist yet. """
result = await session . execute ( select ( Preset ) . where ( Preset . is_builtin . is_ ( True ) ) )
if result . scalars ( ) . first ( ) is not None :
log . info ( " builtin_presets_already_exist " )
return
for preset_def in ( FANTASY_PRESET_RU , FANTASY_PRESET_EN ) :
# Check by slug to avoid race on unique constraint
existing = await session . execute ( select ( Preset ) . where ( Preset . slug == preset_def [ " slug " ] ) )
if existing . scalars ( ) . first ( ) is not None :
continue
session . add ( Preset (
slug = preset_def [ " slug " ] ,
title = preset_def [ " title " ] ,
description = preset_def [ " description " ] ,
language = preset_def [ " language " ] ,
is_public = True ,
is_builtin = True ,
payload = preset_def [ " payload " ] ,
) )
try :
await session . commit ( )
log . info ( " builtin_presets_seeded " )
except IntegrityError :
await session . rollback ( )
log . info ( " builtin_presets_seed_skipped_concurrent " )
2026-06-19 11:28:04 +03:00
if __name__ == " __main__ " :
asyncio . run ( init_db ( ) )