Skip to content
FluffBoost

Database & schema

The Drizzle schema, the tables, and how migrations work.

FluffBoost uses PostgreSQL through Drizzle ORM. The schema in apps/discord/src/database/schema.ts is the single source of truth — Drizzle reads it at runtime, so there's no code-generation step.

Tables

The Drizzle identifier is what you use in code; the SQL name (quoted, PascalCase) is what you use in psql or raw SQL.

DrizzleSQL tablePurpose
guilds"Guild"Per-server config: channel, schedule (frequency/time/timezone/day), isPremium, lastMotivationSentAt
motivationQuotes"MotivationQuote"The approved quote library
suggestionQuotes"SuggestionQuote"Every user submission, with its review status, reviewedBy and reviewedAt
discordActivities"DiscordActivity"Bot status entries, with a type enum

Three enums back these: motivationFrequencyEnum (Daily / Weekly / Monthly), discordActivityTypeEnum (Custom / Listening / Streaming / Playing) and suggestionStatusEnum (Pending / Approved / Rejected). The matching TypeScript types are MotivationFrequency, DiscordActivityType and SuggestionStatus.

Working with the schema

After editing schema.ts:

bun run db:push

Pushes the schema straight to your dev database. Fast, no migration files.

bun run db:generate   # create a migration from the schema diff
bun run db:migrate    # apply pending migrations

Open a visual browser with bun run db:studio.

How scheduling reads the data

The send-motivation worker job runs every minute and works out which guilds are due right now with the schedule evaluator in apps/discord/src/utils/scheduleEvaluator.ts (dayjs with timezone support). A guild stays due for six hours after its scheduled time (the catch-up window) until something is sent for that occurrence. Due guilds are handled in chunks of 25.

Each guild row is claimed before the send: an atomic UPDATE stamps lastMotivationSentAt only if it is still older than the scheduled occurrence, so two workers can never both send the same occurrence.

  • A temporary failure (a Discord 5xx, a network error, a rate limit, or a rejected token) releases the claim, so a later tick inside the catch-up window retries.
  • A permanent failure (a missing or non-text channel, missing access or permissions) keeps the claim, so the bot does not retry every minute.
  • Each send carries a deterministic nonce with enforceNonce, so a retry that reaches Discord within its dedupe window returns the existing message instead of posting a second one.
  • Delivery is at most once per occurrence. If the process dies between the claim and the send, that guild misses that occurrence; chunking limits how many guilds one crash can affect.

On shutdown, or when the job hits its 10-minute limit, delivery stops claiming new guilds between chunks; the unclaimed ones roll over to the next tick.

Keep the claim before send(). Moving the lastMotivationSentAt update after a successful send looks safer but reintroduces the race where two workers both deliver the same quote.

Migrations run at deploy time. The container entrypoint runs apps/discord/src/database/migrate.ts before starting the bot, so a fresh database is created and kept up to date automatically. The repository ships both the migration SQL and drizzle/meta/_journal.json. If that journal is missing, the script exits with code 1 and the deploy fails rather than starting on an unmigrated schema. The migrations folder is resolved from the script's own location, not the working directory. A database that already has the tables but no migration history (for example one built with db:push) is refused until you baseline it, or, for a Prisma-era or drifted schema, reconcile it. Set SKIP_MIGRATIONS=true to opt out for a single deploy; the bot still refuses to start when the tables do not exist at all.

Don't mix db:push with migrations in production. db:push reshapes the database directly without recording anything in the migration history, so a later db:migrate can find the schema already changed and fail or drift. Use db:push for local development only; let production go through generated migrations.

On this page