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.
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.
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.
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.
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.