SQLite with better-sqlite3. Auto-migrates on startup.
Location: data/database.sqlite (relative to cwd, or SQLITE_PATH env var)
Pragmas enabled:
journal_mode = WAL(better concurrency)foreign_keys = ON
id INTEGER PRIMARY KEY AUTOINCREMENT
telegram_id INTEGER UNIQUE NOT NULL
username TEXT
first_name TEXT
last_name TEXT
photo_url TEXT
notifications_enabled INTEGER DEFAULT 1
has_started INTEGER DEFAULT 0
is_blocked_for_reviews INTEGER DEFAULT 0
created_at TEXT DEFAULT (datetime('now'))
updated_at TEXT DEFAULT (datetime('now'))id INTEGER PRIMARY KEY AUTOINCREMENT
user_id INTEGER NOT NULL
course_id INTEGER NOT NULL
is_favorite INTEGER DEFAULT 0
purchased_at TEXT DEFAULT (datetime('now'))
UNIQUE(user_id, course_id)
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
user_id INTEGER NOT NULL
course_id INTEGER NOT NULL
lesson_id INTEGER NOT NULL
completed_at TEXT DEFAULT (datetime('now'))
UNIQUE(user_id, lesson_id)
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
user_id INTEGER NOT NULL
course_id INTEGER NOT NULL
rating INTEGER NOT NULL
comment TEXT
is_edited INTEGER DEFAULT 0
admin_reply TEXT
admin_reply_user_id INTEGER
admin_reply_is_edited INTEGER DEFAULT 0
admin_reply_created_at TEXT
admin_reply_updated_at TEXT
created_at TEXT DEFAULT (datetime('now'))
updated_at TEXT DEFAULT (datetime('now'))
UNIQUE(user_id, course_id)
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
review_id INTEGER NOT NULL
user_id INTEGER NOT NULL
value INTEGER CHECK(value IN (1, -1))
created_at TEXT DEFAULT (datetime('now'))
updated_at TEXT DEFAULT (datetime('now'))
UNIQUE(review_id, user_id)
FK review_id -> reviews(id) ON DELETE CASCADE
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
user_id INTEGER NOT NULL
course_id INTEGER
payment_id TEXT
amount REAL NOT NULL
currency TEXT DEFAULT 'USD'
status TEXT CHECK(status IN ('pending', 'success', 'failed', 'refunded'))
type TEXT CHECK(type IN ('purchase', 'subscription', 'refund'))
notification_message_id INTEGER
created_at TEXT DEFAULT (datetime('now'))
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
user_id INTEGER NOT NULL
lesson_id INTEGER NOT NULL
course_id INTEGER NOT NULL
score REAL NOT NULL
max_score INTEGER NOT NULL
percentage REAL NOT NULL
answers_data TEXT
time_spent INTEGER
passed INTEGER DEFAULT 0
attempt_number INTEGER DEFAULT 1
created_at TEXT DEFAULT (datetime('now'))
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
user_id INTEGER NOT NULL
lesson_id INTEGER NOT NULL
course_id INTEGER NOT NULL
ip_address TEXT
user_agent TEXT
access_token TEXT
accessed_at TEXT DEFAULT (datetime('now'))
FK user_id -> users(id)id INTEGER PRIMARY KEY AUTOINCREMENT
sender_id INTEGER NOT NULL
sender_type TEXT NOT NULL
chat_user_id INTEGER NOT NULL
message TEXT NOT NULL
is_read INTEGER DEFAULT 0
is_edited INTEGER DEFAULT 0
is_deleted INTEGER DEFAULT 0
created_at TEXT DEFAULT (datetime('now'))
updated_at TEXT DEFAULT (datetime('now'))
FK sender_id -> users(id)
FK chat_user_id -> users(id)These tables exist in schema but filesystem-based courses are preferred:
courses
id INTEGER PRIMARY KEY AUTOINCREMENT
title TEXT NOT NULL
author TEXT NOT NULL
price REAL NOT NULL
rating REAL DEFAULT 0
category TEXT
image_url TEXT
description TEXT
duration TEXT
is_published INTEGER DEFAULT 0
created_at TEXT DEFAULT (datetime('now'))course_modules
id INTEGER PRIMARY KEY AUTOINCREMENT
course_id INTEGER NOT NULL
title TEXT NOT NULL
sort_order INTEGER DEFAULT 0lessons
id INTEGER PRIMARY KEY AUTOINCREMENT
module_id INTEGER NOT NULL
course_id INTEGER NOT NULL
title TEXT NOT NULL
content TEXT
type TEXT CHECK(type IN ('text', 'image', 'video', 'quiz', 'completion'))
image_url TEXT
video_url TEXT
duration_seconds INTEGER
sort_order INTEGER DEFAULT 0
FK module_id -> course_modules(id) ON DELETE CASCADEquiz_questions
id INTEGER PRIMARY KEY AUTOINCREMENT
lesson_id INTEGER NOT NULL
question TEXT NOT NULL
type TEXT CHECK(type IN ('single', 'multiple', 'text'))
explanation TEXT
hint TEXT
points INTEGER DEFAULT 1
time_limit INTEGER
sort_order INTEGER DEFAULT 0
FK lesson_id -> lessons(id) ON DELETE CASCADEquiz_answers
id INTEGER PRIMARY KEY AUTOINCREMENT
question_id INTEGER NOT NULL
answer_text TEXT NOT NULL
is_correct INTEGER DEFAULT 0
sort_order INTEGER DEFAULT 0
FK question_id -> quiz_questions(id) ON DELETE CASCADEquiz_settings
id INTEGER PRIMARY KEY AUTOINCREMENT
lesson_id INTEGER UNIQUE NOT NULL
passing_score REAL DEFAULT 70.0
max_attempts INTEGER DEFAULT -1
shuffle_questions INTEGER DEFAULT 0
shuffle_answers INTEGER DEFAULT 1
show_explanations INTEGER DEFAULT 1
require_pass INTEGER DEFAULT 1
FK lesson_id -> lessons(id) ON DELETE CASCADEremedial_content
id INTEGER PRIMARY KEY AUTOINCREMENT
lesson_id INTEGER NOT NULL
title TEXT NOT NULL
content TEXT NOT NULL
content_type TEXT CHECK(content_type IN ('text', 'video', 'article', 'practice'))
media_url TEXT
sort_order INTEGER DEFAULT 0
FK lesson_id -> lessons(id) ON DELETE CASCADEOptional caching layer. Enable with redis.enabled: true in config.
courses:featured - Featured courses list
courses:{id} - Single course data
courses:{id}:lessons - Course lessons
user:{telegramId} - User object (15 min TTL)
user:{userId}:courses - User's purchased course IDs
env:
redis:
enabled: true
host: localhost
port: 6379
password: ""Falls back to no-op if Redis unavailable.