From 339ef06ff97bc86d9342e64682a5855347a984c3 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 21 Sep 2023 07:07:43 +0200 Subject: [PATCH 1/6] Quota: Refactor how Quotas are being handled also renamed limits to quota I believe this new approach would allow me and bot hosters to add, implement or change the quota behavior more easily. Reimplemented the currently existing "Message count" limit to use the new IQuota, refactoring a code *a little*. --- src/IQuota.ts | 46 ++++++++++++ src/commands/check-limit.ts | 43 ++++------- src/configDefault.ts | 17 +++-- src/execution.ts | 74 ++----------------- src/quota/messageCount.ts | 141 ++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 107 deletions(-) create mode 100644 src/IQuota.ts create mode 100644 src/quota/messageCount.ts diff --git a/src/IQuota.ts b/src/IQuota.ts new file mode 100644 index 0000000..2dbf23c --- /dev/null +++ b/src/IQuota.ts @@ -0,0 +1,46 @@ +import { GuildMember, User } from "discord.js"; + +import { apiRequest } from "./execution"; + +export interface userQuotaData { + /** How much quota does the user have */ + quota: number; + /** How much used quota does the user have */ + used: number; + /** Name of the quota unit */ + unitName?: string; + /** String representation of quota */ + toString(): string; +} + +export interface userQuotaRecoveryData extends userQuotaData { + /** Timestamp until asked QuotaRecovery will get fully recovered, Infinity if never */ + recoveryTimestamp?: number; +} +/** + * Represents an quota engine that instance could use + */ +export default interface IQuota { + /** + * Gets user remining quota (or lack of it) + * @param request The request data + */ + checkUser( + user: User | GuildMember | string, + request: apiRequest + ): Promise; + //checkUser(user: User | GuildMember | string, request: RequestMessage): userQuotaData; + /** Name of the Quota engine */ + name?: string; + /** + * Checks recovery of quota units for a given user + * @param user the user to check + * @param request The request data + * @param unitCount for how many units to check for recovery + */ + getUserQuotaRecovery( + user: User | GuildMember | string, + request: apiRequest, + unitCount?: number + ): Promise; +} diff --git a/src/commands/check-limit.ts b/src/commands/check-limit.ts index 870874f..616bb11 100644 --- a/src/commands/check-limit.ts +++ b/src/commands/check-limit.ts @@ -1,7 +1,7 @@ -import { ApplicationCommandType, ChatInputCommandInteraction, ApplicationCommandOption, ApplicationCommandOptionType } from "discord.js"; +import { ApplicationCommandType, ChatInputCommandInteraction, ApplicationCommandOption, ApplicationCommandOptionType, APIEmbedField } from "discord.js"; import Command from "../command"; -import { getUserLimit, getNthUseInLimitTimestamp } from "../execution"; +import { config } from "../index"; export default class MyLimit extends Command implements Command { name = "check-limit"; @@ -27,40 +27,23 @@ export default class MyLimit extends Command implements Command { if (recoveryFor <= 0) recoveryFor = 1; - const userLimitPromise = getUserLimit(interaction.user, interaction.createdAt); - const nthUseInLimitTimestampPromise = getNthUseInLimitTimestamp( - interaction.user, - interaction.createdAt, - recoveryFor, - ); + const userQuotaRecovery = await config.quota.getUserQuotaRecovery(interaction.user, interaction, recoveryFor); - const userLimit = await userLimitPromise; - const nthUseInLimitTimestamp = await nthUseInLimitTimestampPromise; + const fields: APIEmbedField[] = []; - if (userLimit === false || nthUseInLimitTimestamp === false) { - await interaction.reply({ - embeds: [{ - author: { name: interaction.user.username, icon_url: interaction.user.displayAvatarURL({ size: 128 }) }, - description: "User is a VIP, so there is no limit", - }], - ephemeral: ephemeral, - }); - return; - } + fields.push({ name: "Quota", inline: true, value: `${userQuotaRecovery.quota} ${userQuotaRecovery.unitName}`.trim() }); + fields.push({ name: "Usage", inline: true, value: `${userQuotaRecovery.used} ${userQuotaRecovery.unitName}`.trim() }); + + if (userQuotaRecovery.recoveryTimestamp !== undefined) fields.push({ + name: `Recovery for ${recoveryFor} message${recoveryFor>1 ? "s" : ""}`, + value: userQuotaRecovery.recoveryTimestamp === Infinity ? "never" : + `` + }); await interaction.reply({ embeds: [{ author: { name: interaction.user.username, icon_url: interaction.user.displayAvatarURL({ size: 128 }) }, - fields: [ - { name: "Limit", inline: true, value: String(userLimit.limit) }, - { name: "Usage", inline: true, value: String(userLimit.limit - userLimit.remaining) }, - { - name: `Recovery for ${recoveryFor} message${recoveryFor>1 ? "s" : ""}`, - value: nthUseInLimitTimestamp === null ? "never" : - // timestamp of the nth use in limit + 24 hours - ``, - }, - ] + fields: fields, }], ephemeral: ephemeral, }); diff --git a/src/configDefault.ts b/src/configDefault.ts index a4a6bf3..608806b 100644 --- a/src/configDefault.ts +++ b/src/configDefault.ts @@ -4,6 +4,9 @@ import { CreateChatCompletionRequest as ChatCompletionRequestData, } from "openai"; +import IQuota from "./IQuota"; +import MessageCount from "./quota/messageCount"; + export interface IConfigRequired { readonly calendarParams: Intl.DateTimeFormatOptions; /** Tokens to authentiate with */ @@ -24,11 +27,11 @@ export interface IConfigRequired { /** Maximum total token usage for messages (counted locally) */ readonly tokens: number; }; - /** Default limits for user inteacting with the bot */ - readonly userLimits: { - /** How much requests can an user make if it's not overriden in a database entry */ - readonly requests: number; - }; + /** + * Quota parameters to use when checking limits + * you can find some in `./quota` + */ + readonly quota: IQuota; } export type IConfig = Partial; @@ -85,7 +88,5 @@ const defaultConfig: IConfigRequired = { messages: envAsNumber("READ_LIMITS__MESSAGES") ?? 50, tokens: envAsNumber("READ_LIMITS__TOKENS") ?? 2048, }, - userLimits: { - requests: envAsNumber("USER_LIMITS__REQUESTS") ?? 25, - }, + quota: new MessageCount(envAsNumber("QUOTA__DEFAULT_QUOTA"), envAsNumber("QUOTA__LOOKBACK")) }; diff --git a/src/execution.ts b/src/execution.ts index a755ec6..aa99e73 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -8,7 +8,7 @@ import toOpenAIMessages from "./toOpenAIMessages"; import FunctionManager from "./funcitonManager"; type NonNullableInObject = { [k in keyof T]: k extends V ? NonNullable : T[k] }; -type apiRequest = DiscordApi.Message | DiscordApi.RepliableInteraction; +export type apiRequest = DiscordApi.Message | DiscordApi.RepliableInteraction; export type RequestMessage = apiRequest & NonNullableInObject; class ChannelsRunningValue extends Array { @@ -60,69 +60,6 @@ export function getAuthor(request: apiRequest) { return request.user; } -/** - * gets user remaining limit (or lack of it) - * @param user the user to check - * @param requestTimestamp the timestamp of the user request - * @returns object containing the limit and remaining usage or `false` if there is no limit - */ -export async function getUserLimit(user: string | { id: string }, requestTimestamp: Date) { - const userId: string = typeof user === "string" ? user : user.id; - - const userLimits = await database.limits.findUnique({ - where: { user: BigInt(userId) } - }); - - if (userLimits?.vip) return false; - - const usedLimit = (await database.usage.count({ - select: { _all: true }, - where: { - user: BigInt(userId), - timestamp: { - gte: new Date(requestTimestamp.getTime() - 1000 * 60 * 60 * 24 /* 24 hours */) - } - }, - }))._all; - - if (!userLimits || !userLimits.limit) return {limit: config.userLimits.requests, remaining: config.userLimits.requests - usedLimit}; - - return {limit: userLimits.limit, remaining: userLimits.limit - usedLimit}; -} - -/** - * gets the timestamp of nth use inside time limit - * @param user the user or id to check - * @param requestTimestamp the timestamp of the request (message/interaction createdAt) - * @param nth which timestamp in time limit to get (orderedd from oldest to newest) - * @returns `false` if user is vip - * @returns `null` if there is no request - * @returns `Date` timestamp of the nth request - */ -export async function getNthUseInLimitTimestamp(user: string | { id: string }, requestTimestamp: Date, nth = 1) { - const userId: string = typeof user === "string" ? user : user.id; - - const userLimits = await database.limits.findUnique({ - where: { user: BigInt(userId)} - }); - - if (userLimits?.vip) return false; - - const nthUseInLimit = await database.usage.findFirst({ - where: { - user: BigInt(userId), - timestamp: { - gte: new Date(requestTimestamp.getTime() - 1000 * 60 * 60 * 24 /* 24 hours */) - } - }, - orderBy: { timestamp: "asc" }, - skip: nth - 1, - }); - - if (!nthUseInLimit) return null; - return nthUseInLimit.timestamp; -} - /** * Replies to a request * @param request the request to reply to @@ -194,9 +131,9 @@ export async function queueRequest(request: apiRequest) { return; } - const userLimit = await getUserLimit(getAuthor(request), request.createdAt); + const userLimit = await config.quota.checkUser(getAuthor(request), request); - if (userLimit !== false && userLimit.remaining <= 0) { + if (userLimit.used >= userLimit.quota) { if (request instanceof DiscordApi.Message) { request.react("🛑").catch(() => { /* NOTE: We send an informaton about limit reached in DM */ }); if (!request.author.dmChannel) await request.author.createDM(); @@ -204,14 +141,13 @@ export async function queueRequest(request: apiRequest) { embeds: [{ color: 0xff0000, description: - "You've used up your message limit for today,\n" + - `${userLimit.limit} requests in last 24 hours`, + "You've used up your quota,\n" + userLimit.toString(), }] }).catch(() => {/* FIXME: What should the bot do in this case to inform of limit reached?*/}); } else if (request.isRepliable()) { request.reply({ - content: `You've used up your message limit for today, ${userLimit.limit} requests in last 24 hours`, + content: "You've used up your quota\n" + userLimit.toString(), ephemeral: true, }).catch(() => { /* Impossible to get there unless connection lost*/ }); } diff --git a/src/quota/messageCount.ts b/src/quota/messageCount.ts new file mode 100644 index 0000000..2e9bc78 --- /dev/null +++ b/src/quota/messageCount.ts @@ -0,0 +1,141 @@ +import { GuildMember, User } from "discord.js"; + +import IQuota, { userQuotaData, userQuotaRecoveryData } from "../IQuota"; +import { apiRequest } from "../execution"; +import { database } from "../index"; + +/** + * Quota based on Request message count and function calls + */ +export default class MessageCount implements IQuota { + lookback: number; + defaultQuota: number; + + constructor( + defaultQuota: number = 25, + lookback: number = 1000 * 60 * 60 * 24 + ) { + this.defaultQuota = defaultQuota; + this.lookback = lookback; + } + + private getUserQuota(id: string) { + return database.limits.findUnique({ + where: { user: BigInt(id) }, + }); + } + + async checkUser( + user: User | GuildMember | string, + request: apiRequest + ): Promise { + const userId: string = typeof user === "string" ? user : user.id; + + const userQuota = await this.getUserQuota(userId); + + const usedLimit = ( + await database.usage.count({ + select: { _all: true }, + where: { + user: BigInt(userId), + timestamp: { + gte: new Date(request.createdAt.getTime() - this.lookback), + }, + }, + }) + )._all; + + if (userQuota?.vip) return this.createUserQuotaData(Infinity, usedLimit); + if (userQuota?.limit) + return this.createUserQuotaData(userQuota.limit, usedLimit); + return this.createUserQuotaData(this.defaultQuota, usedLimit); + } + + async getUserQuotaRecovery( + user: string | User | GuildMember, + request: apiRequest, + unitCount: number | undefined = 1 + ): Promise { + const userId = typeof user === "string" ? user : user.id; + + const [userQuota, nthUseInLimit] = await Promise.all([ + this.checkUser(userId, request), + database.usage.findFirst({ + where: { + user: BigInt(userId), + timestamp: { + gte: new Date(request.createdTimestamp - this.lookback), + }, + }, + orderBy: { timestamp: "asc" }, + skip: unitCount - 1, + }), + ]); + + return { + ...userQuota, + recoveryTimestamp: nthUseInLimit ? this.lookback + nthUseInLimit?.timestamp.valueOf() : Infinity, + }; + } + + private createUserQuotaData(quota: number, used: number): userQuotaData { + const humanReadable = milisecondsToHumanReadable(this.lookback); + return { + quota: quota, + used: used, + unitName: "messages and model function calls", + toString() { + return `${this.used} of ${this.quota} ${this.unitName} in ${humanReadable}`; + }, + }; + } +} + +function milisecondsToHumanReadable(totalMiliseconds: number): string { + const negative = totalMiliseconds < 0; + if (negative) totalMiliseconds = -totalMiliseconds; + + const totalSeconds = totalMiliseconds / 1000; + const totalMinutes = totalSeconds / 60; + const totalHours = totalMinutes / 60; + const totalDays = totalHours / 24; + const totalYears = totalDays / 365.2425; + + //const hours = Math.floor(totalHours % 24); + const minutes = Math.floor(totalMinutes % 60); + const seconds = Math.floor(totalSeconds % 60); + const miliseconds = Math.floor(totalMiliseconds % 1000); + + if (totalYears > 1.01) return `${Math.ceil(totalYears * 100) / 100} years`; + if (totalDays > 16) return `${Math.ceil(totalDays * 100) / 100} days`; + if (totalDays > 1 && totalDays % 1 === 0) return `${totalDays} days`; + if (totalDays > 4 && totalHours % 1 === 0) + return `${Math.floor(totalDays)} days ${ + Math.ceil((totalHours % 24) * 100) / 100 + } hours`; + + const textHour = totalHours === 0 ? "" : totalHours === 1 ? "1 hour" : `${totalHours} hours`; + const textMinutes = + minutes === 0 ? "" : minutes === 1 ? "1 minute" : `${minutes} minutes`; + const textSeconds = + seconds === 0 ? "" : seconds === 1 ? "1 second" : `${seconds} seconds`; + const textMiliseconds = + miliseconds === 0 + ? "" + : miliseconds === 1 + ? "1 milisecond" + : `${Math.ceil(miliseconds)} miliseconds`; + const textMilisecondsPrecise = + miliseconds === 0 + ? "" + : miliseconds === 1 + ? "1 milisecond" + : `${Math.ceil(miliseconds * 1e6) / 1e6} miliseconds`; + + if (totalHours >= 1) return [textHour, textMinutes].join(" ").trim(); + if (totalMinutes >= 1) return [textMinutes, textSeconds].join(" ").trim(); + if (totalSeconds >= 10) + return [textSeconds, textMiliseconds].join(" ").trim(); + if (totalMiliseconds >= 10) return [textMiliseconds].join(" ").trim(); + return textMilisecondsPrecise; +} From e194c1d81a04a3891f81f13d7dba124306ce3188 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 21 Sep 2023 09:23:47 +0200 Subject: [PATCH 2/6] messageCount: Add docs --- src/quota/messageCount.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/quota/messageCount.ts b/src/quota/messageCount.ts index 2e9bc78..a38cfdb 100644 --- a/src/quota/messageCount.ts +++ b/src/quota/messageCount.ts @@ -11,6 +11,10 @@ export default class MessageCount implements IQuota { lookback: number; defaultQuota: number; + /** + * @param defaultQuota the default quota for users that don't have override + * @param lookback lookback to check + */ constructor( defaultQuota: number = 25, lookback: number = 1000 * 60 * 60 * 24 @@ -19,6 +23,9 @@ export default class MessageCount implements IQuota { this.lookback = lookback; } + /** + * Retrives the quota from the database + */ private getUserQuota(id: string) { return database.limits.findUnique({ where: { user: BigInt(id) }, @@ -78,6 +85,9 @@ export default class MessageCount implements IQuota { }; } + /** + * helper funtion to create userQuotaData + */ private createUserQuotaData(quota: number, used: number): userQuotaData { const humanReadable = milisecondsToHumanReadable(this.lookback); return { From 6792c059597c37accaf70ea584f53c50165fdc68 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 21 Sep 2023 20:07:35 +0200 Subject: [PATCH 3/6] Quota: add tokenCount QuotaEngine --- src/quota/messageCount.ts | 2 +- src/quota/tokenCount.ts | 124 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/quota/tokenCount.ts diff --git a/src/quota/messageCount.ts b/src/quota/messageCount.ts index a38cfdb..7b97755 100644 --- a/src/quota/messageCount.ts +++ b/src/quota/messageCount.ts @@ -101,7 +101,7 @@ export default class MessageCount implements IQuota { } } -function milisecondsToHumanReadable(totalMiliseconds: number): string { +export function milisecondsToHumanReadable(totalMiliseconds: number): string { const negative = totalMiliseconds < 0; if (negative) totalMiliseconds = -totalMiliseconds; diff --git a/src/quota/tokenCount.ts b/src/quota/tokenCount.ts new file mode 100644 index 0000000..f1ce0a3 --- /dev/null +++ b/src/quota/tokenCount.ts @@ -0,0 +1,124 @@ +import { User, GuildMember } from "discord.js"; + +import IQuota, { userQuotaData, userQuotaRecoveryData } from "../IQuota"; +import { apiRequest } from "../execution"; +import { database } from "../index"; +import { milisecondsToHumanReadable } from "./messageCount"; +import { Usage } from "@prisma/client"; + +/** + * Quota based on Tokens used and generated + */ +export default class tokenCount implements IQuota { + defaultQuota: number; + lookback: number; + considerInputTokensAsHalf: boolean; + + constructor( + defaultQuota: number = 512 * 25, + lookback: number = 1000 * 60 * 60 * 24, + considerInputTokensAsHalf: boolean = true, + ) { + this.defaultQuota = defaultQuota; + this.lookback = lookback; + this.considerInputTokensAsHalf = considerInputTokensAsHalf; + } + + private getUserQuota(id: string) { + return database.limits.findUnique({ + where: { user: BigInt(id) }, + }); + } + + async checkUser( + user: string | User | GuildMember, + request: apiRequest + ): Promise { + const userId: string = typeof user === "string" ? user : user.id; + + const userQuota = await this.getUserQuota(userId); + + const usedTokens = (await database.usage.aggregate({ + _sum: { + usageRequest: true, + usageResponse: true, + }, + where: { + user: BigInt(userId), + timestamp: { + gte: new Date(request.createdAt.getTime() - this.lookback), + } + } + }))._sum; + + if (!usedTokens.usageRequest || !usedTokens.usageResponse) throw new Error("Null from a database!! (tokenCount Quota)"); + + const usedUnits = (() => { + if (this.considerInputTokensAsHalf) + return usedTokens.usageResponse + usedTokens.usageRequest / 2; + return usedTokens.usageResponse + usedTokens.usageRequest; + })(); + + if (userQuota?.vip) return this.createUserQuotaData(Infinity, usedUnits); + if (userQuota?.limit) + return this.createUserQuotaData(userQuota.limit, usedUnits); + return this.createUserQuotaData(this.defaultQuota, usedUnits); + } + + findNthUsage(user: string, requestTimestamp: number, unitCount: number) { + if (this.considerInputTokensAsHalf) + throw("Not implemented"); + return database.$queryRaw>` + SELECT t1.*, ( + SELECT + SUM(usageResponse + usageRequest) AS usage + FROM \`usage\` + WHERE + user = ${user} AND + timestamp >= ${requestTimestamp - this.lookback} AND + timestamp <= t1.timestamp + ) as usage + FROM + \`usage\` AS t1 + WHERE + user = ${user} AND + timestamp >= ${requestTimestamp - this.lookback} AND + usage >= ${unitCount} + ORDER BY timestamp ASC + LIMIT 1 + `; + } + async getUserQuotaRecovery( + user: string | User | GuildMember, + request: apiRequest, + unitCount: number = 1 + ): Promise { + const userId = typeof user ==="string" ? user : user.id; + + const [userQuota, renameMebecause] = await Promise.all([ + this.checkUser(userId, request), + this.findNthUsage(userId, request.createdTimestamp, unitCount) + ]); + + console.log(renameMebecause); + return { + ...userQuota, + recoveryTimestamp: (renameMebecause.at(0)?.timestamp.valueOf() ?? Infinity) + this.lookback, + }; + } + + /** + * helper funtion to create userQuotaData + */ + private createUserQuotaData(quota: number, used: number): userQuotaData { + const humanReadable = milisecondsToHumanReadable(this.lookback); + return { + quota: quota, + used: used, + unitName: "tokens", + toString() { + return `${this.used} of ${this.quota} ${this.unitName} in ${humanReadable}`; + }, + }; + } +} From b2ee15602857b322bf8d8355cea3713bee470c1e Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 21 Sep 2023 20:08:52 +0200 Subject: [PATCH 4/6] /check-limit: fix wrong unit name in field name --- src/commands/check-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/check-limit.ts b/src/commands/check-limit.ts index 616bb11..c9e667b 100644 --- a/src/commands/check-limit.ts +++ b/src/commands/check-limit.ts @@ -35,7 +35,7 @@ export default class MyLimit extends Command implements Command { fields.push({ name: "Usage", inline: true, value: `${userQuotaRecovery.used} ${userQuotaRecovery.unitName}`.trim() }); if (userQuotaRecovery.recoveryTimestamp !== undefined) fields.push({ - name: `Recovery for ${recoveryFor} message${recoveryFor>1 ? "s" : ""}`, + name: `Recovery for ${recoveryFor} ${userQuotaRecovery.unitName}`.trim(), value: userQuotaRecovery.recoveryTimestamp === Infinity ? "never" : `` }); From 4abdaebf70201f2264e32ebb73f2bd71653552a5 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 21 Sep 2023 20:38:50 +0200 Subject: [PATCH 5/6] quota/tokenCount: I always forget about debug logs... --- src/quota/tokenCount.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/quota/tokenCount.ts b/src/quota/tokenCount.ts index f1ce0a3..03dfc2f 100644 --- a/src/quota/tokenCount.ts +++ b/src/quota/tokenCount.ts @@ -100,7 +100,6 @@ export default class tokenCount implements IQuota { this.findNthUsage(userId, request.createdTimestamp, unitCount) ]); - console.log(renameMebecause); return { ...userQuota, recoveryTimestamp: (renameMebecause.at(0)?.timestamp.valueOf() ?? Infinity) + this.lookback, From 16fb74ec4b9fc61a222b600935eb70e3ea988938 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 21 Sep 2023 20:42:06 +0200 Subject: [PATCH 6/6] quota/tokenCount: Implement the "request tokens as half" case --- src/quota/tokenCount.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/quota/tokenCount.ts b/src/quota/tokenCount.ts index 03dfc2f..263ba13 100644 --- a/src/quota/tokenCount.ts +++ b/src/quota/tokenCount.ts @@ -65,9 +65,34 @@ export default class tokenCount implements IQuota { return this.createUserQuotaData(this.defaultQuota, usedUnits); } + /** + * Helper function to use proper query based on considerInputTokensAsHalf setting + * @param user The user to find nth usage + * @param requestTimestamp the timestamp of the request + * @param unitCount the unit count to check + * @returns promise of giving out the record + */ findNthUsage(user: string, requestTimestamp: number, unitCount: number) { if (this.considerInputTokensAsHalf) - throw("Not implemented"); + return database.$queryRaw>` + SELECT t1.*, ( + SELECT + SUM(usageResponse + usageRequest/2) AS usage + FROM \`usage\` + WHERE + user = ${user} AND + timestamp >= ${requestTimestamp - this.lookback} AND + timestamp <= t1.timestamp + ) as usage + FROM + \`usage\` AS t1 + WHERE + user = ${user} AND + timestamp >= ${requestTimestamp - this.lookback} AND + usage >= ${unitCount} + ORDER BY timestamp ASC + LIMIT 1 + `; return database.$queryRaw>` SELECT t1.*, ( SELECT