Scripting Guide

Lesson 10: Database Patterns

Battle-tested shapes for common features: leaderboards, cooldowns, per-server settings, and one-time flags.

With the db basics from Lesson 9, most real features reduce to four patterns. Each pattern below is a complete, uploadable agent.

Pattern 1: leaderboard

Accumulate points with $inc, then read the top N with sort and limit. This is the core of XP systems, economy bots, and contest trackers.

leaderboard.js (event: messageCreate)
import { EmbedBuilder } from "discord";

export async function onMessage(message, db) {
  if (message.author.bot) return;

  // 1 XP per message.
  await db.updateOne(
    "xp",
    { guildId: message.guildId, userId: message.author.id },
    { $inc: { points: 1 }, $set: { name: message.author.username } },
    { upsert: true },
  );

  if (message.content !== "!top") return;

  const top = await db.find(
    "xp",
    { guildId: message.guildId },
    { sort: { points: -1 }, limit: 10 },
  );

  const lines = top.map(
    (doc, i) => (i + 1) + ". " + doc.name + " - " + doc.points + " XP"
  );

  await message.reply({
    embeds: [
      new EmbedBuilder()
        .setTitle("Leaderboard")
        .setDescription(lines.join("\n") || "No data yet.")
        .setColor("Gold"),
    ],
  });
}

Pattern 2: cooldowns without timers

The sandbox has no setTimeout, so time-based logic is done by storing timestamps and comparing on the next event. This handles per-user command cooldowns, daily rewards, and rate limiting your own features.

daily.js (event: messageCreate)
const DAY_MS = 24 * 60 * 60 * 1000;

export async function onMessage(message, db) {
  if (message.author.bot) return;
  if (message.content !== "!daily") return;

  const key = { guildId: message.guildId, userId: message.author.id };
  const doc = await db.findOne("daily", key);
  const now = Date.now();

  if (doc && now - doc.lastClaim < DAY_MS) {
    const hoursLeft = Math.ceil((DAY_MS - (now - doc.lastClaim)) / 3600000);
    await message.reply("Already claimed. Try again in ~" + hoursLeft + "h.");
    return;
  }

  await db.updateOne(
    "daily",
    key,
    { $set: { lastClaim: now }, $inc: { coins: 100 } },
    { upsert: true },
  );
  await message.reply("You claimed 100 coins!");
}

Pattern 3: per-server settings

Keep one settings document per server and read it at the top of agents that need configuration. Admins change settings with a command instead of you re-uploading code.

settings.js (event: messageCreate)
export async function onMessage(message, db) {
  if (message.author.bot) return;

  // Admin command: !set logchannel <id>
  if (message.content.startsWith("!set logchannel ")) {
    if (!message.member.permissions.includes("ManageGuild")) return;

    const channelId = message.content.split(" ")[2];
    await db.updateOne(
      "settings",
      { guildId: message.guildId },
      { $set: { logChannelId: channelId } },
      { upsert: true },
    );
    await message.reply("Log channel saved.");
    return;
  }

  // Any other agent can now read the setting:
  // const settings = await db.findOne("settings", { guildId: message.guildId });
  // if (settings?.logChannelId) { ... }
}

Pattern 4: one-time flags and idempotency

When an action must happen at most once (first-join greeting, claim buttons, starboard posts), db.exists plus an insert acts as the lock.

first-join.js (event: guildMemberAdd)
export async function onJoin({ member, guild }, db) {
  const key = { guildId: guild.id, userId: member.id };

  // Returning members do not get a second welcome package.
  const seen = await db.exists("welcomed", key);
  if (seen) return;

  await db.insertOne("welcomed", { ...key, at: Date.now() });

  const dm = await member.send(
    "Welcome to " + guild.name + "! Read the rules in #rules to get started."
  );
  // dm may be { error } if their DMs are closed; that is fine here.
}

Designing your collections

  • Always store `guildId` in multi-server setups; one cluster serves all your servers.
  • Filter on the fields you store. { guildId, userId } pairs are the workhorse key for per-member data.
  • Keep documents small. Store IDs and numbers, not whole payload objects.
  • Name collections by feature (xp, warnings, settings), not by event.
  • Remember the caps: reads return at most 200 documents, writes accept at most 100 at once, and queries are cut at 3 seconds.

Exercise

Combine patterns 1 and 2 into an economy: !work grants 50 coins with a 1 hour cooldown, !balance shows your coins, and !rich shows the top 5. Then add a settings command !set workreward <n> (ManageGuild only) that changes the reward without touching code.