Database Guide
This guide describes the Ririko database: the two kinds of database it supports, how the schema is created and changed, and every table.
1. How the Schema Is Managed
1.1. Two Kinds of Database
Ririko uses Drizzle ORM for type-safe SQL queries. It supports two databases:
- PostgreSQL for production. It uses the
pgdriver (drizzle-orm/node-postgres) with connection pooling, native UUID and JSONB. - SQLite for development and small self-hosted setups. It uses
better-sqlite3(drizzle-orm/better-sqlite3) with WAL mode (PRAGMA journal_mode = WAL;) and enforced foreign keys (PRAGMA foreign_keys = ON;).
Set the kind with DATABASE_DIALECT (sqlite or postgres) and the address with DATABASE_URL.
1.2. Who Changes the Schema
Only the migration runner, migrateDatabase, creates or changes tables.
- The bot.
main.tsrunsprepareDatabase(apps/bot/src/database-startup.ts) right after it opens the database. This happens before the 1.4.0 upgrade and before any service starts.- With
DB_AUTO_MIGRATE=true(the default) the bot applies the pending migrations. An empty database gets0000_baseline. A database that has tables but no migration records is adopted once (section 1.6). - With
DB_AUTO_MIGRATE=falsethe bot only checks. It exits with a message that names the pending migrations. It never starts on an old schema. Runririko db:migratefirst. - In both modes, a database that holds a contract migration this release does not know stops the bot. This is the downgrade guard. Additive migrations that the release does not know only produce a warning.
- With
- The command-line tool.
ririko db:migrateapplies the same migrations by hand. In the bot image, rundocker exec <bot container> ririko db:migrate.--statusprints the latest, pending and unknown migration ids.--dry-runprints the plan, including what adopting an old database would change. It changes nothing.- Exit codes:
0done or nothing to do,1failure,2refused by the downgrade guard.
- The dashboard. It never changes the schema. It checks the schema through
assertSchemaCurrentinapps/web/src/lib/server/services.ts.- While the database is behind (never migrated, tables without records, or pending migrations), the dashboard cannot start.
/api/readyanswers503, and the server log names the pending migrations. /api/healthstays200and reportsMIGRATION_PENDING. So Docker does not restart the dashboard while the bot migrates.- The next request tries again. The dashboard recovers by itself when the bot or
ririko db:migratehas migrated the database. The same downgrade guard applies.
- While the database is behind (never migrated, tables without records, or pending migrations), the dashboard cannot start.
ririko db:copy. It prepares its PostgreSQL target withmigrateDatabase. The target gets the exact tables the bot expects and its own migration records. The migration records of the source are never copied.- Tests.
createDatabaseClient({ autoMigrate: true })runsmigrateDatabaseafter it connects. It is for tests and throwaway databases (:memory:) and is off by default.describeDialectsand the bot test harness migrate the same way.createSqliteClientcreates no tables, not even in a new file.
pnpm db:push is a shortcut for development. It never runs against a staging or production database.
1.3. Change the Schema
Follow these steps when you change a table.
-
Change the Drizzle schema in
packages/database/src/schema/. -
Optional: choose the name of the migration. Without this step, drizzle-kit picks a name:
pnpm -F @ririko/database db:generate-sql --name <slug> -
Generate the migration files for both dialects:
pnpm db:generateThis runs
drizzle-kit generatefor both dialects (drizzle.sqlite.config.tsanddrizzle.pg.config.ts), thendb:generate-migrations(the embedder), thendb:generate-ddl. -
Read the new SQL in
packages/database/migrations/. You may edit a migration before its release, for example to add a data backfill. After the release, a migration never changes. -
Run the check that CI runs:
pnpm db:checkIt prints no problem when the schema, the migration files and the embedded modules agree.
-
Commit all the output:
packages/database/migrations/, the embedded modules and the DDL files.
A change that removes or rewrites something (DROP TABLE, DROP COLUMN, RENAME, ALTER COLUMN ... TYPE, SET NOT NULL) follows the expand and contract rule:
- Ship the additive step first. The old release must still run on the new schema.
- Ship the removing step in a later release, in a migration whose first line is
-- ririko:contract. Do this only when no release uses the old shape.
1.4. Migration Files
- Files.
packages/database/migrations/sqlite/andpackages/database/migrations/pg/hold one ordered list for each dialect. The files are namedNNNN_<slug>.sql. Each folder also has the drizzle-kitmeta/snapshots. The nextgeneratecompares against the snapshots, so commit them. - Baseline.
0000_baseline.sqlis the schema as it was when migrations began. PostgreSQL migrations use unqualified table names, so they build in thesearch_pathof the connection. - Embedded modules.
db:generate-migrations(scripts/embed-migrations.mjs) writespackages/database/src/migrations/generated/sqlite.ts(SQLITE_MIGRATIONS) andpg.ts(PG_MIGRATIONS). The production images have nodrizzle-kit, so the runner reads these modules. Each entry is{ id, statements, checksum, contract }:statements: the file split at the drizzle marker--> statement-breakpoint, never at;;checksum: the sha256 of the file with CRLF changed to LF, so Windows and Linux agree;contract:truewhen the first line is-- ririko:contract.
- DDL files.
pnpm -F @ririko/database db:generate-ddlgenerates the bootstrap DDL (SQLITE_SCHEMA_DDLandPG_SCHEMA_DDL) from the Drizzle schemas. They are fixtures for the parity and baseline tests. The testsschema/sqlite/ddl.test.tsandschema/pg/ddl.test.tsfail when they are old. - Determinism. The output is the same every time.
migrations/generated.test.tsfails when the embedded modules are old. It also checks that the baseline builds the same schema as the DDL files (SQLite in memory, PostgreSQL whenTEST_POSTGRES_URLis set). - CI check.
pnpm db:check(scripts/check-migrations.ts, run by the CircleCIlintjob) fails and prints the file and the statement when:drizzle-kit generatewould still write a migration (it runs on a temporary copy of the folder);- the embedded modules differ from the SQL files;
- a migration has
DROP TABLE,DROP COLUMN,RENAME,ALTER COLUMN ... TYPEorSET NOT NULLwithout-- ririko:contracton its first line.
- Transactions. Each migration runs in one transaction. On PostgreSQL an advisory lock keeps two processes from migrating at the same time.
1.5. SQLite Table Rebuilds
For a changed column type or a dropped column, drizzle-kit writes a rebuild for SQLite: PRAGMA foreign_keys=OFF, a new table, a copy, DROP TABLE, a rename and PRAGMA foreign_keys=ON. SQLite ignores that pragma inside a transaction. So the runner treats a migration that contains PRAGMA foreign_keys=OFF as a rebuild:
- It turns foreign keys off on the connection before
BEGIN. - It skips the two pragma statements inside the transaction.
- It requires
PRAGMA foreign_key_checkto return no rows beforeCOMMIT. If rows come back, it rolls back with aDatabaseErrorthat names the table. - It restores the setting afterwards.
Children with ON DELETE CASCADE therefore stay. PostgreSQL does not need this.
1.6. Adopt an Existing Database
A database that has tables but no ririko_schema_migrations table is not given 0000_baseline to run. It is adopted (migrations/adopt-baseline.ts, called by migrateDatabase). On SQLite the runner first makes a backup. Then, under the same lock:
-
ensureTextIdColumnschanges the PostgreSQLuuidid columns of section 1.7 totext. The audit of duplicate card serials refuses serials that are not unique. -
The runner compares the live schema with the baseline. The expected shape comes from a scratch database that runs the baseline (SQLite in memory, or temporary tables in a PostgreSQL transaction that rolls back). Both are read with the same catalog queries, so no SQL is parsed.
-
The repair only adds, in one transaction:
- It creates missing tables, foreign keys and indexes from the baseline statements.
- It adds a missing column that is nullable or has a default.
- Extra tables and columns are only logged.
-
PostgreSQL compares column types exactly (
format_type). SQLite compares them by type affinity (SQLite datatype rules, section 3.1), because the declared text does not change how SQLite stores a value. A different declared type with the same affinity (for exampleBIGINTforINTEGER) is only reported as a note. INTEGER and NUMERIC affinity also count as compatible. TEXT, BLOB and REAL against INTEGER or NUMERIC are refused. The runner does not rebuild columns. -
The adoption is refused, with one report of every problem and no change, when:
- a NOT NULL column without a default is missing;
- a column cannot be added (a primary key, a generated column, or a non-constant default on SQLite);
- a column type differs (on SQLite, a different affinity);
- card serials are duplicated.
--dry-runshows the same plan or report. -
Then the runner records
0000_baselinewithadopted = trueand its embedded checksum. Later migrations run as usual. A second run changes nothing.
1.7. Text Ids
These id columns are text in both dialects, because SQLite holds slugs and prefixed ids that the PostgreSQL uuid type rejects: card ids (card_fire_001), asset ids (asset_card_bulk_fire_001), item ids (candy_minor) and AI ids (conv_<uuid>, msg_<uuid>).
- The columns:
waifu_assets.id,waifu_cards.id,waifu_cards.asset_id,user_cards.card_id,dungeon_bosses.asset_id,game_achievements.reward_card_idandreward_item_id,quests.reward_card_id,economy_items.id,economy_inventories.item_id,ai_conversations.id,ai_messages.idandai_messages.conversation_id. - No remapping. Ids never change. The card images in
public/cardsare named by card id, and the same ids appear in adventure payloads,tcg_system_configsandwaifu_card_serials.card_id. - Defaults. A primary key keeps a random-uuid default, stored as text (
gen_random_uuid()::text). - Old PostgreSQL databases.
ensureTextIdColumnschangesuuidcolumns totext. Adoption runs it. It runs under an advisory lock in one transaction, casts each value to its text form, and changes nothing on a second run. It does nothing on SQLite. The list of columns isTEXT_ID_COLUMNSinpackages/database/src/migrations/text-ids.ts.
1.8. Move Data Between Databases
- To move a 2.0 SQLite database into an empty PostgreSQL database, use
ririko db:copy. migrations.md section 5 has the steps. - To move a 1.4.0 database into 2.0, see migrations.md.
2. Table Catalog
The tables are grouped by area.
2.1. Core Identity & Discord Guilds
-
users: Universal Discord user registry (idSnowflake PK,username,display_name,avatar_url,profile_background_url,is_blacklisted,warn_count,notify_level_up,created_at,updated_at). -
guilds: Discord server registry (idSnowflake PK,name,icon_url,owner_id,invited_by_idnullable,invited_vianullable,joined_at,is_active,left_atnullable,created_at,updated_at). The bot keeps it current (GuildRepository,guild-registry.listener.ts). On every ready event it saves the cached servers and marks the others inactive. The eventsGuildCreate,GuildUpdateandGuildDeletefollow. A server that is only unavailable (an outage) stays active. A server the bot left stays listed as inactive, withleft_atset to the time it left, until the retention purge deletes it with the rest of the server's data (section 4.1). Adding the bot back clearsleft_at.invited_by_idis the member who added the bot.invited_viasays how the bot learned that:oauth: the dashboard invite flow (GuildRepository.recordInvite). The user who authorized the bot is the answer, so it replaces an earlier inviter.audit_log: theBotAddentry, which Discord keeps for 45 days. It needs View Audit Log.integration: the user of the bot's integration in the integrations list of the server. It needs Manage Server. The bot uses it when the audit log names no one.
Both columns stay null when nothing says. A later bot update never overwrites the inviter, the source or
joined_atof an active server. A server that the bot left and joined again starts over. -
user_consents: Which notice or consent a user accepted (user_id,kind[terms,ai],version,accepted_at). Primary key (user_id,kind), no foreign key.versionis the date of the legal texts in force when the user accepted (TERMS_VERSION,AI_CONSENT_VERSIONinConsentService); a newer version is accepted again and replaces the row, and a withdrawn consent deletes it. The bot writes atermsrow after it shows the one-time notice that follows a user's first command (TERMS_URLandPRIVACY_URLchoose the pages it links to). -
guild_members: Association table tracking guild membership (guild_id,user_id,nickname,joined_at,roles,is_in_guild). -
guild_settings: Unified guild configuration (guild_idPK,prefix,locale,timezone,ai_channel_id,log_channel_id,music_channel_id,welcomer_channel_id,welcomer_enabled,welcomer_bg,farewell_channel_id,farewell_enabled,farewell_bg,karma_notifications_enabled,created_at,updated_at).
2.2. Commands & Dynamic Module Settings
commands: Machine-readable command registry (namePK,category,description,slash_enabled,prefix_enabled,default_permission,cooldown_seconds). The bot replaces it with its registered commands at every startup (hidden and owner-only commands left out) so the dashboard and CLI can list them.command_settings: Per-guild or per-channel overrides (idUUID PK,guild_id,channel_id,command_name,is_enabled,cooldown_override,allowed_roles,blocked_roles). A row with a nullchannel_idapplies server wide; a channel row replaces it in that channel.GuildConfigServicereplaces a guild's rows together, so the table has no unique key.
2.3. Moderation & Safety Engine
moderation_cases: Immutable audit logs of punitive actions (idSerial/UUID PK,guild_id,case_number,type[WARN, TIMEOUT, KICK, BAN, SOFTBAN, UNBAN],target_user_id,moderator_user_id,reason,duration_seconds,metadata,created_at).moderation_warnings: Active and expired warning records (idUUID PK,guild_id,user_id,moderator_id,reason,severity,is_active,expires_at,created_at).moderation_rules: Configurable auto-mod rule definitions (idUUID PK,guild_id,rule_type[INVITE_SPAM, MENTION_SPAM, SCAM_URL, SUSPICIOUS_DOMAIN, REPEATED_TEXT, ATTACHMENT_SPAM],action[DELETE, WARN, TIMEOUT],threshold,is_enabled,exempt_roles,exempt_channels).moderation_notes: Staff notes on users (idUUID PK,guild_id,target_user_id,author_user_id,content,created_at,updated_at).
2.4. Centralized Transactional Economy & Banking
economy_accounts: Account status records (user_idPK,is_frozen,daily_streak,last_daily_at,created_at,updated_at).economy_balances: Balances per currency/type (user_idPK,wallet_balanceBigInt,bank_balanceBigInt,bank_capacityBigInt,net_worthBigInt,updated_at).economy_transactions: Immutable double-entry financial ledger (idUUID PK,user_id,guild_id,type[TRANSFER, DEPOSIT, WITHDRAW, DAILY, GAMBLE, SHOP_BUY, MARKET_FEE, TCG_REWARD],amountBigInt,currency,balance_beforeBigInt,balance_afterBigInt,source,metadataJSON,created_at).economy_rewards: System-wide reward configurations and history (idUUID PK,event_type,base_amount,multiplier,cooldown_seconds).economy_cooldowns: Per-user reward rate-limit trackers (idUUID PK,user_id,action_type,last_triggered_at,expires_at).economy_items: Item shop catalog (idtext PK, a uuid or a legacy slug,name,description,price,rarity,category_id,icon_url,is_purchasable,metadata).economy_item_categories: Item category groupings (idUUID PK,name,description).economy_inventories: User item inventory bags (idUUID PK,user_id,item_idtext,quantity,acquired_at).
2.5. Experience, Leveling & Rankings
xp_accounts: User leveling progress (user_idPK,guild_id,xpBigInt,levelInt,karmaInt,last_xp_at,created_at,updated_at).xp_events: Audit trail for XP gain events (idUUID PK,user_id,guild_id,xp_awarded,source[TEXT, VOICE, QUEST, GAME],created_at).leaderboard_snapshots: Cached ranking snapshots for fast global and server rank queries (user_id,guild_id,global_rank,server_rank,calculated_at).
2.6. Music & Audio System
music_guild_settings: Guild audio configuration (guild_idPK,default_volume,dj_role_id,restrict_voice_channel_id,auto_leave_empty,lyrics_provider).music_channels: Dedicated persistent music channel bindings (guild_idPK,channel_id,last_message_id).music_history: Audit log of played songs (idUUID PK,guild_id,user_id,track_title,track_url,duration_seconds,source_provider,played_at).music_saved_playlists: Custom user and guild playlists (idUUID PK,user_id,name,description,is_public,play_count,guild_id,created_at).music_playlist_tracks: Tracks within saved playlists (idUUID PK,playlist_id,title,url,duration,thumbnail_url,position).
2.7. AI Chatbot & Conversational Context
ai_channels: Dedicated AI channel registrations (guild_idPK,channel_id).ai_conversations: Active conversation session identifiers (idtext PK,conv_<uuid>,user_id,guild_id,channel_id,provider,model,summary,created_at,updated_at).ai_messages: Contextual message history with token counts (idtext PK,msg_<uuid>,conversation_idtext,role[SYSTEM, USER, ASSISTANT, TOOL],content,tool_callsJSON,token_count,created_at).ai_guild_preferences: Guild-specific personality prompt and tone (guild_idPK,personality_prompt,speaking_style,allowed_toolsJSON,model_override).ai_user_preferences: User personal preferences (user_idPK,nickname,timezone,language_preference).
2.8. Image Generation & Graphics
image_providers: Configured image generation backends (idString PK,name,is_enabled,is_free_tier,rate_limit_per_min,capabilitiesJSON).image_jobs: Concurrency-limited image synthesis job queue (idUUID PK,user_id,guild_id,provider_id,prompt,negative_prompt,status[QUEUED, PROCESSING, COMPLETED, FAILED],result_url,error_message,created_at,completed_at).image_presets: Anime and art style presets (idUUID PK,name,positive_prompt_prefix,negative_prompt_preset,is_system_preset).image_usage: Daily/monthly user quota tracking (user_idPK,provider_id,images_generated_today,last_reset_at).
2.9. Giveaways
giveaways: Persistent giveaway records (idUUID PK,guild_id,channel_id,message_id,prize,winner_count,starts_at,ends_at,is_ended,requirementsJSON,created_by).giveaway_entries: User entries into active giveaways (giveaway_id,user_id,bonus_multiplier,entered_at).giveaway_winners: Recorded winners with roll timestamps (giveaway_id,user_id,won_at,is_reroll).
2.10. Streamer Platforms (Twitch, YouTube, TikTok)
streamers: Multi-platform creator registry (idUUID PK,platform[TWITCH, YOUTUBE, TIKTOK, FACEBOOK],platform_user_id,username,display_name,avatar_url,is_live,last_checked_at).stream_subscriptions: Guild notification subscriptions (idUUID PK,streamer_id,guild_id,channel_id,custom_message,mention_role_id,created_at). At most 25 per guild;StreamAlertService(used by/streamand the dashboard) lets the repository generate the uuid IDs. The 1.4.0 migration derives UUID v5 IDs from the legacy IDs (legacyUuid), so re-runs match.stream_events: Live broadcast session tracking (idUUID PK,streamer_id,stream_id,title,game_name,viewer_count,started_at,ended_at).stream_announcements: Idempotency records guaranteeing exactly-once delivery (idUUID PK,idempotency_keyUNIQUE,guild_id,channel_id,message_id,announced_at).stream_assets: Downloaded and validated thumbnails cached on Discord CDN (stream_idPK,original_url,discord_attachment_url,file_hash,cached_at).
2.11. Free Games Announcer
free_games: Free promotional games registry (idString PK,provider[EPIC, STEAM, GOG],title,store_url,thumbnail_url,start_date,end_date).free_game_announcements: Recorded guild announcements preventing re-announcement (game_id,guild_id,channel_id,message_id,announced_at).free_game_channels: Where a guild's announcements go (guild_idPK,channel_id,mention_role_idnullable,created_at). The role is the only mention an announcement may ping.
2.12. Waifu Trading Card Game & Gamification (Flagship Subsystem)
waifu_sources: Ingestion sources (idString PK,name,base_url,attribution_text,is_active).waifu_assets: Ingested & hashed anime character images (idtext PK,source_id,source_image_id,character_name,anime_title,image_hashUNIQUE,local_storage_path,discord_cdn_url,is_deleted_by_request,tagsJSON,created_at).waifu_cards: Collectible card definitions (idtext PK,asset_idtext,name,rarity[COMMON, UNCOMMON, RARE, SUPER_RARE, ULTRA_RARE, SECRET_RARE, SIR, MYTHIC],element[FIRE, WATER, EARTH, LIGHTNING, ICE, LIGHT, SHADOW],attackInt,defenseInt,speedInt,healthInt,crit_rateFloat,skill_name,skill_description,passive_name,passive_description,collection_numberInt,is_active).user_cards: Instances of cards owned by players (idUUID PK,user_id,card_idtext,serial_numberInt,levelInt,expInt,state[IDLE, EQUIPPED, IN_TRADE, IN_MARKET],obtained_at).game_items: Master catalog of equipments, accessories, and consumables (idUUID PK,codeUNIQUE,name,description,type[EQUIPMENT, ACCESSORY, CONSUMABLE],subtype[WEAPON, ARMOR, RELIC, RING, AMULET, TALISMAN, HP_POTION, MANA_POTION, ENERGY_RESTORE],rarity[COMMON, UNCOMMON, RARE, SUPER_RARE, ULTRA_RARE, SECRET_RARE, SIR, MYTHIC],base_statsJSON,battle_perksJSON,consumable_effectJSON,is_shop_buyableBoolean,shop_priceBigInt,max_daily_purchasesInt,is_tradeableBoolean,created_at).user_inventory_items: Item instances owned by players (idUUID PK,user_id,item_id,quantityInt,enhancement_levelInt,equipped_to_card_idUUID Nullable,slot[WEAPON, ARMOR, RELIC, RING, AMULET, TALISMAN, NONE],state[IDLE, EQUIPPED, IN_TRADE, IN_MARKET],obtained_from[SHOP, BATTLE, DUNGEON, BOSS, QUEST, ACHIEVEMENT, TRADE],created_at,updated_at).player_energy: Player stamina pool & lifecycle (user_idSnowflake PK,current_energyInt,max_energyInt,bonus_energyInt,daily_energy_pots_usedInt,last_replenished_atTimestamp,last_reset_dateDate,updated_atTimestamp).game_achievements: Master achievement specifications (idUUID PK,codeUNIQUE,title,description,category[COLLECTOR, COMBATANT, TYCOON, BLACKSMITH, DEVOTION, GUILD_HERO],tier[BRONZE, SILVER, GOLD, PLATINUM, MYTHIC],requirement_typeString,requirement_targetInt,reward_xpInt,reward_creditsBigInt,reward_card_idUUID Nullable,reward_item_idUUID Nullable,reward_consumablesJSON,reward_titleString,badge_iconString,is_hiddenBoolean,created_at).user_achievements: User progress and unlock claims (idUUID PK,user_id,achievement_id,progressInt,is_unlockedBoolean,is_claimedBoolean,unlocked_atTimestamp,claimed_atTimestamp).dungeon_seasons: Seasonal dungeon instances (idString PK [e.g. 'TUTORIAL', 'S1', 'S2', 'S3'],name,description,theme_element[FIRE, WATER, EARTH, LIGHTNING, ICE, LIGHT, SHADOW, ALL],seasonal_affixesJSON,scaling_model[LINEAR, POLYNOMIAL, EXPONENTIAL, HYBRID],scaling_paramsJSON,is_tutorialBoolean,is_activeBoolean,starts_atTimestamp,ends_atTimestamp,created_atTimestamp).dungeon_floors: Floor stages within a season (idUUID PK,season_idString FK referencesdungeon_seasons(id),floor_numberInt,name,energy_costInt,min_player_levelInt,enemy_lineupJSON,floor_affixesJSON,is_boss_floorBoolean,first_clear_rewardsJSON,repeat_rewards_tableJSON,created_atTimestamp).dungeon_bosses: Seasonal anime-character bosses synced bypnpm tcg:boss-builder(idString PK<season_id>:<key>,season_idString FK referencesdungeon_seasons(id),key,name,anime_title,element,tier[STANDARD, MINI_BOSS, MAJOR_BOSS],titleNullable,flavor_textNullable,asset_idFK referenceswaifu_assets(id)Nullable,anilist_idInt Nullable,danbooru_tagNullable,image_pathNullable,definitionJSON [combat overrides validated bybossDefinitionSchema: stats or statMultipliers, skill, enrage, wardLayers, maxTurns],signature_drop_codeNullable,is_activeBoolean,created_at,updated_at, UNIQUE(season_id,key)). Floors reference bosses throughdungeon_floors.enemy_lineup = [{ bossId, overrides? }]; season difficulty lives indungeon_seasons.scaling_params(seasonCurveSchema: baseStats, growth parameters, boss multipliers, enrage, affixStartFloor). Precedence: floor overrides > boss definition > season curve > code defaults.user_dungeon_progress: Player clearance records per season (idUUID PK,user_idSnowflake FK referencesusers(id),season_idString FK referencesdungeon_seasons(id),highest_cleared_floorInt,attempts_countInt,clear_countInt,first_cleared_atTimestamp Nullable,last_attempt_atTimestamp,created_atTimestamp,updated_atTimestamp, UNIQUE(user_id,season_id)).tcg_system_configs: Administrative gameplay parameters (keyString PK,valueJSON,updated_bySnowflake,updated_atTimestamp).card_trades: Atomic P2P two-party trade proposals (idUUID PK,sender_user_id,receiver_user_id,offered_card_idsJSON,requested_card_idsJSON,offered_creditsBigInt,requested_creditsBigInt,status[PENDING, ACCEPTED, REJECTED, CANCELLED],created_at,resolved_at).market_listings: Community player marketplace listings (idUUID PK,seller_user_id,user_card_id,priceBigInt,tax_paidBigInt,status[ACTIVE, SOLD, CANCELLED, EXPIRED],created_at,expires_at).waifu_guilds: In-game player guilds (idUUID PK,nameUNIQUE,leader_user_id,levelInt,guild_xpBigInt,guild_bankBigInt,created_at).waifu_guild_members: Members of Waifu Guilds (guild_id,user_id,rank[LEADER, OFFICER, MEMBER],contribution_xpBigInt,joined_at).quests: Daily and weekly quest templates (idUUID PK,title,description,reward_xpInt,reward_creditsBigInt,reward_card_id,target_countInt,type).bosses: Server and guild cooperative raid bosses (idUUID PK,name,total_hpBigInt,current_hpBigInt,element,rewards_tableJSON,starts_at,ends_at).boss_runs: Player attacks on raid bosses (idUUID PK,boss_id,user_id,damage_dealtBigInt,cards_usedJSON,performed_at).
2.13. Interactive Mini-Games
mini_games: Registered games catalog (idString PK,name,min_players,max_players,allow_wagers,cooldown_seconds).game_sessions: Active game state instances (idUUID PK,game_id,guild_id,channel_id,host_user_id,opponent_user_id,wager_amountBigInt,stateJSON,status[WAITING, IN_PROGRESS, COMPLETED, TIMED_OUT],winner_user_id,created_at).game_statistics: Per-user game statistics (user_id,game_id,winsInt,lossesInt,tiesInt,total_wageredBigInt,net_profitBigInt).
2.14. Server Utilities & Scheduled Reminders
reaction_roles: Message-to-role bindings (idUUID PK,guild_id,channel_id,message_id,emoji_or_component_id,role_id).auto_voice_configs: Auto voice channel generators (idUUID PK,guild_id,parent_channel_id,channel_name_template,user_limit,bitrate).reminders: Persistent reminder scheduler (idUUID PK,user_id,guild_id,channel_id,message,trigger_at,repeat_interval[NONE, DAILY, WEEKLY],is_completed).guild_welcomer/guild_farewell: Welcome and farewell cards (guild_idPK,channel_id(empty when not set up),message_template,card_theme,background_url,background_file,text_color,is_enabled,text_message_enabled(default false),text_message(default empty)). At most one ofbackground_urlandbackground_fileis set;background_filenames an upload instorage/welcomer-backgrounds. The two text columns come from migration0001_welcomer_text_message. They hold the optional text message sent with the card (see docs/modules.md).audit_logs: Security and administrative action audit trails (idUUID PK,guild_id,actor_user_id,action,detailsJSON,ip_address,user_agent,created_at).GuildConfigServicewritesguild_config.<module>.updateentries withdetails = { source: 'dashboard' | 'cli', changes: [{ field, before, after }] }.
2.15. Web Dashboard
The dashboard uses these tables. The page design is in dashboard.md. The decision about sessions and credential theft is in ADR-013.
web_sessions: Opaque server-side dashboard sessions. The primary keyidis the SHA-256 hash (hex) of the session cookie; the raw value exists only in the__Host-ririko_sessioncookie. Columns:user_id,created_at,last_seen_at,expires_at(12 h absolute),ip_address,user_agent,step_up_at,discord_access_tokenanddiscord_refresh_token(AES-256-GCM ciphertexts in thev<key version>.<iv>.<tag>.<data>format, bound to the row as additional authenticated data), anddiscord_token_expires_at. The key version is part of each ciphertext, so there is no separatekey_versioncolumn. Indexes onuser_id(for "sign out everywhere") andexpires_at(expiry cleanup).webauthn_challenge(<purpose>:<challenge>) andwebauthn_challenge_expires_athold the pending WebAuthn challenge, cleared by the single update that consumes it.guild_config_versions: Change feed for settings written outside the bot process. Primary key (guild_id,module),version(incremented on every write),updated_at(indexed). The dashboard and CLI bump it in the same transaction as the settings write; the bot'sGuildConfigWatcherpolls recent rows and emitsguild:configChanged.web_passkeys: WebAuthn credentials per user.idis the credential ID (base64url);user_id(indexed),name,public_key(COSE, base64url),counter,transports(JSON),device_type(singleDeviceormultiDevice),backed_up,created_at,last_used_at. Only public keys are stored.web_known_devices: Browsers each user has signed in from. Primary key (user_id,device_hash), wheredevice_hashis the SHA-256 (hex) of the random__Host-ririko_devicecookie;first_seen_at,last_seen_at(indexed). A sign-in whose device is not listed triggers a new-device DM. Rows unseen for a year (the cookie lifetime) are deleted at sign-in.command_usage_daily: Command usage counters. Primary key (guild_id,day,command_name), wheredayis the UTC day asYYYY-MM-DD;count. The bot'sCommandUsageRecorderbuffers counts from the router'sonCommandRunhook and adds them every 60 seconds withcount = count + excluded.count; rows older than 90 days are deleted.bot_status: One row per bot process (id = 'bot'):gateway_ping_ms(null before the first heartbeat),guild_count,version,started_at,updated_at. Written byBotStatusReporterevery 30 seconds; a row older than 90 seconds means the bot is offline. The health probes can reuse it.guild_voice_activity:guild_idPK,channels(JSON list of{ channelId, members }for voice channels with at least one member who is not a bot),updated_at. Written byBotStatusReporteronly when a guild's channels change; guilds with no active channel have no row.- Guild TCG settings on
guild_settings:tcg_drops_enabled(default false),tcg_drop_channel_id(null counts every channel),tcg_drop_message_threshold(50),tcg_drop_start_hour(8),tcg_drop_end_hour(23),tcg_drop_claim_timeout_seconds(60),tcg_drop_cooldown_minutes(5) andtcg_manager_role_id. Edited through thetcgmodule ofGuildConfigService;DropManagerreads them throughdropConfigFromSettings.
3. Indexing & Transaction Integrity Rules
- Foreign Key Enforcement: In SQLite, every connection explicitly executes
PRAGMA foreign_keys = ON;. - ACID Transactions: Balances, card trading, market purchases, equipment enhancements, and giveaway completions are strictly executed within Drizzle transactions (
db.transaction(...)). - Compound Indexes: Essential for high query throughput:
moderation_cases(guild_id, case_number)economy_transactions(user_id, created_at DESC)ai_messages(conversation_id, created_at ASC)stream_announcements(idempotency_key)user_cards(user_id, state)user_inventory_items(user_id, state)user_achievements(user_id, is_claimed)game_items(type, rarity)dungeon_floors(season_id, floor_number)user_dungeon_progress(user_id, season_id)
- Time Indexes for the Retention Purge: a delete by time alone needs an index on that time column. Migration
0003_data_retention_indexesaddsai_messages(created_at),ai_conversations(updated_at),music_history(played_at),image_jobs(created_at)andaudit_logs(created_at). The older(guild_id, time)and(user_id, time)indexes do not serve them. The migration is additive; it builds the indexes with plainCREATE INDEX, which blocks writes to a table while PostgreSQL builds its index.
4. Data Retention
The bot deletes data past a fixed period so the privacy policy can state the periods. DataRetentionService (packages/services/src/privacy/data-retention.service.ts) runs once per UTC day, starting one minute after the bot is ready, and checks every hour whether the current day has had a complete purge. The periods are the constant RETENTION_DAYS, so change them only together with the policy:
| Data | Table and column | Kept for | What is deleted |
|---|---|---|---|
| AI chat | ai_messages.created_at | 90 days | Messages older than the period. |
| AI chat | ai_conversations.updated_at | 90 days | Conversations not updated within the period that have no message left. The summary (text derived from chat) of every conversation not updated within the period is cleared. |
| Music history | music_history.played_at | 90 days | Plays older than the period. |
| Image jobs | image_jobs.created_at | 90 days | Jobs older than the period whose status is COMPLETED or FAILED. Jobs still QUEUED or PROCESSING stay. |
| Audit log | audit_logs.created_at | 365 days | Entries older than the period. |
| Server data | guilds.left_at | 90 days | Everything stored for a server the bot left, then its guilds row (section 4.1). The period is RETENTION_DAYS.departedGuild. |
How the purge behaves:
- The cutoff is the current time minus the period. A row exactly at the cutoff is kept; only rows older than it are deleted.
- Each repository has
deleteOlderThan(cutoff), which returns the number of rows it deleted. It deletes in batches of 5000 rows (PURGE_BATCH_SIZE): one statement removes at most 5000 primary keys found through the time index, and the loop ends when a batch comes back short. A first purge of a very large table therefore never runs as one long statement. The batches are not one transaction. - The tables are purged one after another. A failure in one table is logged and the others still run; the day then stays open, so the next hourly check tries again.
- The purge logs one line,
[DataRetention] Purge finished: ai=…, musicHistory=…, imageJobs=…, auditLog=…, departedGuild=…, with the counts and no row content (departedGuildcounts the servers purged). - Not covered here:
command_usage_daily(90 days,CommandUsageRecorder),web_sessionsandweb_known_devices(cleaned when they expire). Servers the bot left are covered in section 4.1.
4.1. Delete a Server's Data After the Bot Leaves
When the bot leaves or is removed from a server, GuildRepository.markInactive sets guilds.is_active to false and guilds.left_at to the current time. It never moves left_at of a server that is already inactive. Adding the bot back (upsert, also through recordInvite) sets is_active to true and clears left_at, so the server keeps all its data. Migration 0004_guild_left_at adds the column and sets left_at to the time the migration runs for servers that are already inactive, so their 90 days start at the deploy and nothing is purged before 90 days after it.
The same daily purge deletes the data of every server whose left_at is more than 90 days ago (RETENTION_DAYS.departedGuild). A server that left exactly at the cutoff is kept; one that left a millisecond earlier is purged. GuildDataRepository (packages/database/src/repositories/guild-data.repository.ts) does it:
- No table has a foreign key to
guilds, so nothing cascades.GUILD_DATA_INVENTORY(guild-data-inventory.ts) lists every table with aguild_idcolumn and what the purge does with it:delete(37 tables): the rows with thisguild_idare deleted.detach(3 tables):economy_transactions,music_saved_playlistsandremindersbelong to users and only record the server where they were made. The purge keeps the rows and setsguild_idto null.exclude(1 table):waifu_guild_members.guild_idis the id of a WaifuGuild (the card game), not a Discord server.GUILD_CHILD_DATAlists the tables that hold no server id but belong to rows that are deleted:ai_messages(viaai_conversations),giveaway_entriesandgiveaway_winners(viagiveaways), andadventure_choices(viaadventure_sessions). They are deleted first.- Rows with a null
guild_id(a DM conversation, an owner console audit entry) belong to no server and stay.
- Tables with no
guild_idare never touched: user balances, cards, inventories, AI preferences and consent records. - The test
guild-data-inventory.test.tsreads both Drizzle schemas and fails when a table with aguild_idcolumn is not in the inventory. A new table with aguild_idcolumn must be added toGUILD_DATA_INVENTORY(and a table that belongs to its rows toGUILD_CHILD_DATA) in the same change. - One server is one transaction. Inside it the purge reads the
guildsrow again (locked withFOR UPDATEon PostgreSQL) and does nothing unless the server is still inactive and left before the cutoff, so a server the bot was added back to a moment ago keeps its data. A failure rolls that server back; the others still run, and the day stays open for a retry. - Each table is deleted in batches of 5000 rows (
deleteInBatches), found byrowid(SQLite) orctid(PostgreSQL). The last step deletes theguildsrow. - The log has one line per server with row counts per table and no content, for example
[DataRetention] Deleted server <id>: xp_accounts=12, guilds=1; unlinked from the server: reminders=2. - Servers the bot left before
guildsexisted were never recorded, so their data is not purged by this rule.
4.2. Erase One User's Data
ririko user erase <discord_user_id> answers an erasure request (see development.md). UserDataRepository (packages/database/src/repositories/user-data.repository.ts) runs it, and USER_DATA_INVENTORY (user-data-inventory.ts) says what happens to each column that can hold a Discord user ID:
delete: the rows belong only to the user (AI chats, consents, preferences, balance, inventory, cards, XP, reminders, music history, image jobs, sessions, passkeys, known browsers, memberships, and theusersrow). They are deleted. Tables that hold no user column but belong to deleted rows (ai_messages,music_playlist_tracks,adventure_choices) are listed inUSER_CHILD_DATAand deleted first.tombstone: the row is shared with other people (a ledger entry, a trade, a giveaway winner, a case where the user is the moderator, the creator of a giveaway). The row stays and the ID becomeserased:<first 16 hex digits of the SHA-256 of the ID>.audit_logs.actor_user_idalso loses the IP address and the browser name. A Waifu guild that the user leads alone is deleted; one with other members keeps them and loses the name of its leader.keep: the column holds no Discord user ID (streamers.platform_user_idis the ID on Twitch, YouTube or TikTok).- Moderation records are kept unless the host adds
--include-moderation: then cases about the user get a tombstone, and notes and warnings about the user are deleted. Cases, notes and warnings where the user is the moderator or author always get a tombstone. USER_TEXT_DATAlists the JSON columns that can carry an ID in a value or a key:economy_transactions.metadata(the other side of a transfer),audit_logs.details,game_sessions.stateand, with--include-moderation,moderation_cases.metadata. The quoted ID is replaced by the tombstone, as text, in the rows of every user.- Pending trades of the user are cancelled first and the cards of the other party go back from
IN_TRADEtoIDLE, so no card stays locked in a trade that cannot finish. - The whole erasure is one transaction, with the audit entry in it. A failure rolls everything back.
- The test
user-data-inventory.test.tsreads both Drizzle schemas and fails when a column that ends inuser_id,_by,_by_id,owner_idormoderator_idis not in the inventory. A new column that can hold a Discord user ID must be added toUSER_DATA_INVENTORYwith its action in the same change, and a new JSON column that can hold one toUSER_TEXT_DATA. A column with another name that holds a user ID is not found by the guard test: add it by hand. - Free text is not searched: the
reasonof a moderation case or warning, and the content of a note, can name the user in words. The host decides about those when it handles the request.