first commit
This commit is contained in:
709
api/app/services/database_init.py
Normal file
709
api/app/services/database_init.py
Normal file
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
Database Initialization Service.
|
||||
|
||||
This service handles programmatic creation of Appwrite database tables,
|
||||
including schema definition, column creation, and index setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class DatabaseInitService:
|
||||
"""
|
||||
Service for initializing Appwrite database tables.
|
||||
|
||||
This service provides methods to:
|
||||
- Create tables if they don't exist
|
||||
- Define table schemas (columns/attributes)
|
||||
- Create indexes for efficient querying
|
||||
- Validate existing table structures
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the database initialization service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("DatabaseInitService initialized", database_id=self.database_id)
|
||||
|
||||
def init_all_tables(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Initialize all application tables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to success status
|
||||
"""
|
||||
results = {}
|
||||
|
||||
logger.info("Initializing all database tables")
|
||||
|
||||
# Initialize characters table
|
||||
try:
|
||||
self.init_characters_table()
|
||||
results['characters'] = True
|
||||
logger.info("Characters table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize characters table", error=str(e))
|
||||
results['characters'] = False
|
||||
|
||||
# Initialize game_sessions table
|
||||
try:
|
||||
self.init_game_sessions_table()
|
||||
results['game_sessions'] = True
|
||||
logger.info("Game sessions table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize game_sessions table", error=str(e))
|
||||
results['game_sessions'] = False
|
||||
|
||||
# Initialize ai_usage_logs table
|
||||
try:
|
||||
self.init_ai_usage_logs_table()
|
||||
results['ai_usage_logs'] = True
|
||||
logger.info("AI usage logs table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize ai_usage_logs table", error=str(e))
|
||||
results['ai_usage_logs'] = False
|
||||
|
||||
success_count = sum(1 for v in results.values() if v)
|
||||
total_count = len(results)
|
||||
|
||||
logger.info("Table initialization complete",
|
||||
success=success_count,
|
||||
total=total_count,
|
||||
results=results)
|
||||
|
||||
return results
|
||||
|
||||
def init_characters_table(self) -> bool:
|
||||
"""
|
||||
Initialize the characters table.
|
||||
|
||||
Table schema:
|
||||
- userId (string, required): Owner's user ID
|
||||
- characterData (string, required): JSON-serialized character data
|
||||
- is_active (boolean, default=True): Soft delete flag
|
||||
- created_at (datetime): Auto-managed creation timestamp
|
||||
- updated_at (datetime): Auto-managed update timestamp
|
||||
|
||||
Indexes:
|
||||
- userId: For general user queries
|
||||
- userId + is_active: Composite index for efficiently fetching active characters
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'characters'
|
||||
|
||||
logger.info("Initializing characters table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("Characters table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Characters table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating characters table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Characters'
|
||||
)
|
||||
logger.info("Characters table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='characterData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON data
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='is_active',
|
||||
column_type='boolean',
|
||||
required=False, # Cannot be required if we want a default value
|
||||
default=True
|
||||
)
|
||||
|
||||
# Note: created_at and updated_at are auto-managed by DatabaseService
|
||||
# through the _parse_row method and timestamp updates
|
||||
|
||||
# Wait for columns to fully propagate in Appwrite before creating indexes
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes for efficient querying
|
||||
# Note: Individual userId index for general user queries
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId',
|
||||
index_type='key',
|
||||
attributes=['userId']
|
||||
)
|
||||
|
||||
# Composite index for the most common query pattern:
|
||||
# Query.equal('userId', user_id) + Query.equal('is_active', True)
|
||||
# This single composite index covers both conditions efficiently
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_is_active',
|
||||
index_type='key',
|
||||
attributes=['userId', 'is_active']
|
||||
)
|
||||
|
||||
logger.info("Characters table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize characters table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_game_sessions_table(self) -> bool:
|
||||
"""
|
||||
Initialize the game_sessions table.
|
||||
|
||||
Table schema:
|
||||
- userId (string, required): Owner's user ID
|
||||
- characterId (string, required): Character ID for this session
|
||||
- sessionData (string, required): JSON-serialized session data
|
||||
- status (string, required): Session status (active, completed, abandoned)
|
||||
- sessionType (string, required): Session type (solo, multiplayer)
|
||||
|
||||
Indexes:
|
||||
- userId: For user session queries
|
||||
- userId + status: For active session queries
|
||||
- characterId: For character session lookups
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'game_sessions'
|
||||
|
||||
logger.info("Initializing game_sessions table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("Game sessions table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Game sessions table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating game_sessions table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Game Sessions'
|
||||
)
|
||||
logger.info("Game sessions table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='characterId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON data
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='status',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionType',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId',
|
||||
index_type='key',
|
||||
attributes=['userId']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_status',
|
||||
index_type='key',
|
||||
attributes=['userId', 'status']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_characterId',
|
||||
index_type='key',
|
||||
attributes=['characterId']
|
||||
)
|
||||
|
||||
logger.info("Game sessions table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize game_sessions table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_ai_usage_logs_table(self) -> bool:
|
||||
"""
|
||||
Initialize the ai_usage_logs table for tracking AI API usage and costs.
|
||||
|
||||
Table schema:
|
||||
- user_id (string, required): User who made the request
|
||||
- timestamp (string, required): ISO timestamp of the request
|
||||
- model (string, required): Model identifier
|
||||
- tokens_input (integer, required): Input token count
|
||||
- tokens_output (integer, required): Output token count
|
||||
- tokens_total (integer, required): Total token count
|
||||
- estimated_cost (float, required): Estimated cost in USD
|
||||
- task_type (string, required): Type of task
|
||||
- session_id (string, optional): Game session ID
|
||||
- character_id (string, optional): Character ID
|
||||
- request_duration_ms (integer): Request duration in milliseconds
|
||||
- success (boolean): Whether request succeeded
|
||||
- error_message (string, optional): Error message if failed
|
||||
|
||||
Indexes:
|
||||
- user_id: For user usage queries
|
||||
- timestamp: For date range queries
|
||||
- user_id + timestamp: Composite for user date range queries
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'ai_usage_logs'
|
||||
|
||||
logger.info("Initializing ai_usage_logs table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("AI usage logs table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("AI usage logs table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating ai_usage_logs table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='AI Usage Logs'
|
||||
)
|
||||
logger.info("AI usage logs table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='user_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='timestamp',
|
||||
column_type='string',
|
||||
size=50, # ISO timestamp format
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='model',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_input',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_output',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_total',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='estimated_cost',
|
||||
column_type='float',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='task_type',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='session_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='character_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='request_duration_ms',
|
||||
column_type='integer',
|
||||
required=False,
|
||||
default=0
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='success',
|
||||
column_type='boolean',
|
||||
required=False,
|
||||
default=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='error_message',
|
||||
column_type='string',
|
||||
size=1000,
|
||||
required=False
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_user_id',
|
||||
index_type='key',
|
||||
attributes=['user_id']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_timestamp',
|
||||
index_type='key',
|
||||
attributes=['timestamp']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_user_id_timestamp',
|
||||
index_type='key',
|
||||
attributes=['user_id', 'timestamp']
|
||||
)
|
||||
|
||||
logger.info("AI usage logs table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize ai_usage_logs table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_column(
|
||||
self,
|
||||
table_id: str,
|
||||
column_id: str,
|
||||
column_type: str,
|
||||
size: Optional[int] = None,
|
||||
required: bool = False,
|
||||
default: Optional[Any] = None,
|
||||
array: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a column in a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
column_id: Column ID
|
||||
column_type: Column type (string, integer, float, boolean, datetime, email, ip, url)
|
||||
size: Column size (for string types)
|
||||
required: Whether column is required
|
||||
default: Default value
|
||||
array: Whether column is an array
|
||||
|
||||
Returns:
|
||||
Column creation response
|
||||
|
||||
Raises:
|
||||
AppwriteException: If column creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating column",
|
||||
table_id=table_id,
|
||||
column_id=column_id,
|
||||
column_type=column_type)
|
||||
|
||||
# Build column parameters (Appwrite SDK uses 'key' not 'column_id')
|
||||
params = {
|
||||
'database_id': self.database_id,
|
||||
'table_id': table_id,
|
||||
'key': column_id,
|
||||
'required': required,
|
||||
'array': array
|
||||
}
|
||||
|
||||
if size is not None:
|
||||
params['size'] = size
|
||||
|
||||
if default is not None:
|
||||
params['default'] = default
|
||||
|
||||
# Create column using the appropriate method based on type
|
||||
if column_type == 'string':
|
||||
result = self.tables_db.create_string_column(**params)
|
||||
elif column_type == 'integer':
|
||||
result = self.tables_db.create_integer_column(**params)
|
||||
elif column_type == 'float':
|
||||
result = self.tables_db.create_float_column(**params)
|
||||
elif column_type == 'boolean':
|
||||
result = self.tables_db.create_boolean_column(**params)
|
||||
elif column_type == 'datetime':
|
||||
result = self.tables_db.create_datetime_column(**params)
|
||||
elif column_type == 'email':
|
||||
result = self.tables_db.create_email_column(**params)
|
||||
else:
|
||||
raise ValueError(f"Unsupported column type: {column_type}")
|
||||
|
||||
logger.info("Column created successfully",
|
||||
table_id=table_id,
|
||||
column_id=column_id)
|
||||
|
||||
return result
|
||||
|
||||
except AppwriteException as e:
|
||||
# If column already exists, log warning but don't fail
|
||||
if e.code == 409: # Conflict - column already exists
|
||||
logger.warning("Column already exists",
|
||||
table_id=table_id,
|
||||
column_id=column_id)
|
||||
return {}
|
||||
logger.error("Failed to create column",
|
||||
table_id=table_id,
|
||||
column_id=column_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_index(
|
||||
self,
|
||||
table_id: str,
|
||||
index_id: str,
|
||||
index_type: str,
|
||||
attributes: List[str],
|
||||
orders: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an index on a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
index_id: Index ID
|
||||
index_type: Index type (key, fulltext, unique)
|
||||
attributes: List of column IDs to index
|
||||
orders: List of sort orders (ASC, DESC) for each attribute
|
||||
|
||||
Returns:
|
||||
Index creation response
|
||||
|
||||
Raises:
|
||||
AppwriteException: If index creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating index",
|
||||
table_id=table_id,
|
||||
index_id=index_id,
|
||||
attributes=attributes)
|
||||
|
||||
result = self.tables_db.create_index(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
key=index_id,
|
||||
type=index_type,
|
||||
columns=attributes, # SDK uses 'columns', not 'attributes'
|
||||
orders=orders or ['ASC'] * len(attributes)
|
||||
)
|
||||
|
||||
logger.info("Index created successfully",
|
||||
table_id=table_id,
|
||||
index_id=index_id)
|
||||
|
||||
return result
|
||||
|
||||
except AppwriteException as e:
|
||||
# If index already exists, log warning but don't fail
|
||||
if e.code == 409: # Conflict - index already exists
|
||||
logger.warning("Index already exists",
|
||||
table_id=table_id,
|
||||
index_id=index_id)
|
||||
return {}
|
||||
logger.error("Failed to create index",
|
||||
table_id=table_id,
|
||||
index_id=index_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_init_service_instance: Optional[DatabaseInitService] = None
|
||||
|
||||
|
||||
def get_database_init_service() -> DatabaseInitService:
|
||||
"""
|
||||
Get the global DatabaseInitService instance.
|
||||
|
||||
Returns:
|
||||
Singleton DatabaseInitService instance
|
||||
"""
|
||||
global _init_service_instance
|
||||
if _init_service_instance is None:
|
||||
_init_service_instance = DatabaseInitService()
|
||||
return _init_service_instance
|
||||
|
||||
|
||||
def init_database() -> Dict[str, bool]:
|
||||
"""
|
||||
Convenience function to initialize all database tables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to success status
|
||||
"""
|
||||
service = get_database_init_service()
|
||||
return service.init_all_tables()
|
||||
Reference in New Issue
Block a user