4.3 KiB
type: database title: Database Schema Overview description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode. tags: [database, sqlite, typeorm, schema] timestamp: 2026-07-22T16:44:54Z
Overview
HIPCTF uses SQLite via better-sqlite3 with TypeORM migrations. The
database is a single file (DATABASE_PATH, default
/data/hipctf/db.sqlite) running in WAL journal mode for better
concurrent read/write performance.
The schema is created by a single migration
(backend/src/database/migrations/1700000000000-InitSchema.ts) and seeded
by a second migration
(backend/src/database/migrations/1700000000100-SeedSystemData.ts).
The initial migration declares every column used by the entities
(including category.created_at and category.updated_at), so the
later AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300, and
RepairCategorySchemaAndSystemCategories1700000000400 migrations only
patch legacy databases that pre-date those columns and reseed the
canonical system rows. The ...0400 migration is idempotent and
forward-only: it adds created_at/updated_at to legacy six-column
category tables, deletes obsolete FOR/OSI system rows, rewrites
metadata on canonical rows, inserts any missing canonical row, and
re-asserts the unique indexes on system_key and abbreviation.
Tables
| Table | Purpose |
|---|---|
user |
Authenticated users with role + status. |
setting |
Key/value site configuration. |
category |
Challenge categories (system + user-defined). |
challenge |
CTF challenges with scoring + protocol metadata. |
challenge_file |
Files attached to challenges. |
solve |
User-solved challenges (scoring ledger). |
refresh_token |
Rotating refresh tokens (hash + revocation). |
blog_post |
Markdown blog posts in draft/published states. |
Relationships
user ───< solve >─── challenge
│
└─── challenge_file >── challenge ──> category
user ───< refresh_token
challenge.category_id→category.id(ON DELETE RESTRICT).challenge_file.challenge_id→challenge.id(ON DELETE RESTRICT).solve.challenge_id→challenge.id(ON DELETE RESTRICT).solve.user_id→user.id(ON DELETE RESTRICT).
Indexes
| Table | Index |
|---|---|
category |
uq_category_system_key (unique on system_key where NOT NULL) |
challenge |
idx_challenge_category |
challenge_file |
idx_challenge_file_challenge |
solve |
uq_solve_challenge_user (unique), idx_solve_user, idx_solve_challenge |
refresh_token |
idx_refresh_token_user, unique on token_hash |
blog_post |
idx_blog_status_published (status, published_at) |
PRAGMAs
| PRAGMA | Where set | Effect |
|---|---|---|
foreign_keys = ON |
Migration InitSchema1700000000000 |
Enforce FK constraints. |
journal_mode = WAL |
Migration + DatabaseInitService.ensureWalMode() on every startup |
Persisted in DB header; allows concurrent readers. |
Bootstrap behavior
On every process start, DatabaseInitService.init():
- Initializes the TypeORM
DataSource(idempotent). - Calls
ensureWalMode()— idempotent; if WAL is already set it only logs. - Runs pending migrations if any (or if the
categorytable is missing). - Logs the seed row counts (categories, settings) via
verifySeed().