Spaces:
Runtime error
Runtime error
| import fs from 'fs/promises'; | |
| import path from 'path'; | |
| import { DATA_ROOT, isPostgresStorageMode } from './dataPaths.js'; | |
| import { | |
| decryptJsonPayload, | |
| encryptJsonPayload, | |
| makeLookupToken, | |
| pgQuery, | |
| withPgTransaction, | |
| } from './postgres.js'; | |
| const STORE_FILE = path.join(DATA_ROOT, 'web-search-usage.json'); | |
| const APP_TIMEZONE = process.env.APP_TIMEZONE || 'America/New_York'; | |
| let state = { days: {} }; | |
| let loaded = false; | |
| let saveChain = Promise.resolve(); | |
| function todayKey() { | |
| return new Intl.DateTimeFormat('en-CA', { | |
| timeZone: APP_TIMEZONE, | |
| year: 'numeric', | |
| month: '2-digit', | |
| day: '2-digit', | |
| }).format(new Date()); | |
| } | |
| function usageLookup(key) { | |
| return makeLookupToken('web-search-usage', key); | |
| } | |
| function usageAad(key, day) { | |
| return `web-search-usage:${key}:${day}`; | |
| } | |
| function pruneDays() { | |
| const keepKey = todayKey(); | |
| state.days = Object.fromEntries( | |
| Object.entries(state.days || {}).filter(([key]) => key === keepKey) | |
| ); | |
| } | |
| async function ensureLoaded() { | |
| if (loaded || isPostgresStorageMode()) return; | |
| loaded = true; | |
| try { | |
| await fs.mkdir(DATA_ROOT, { recursive: true }); | |
| const raw = await fs.readFile(STORE_FILE, 'utf8'); | |
| const parsed = JSON.parse(raw); | |
| if (parsed && typeof parsed === 'object') state = parsed; | |
| } catch {} | |
| pruneDays(); | |
| } | |
| function saveState() { | |
| saveChain = saveChain.then(async () => { | |
| pruneDays(); | |
| await fs.mkdir(DATA_ROOT, { recursive: true }); | |
| await fs.writeFile(STORE_FILE, JSON.stringify(state, null, 2), 'utf8'); | |
| }).catch((err) => { | |
| console.error('Failed to persist web search usage:', err); | |
| }); | |
| return saveChain; | |
| } | |
| function getCounterRecord(key) { | |
| const day = todayKey(); | |
| if (!state.days[day]) state.days[day] = {}; | |
| if (!state.days[day][key]) state.days[day][key] = 0; | |
| return { | |
| day, | |
| used: state.days[day][key], | |
| set(nextValue) { | |
| state.days[day][key] = nextValue; | |
| }, | |
| }; | |
| } | |
| async function pruneSqlUsage(day, client = null) { | |
| const runner = client || { query: pgQuery }; | |
| await runner.query('DELETE FROM web_search_usage WHERE day_key <> $1', [day]); | |
| } | |
| async function getSqlUsage(key, limit = 15) { | |
| const day = todayKey(); | |
| await pruneSqlUsage(day); | |
| const lookup = usageLookup(key); | |
| const { rows } = await pgQuery( | |
| 'SELECT payload FROM web_search_usage WHERE key_lookup = $1 AND day_key = $2', | |
| [lookup, day] | |
| ); | |
| const payload = rows[0] | |
| ? decryptJsonPayload(rows[0].payload, usageAad(key, day)) | |
| : null; | |
| const used = Math.max(0, Number(payload?.used) || 0); | |
| return { | |
| limit, | |
| used, | |
| remaining: Math.max(0, limit - used), | |
| window: day, | |
| period: 'daily', | |
| }; | |
| } | |
| async function consumeSqlUsage(key, limit = 15) { | |
| return withPgTransaction(async (client) => { | |
| const day = todayKey(); | |
| await pruneSqlUsage(day, client); | |
| const lookup = usageLookup(key); | |
| const { rows } = await client.query( | |
| 'SELECT payload FROM web_search_usage WHERE key_lookup = $1 AND day_key = $2 FOR UPDATE', | |
| [lookup, day] | |
| ); | |
| const current = rows[0] | |
| ? decryptJsonPayload(rows[0].payload, usageAad(key, day)) | |
| : { used: 0 }; | |
| const used = Math.max(0, Number(current?.used) || 0); | |
| if (used >= limit) { | |
| return { | |
| allowed: false, | |
| limit, | |
| used, | |
| remaining: 0, | |
| window: day, | |
| period: 'daily', | |
| }; | |
| } | |
| const next = { used: used + 1 }; | |
| await client.query( | |
| `INSERT INTO web_search_usage (key_lookup, day_key, updated_at, payload) | |
| VALUES ($1, $2, $3, $4::jsonb) | |
| ON CONFLICT (key_lookup, day_key) | |
| DO UPDATE SET updated_at = EXCLUDED.updated_at, payload = EXCLUDED.payload`, | |
| [lookup, day, new Date().toISOString(), JSON.stringify(encryptJsonPayload(next, usageAad(key, day)))] | |
| ); | |
| return { | |
| allowed: true, | |
| limit, | |
| used: next.used, | |
| remaining: Math.max(0, limit - next.used), | |
| window: day, | |
| period: 'daily', | |
| }; | |
| }); | |
| } | |
| export async function getWebSearchUsage(key, limit = 15) { | |
| if (isPostgresStorageMode()) return getSqlUsage(key, limit); | |
| await ensureLoaded(); | |
| const record = getCounterRecord(key); | |
| return { | |
| limit, | |
| used: record.used, | |
| remaining: Math.max(0, limit - record.used), | |
| window: record.day, | |
| period: 'daily', | |
| }; | |
| } | |
| export async function consumeWebSearchUsage(key, limit = 15) { | |
| if (isPostgresStorageMode()) return consumeSqlUsage(key, limit); | |
| await ensureLoaded(); | |
| const record = getCounterRecord(key); | |
| if (record.used >= limit) { | |
| return { | |
| allowed: false, | |
| ...(await getWebSearchUsage(key, limit)), | |
| }; | |
| } | |
| record.set(record.used + 1); | |
| await saveState(); | |
| return { | |
| allowed: true, | |
| ...(await getWebSearchUsage(key, limit)), | |
| }; | |
| } | |