jevstrudel.git / worker / src / budget-counter.ts

A signed-in listener's daily Jev budget, counted: the logic of the JevBudget Durable Object (budget.ts), apart from it so the tests run it on Node's SQLite without workerd.

One object per user holds one row per UTC day: how many relay calls that user has been charged for. spend checks and charges in one synchronous SQL step, and a Durable Object runs one call at a time with synchronous SQLite storage, so concurrent calls from one user's tabs and devices are counted exactly: never over the limit, never a charge lost. Older days are deleted as a new one starts; only today is ever read.

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;
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}

The limit as configured (wrangler.json's JEV_DAILY_PER_USER): a positive whole number, or the Worker refuses to charge anyone (and so to forward 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}
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}

Charges calls relay calls (1 for the relay; content-jobs.ts charges a listener's song score, three runs, at once) unless they do not all fit in 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}