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}