jevstrudel.git / worker / src / budget-counter.ts
1// A signed-in listener's daily Jev budget, counted: the logic of the
2// JevBudget Durable Object (budget.ts), apart from it so the tests run it
3// on Node's SQLite without workerd.
4//
5// One object per user holds one row per UTC day: how many relay calls that
6// user has been charged for. `spend` checks and charges in one synchronous
7// SQL step, and a Durable Object runs one call at a time with synchronous
8// SQLite storage, so concurrent calls from one user's tabs and devices are
9// counted exactly: never over the limit, never a charge lost. Older days
10// are deleted as a new one starts; only today is ever read.
11
12// The part of a Durable Object's SqlStorage used here.
13export interface Sql {
14  exec<T extends Record<string, SqlStorageValue>>(query: string, ...bindings: unknown[]): { toArray(): T[] };
15}
16type SqlStorageValue = ArrayBuffer | string | number | null;
17
18export type Spend = {
19  ok: boolean; // charged; false when the day's budget was already spent
20  used: number; // charged today, including this call
21  limit: number;
22  exhausted: boolean; // this call spent the last of today's budget
23  resetsAt: number; // ms: the next UTC midnight, when the count starts again
24};
25export type Balance = Omit<Spend, 'ok' | 'exhausted'>;
26
27export const utcDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
28export function nextUtcMidnight(ms: number): number {
29  const d = new Date(ms);
30  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
31}
32
33// The limit as configured (wrangler.json's JEV_DAILY_PER_USER): a positive
34// whole number, or the Worker refuses to charge anyone (and so to forward
35// signed-in calls) rather than guess one.
36export function dailyLimit(value: unknown): number {
37  const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
38  if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
39    throw new Error(`JEV_DAILY_PER_USER must be a positive whole number, not ${JSON.stringify(value)}`);
40  }
41  return n;
42}
43
44export function createSchema(sql: Sql) {
45  sql.exec(
46    'CREATE TABLE IF NOT EXISTS spent (day TEXT PRIMARY KEY, used INTEGER NOT NULL CHECK (used >= 0)) STRICT',
47  );
48}
49
50export function balance(sql: Sql, limit: number, now: number): Balance {
51  const [row] = sql.exec<{ used: number }>('SELECT used FROM spent WHERE day = ?', utcDay(now)).toArray();
52  return { used: row?.used ?? 0, limit, resetsAt: nextUtcMidnight(now) };
53}
54
55// Charges `calls` relay calls (1 for the relay; content-jobs.ts charges a
56// listener's song score, three runs, at once) unless they do not all fit in
57// what is left today: never part of them.
58export function spend(sql: Sql, limit: number, now: number, calls = 1): Spend {
59  if (!Number.isInteger(calls) || calls < 1) throw new Error(`spend: calls is a whole number from 1, not ${calls}`);
60  const day = utcDay(now);
61  sql.exec('DELETE FROM spent WHERE day <> ?', day);
62  const resetsAt = nextUtcMidnight(now);
63  if (calls > limit) return { ok: false, used: balance(sql, limit, now).used, limit, exhausted: false, resetsAt };
64  // Charged only while it fits: the WHERE makes the check and the charge
65  // one statement, so there is no gap between them. A first charge of the
66  // day inserts `calls`, which fits (calls <= limit, above).
67  const [charged] = sql
68    .exec<{ used: number }>(
69      `INSERT INTO spent (day, used) VALUES (?, ?)
70       ON CONFLICT (day) DO UPDATE SET used = used + excluded.used WHERE used + excluded.used <= ?
71       RETURNING used`,
72      day,
73      calls,
74      limit,
75    )
76    .toArray();
77  if (charged && charged.used <= limit) {
78    return { ok: true, used: charged.used, limit, exhausted: charged.used === limit, resetsAt };
79  }
80  return { ok: false, used: balance(sql, limit, now).used, limit, exhausted: false, resetsAt };
81}