chat-dev / server /webSearchUsageStore.js
incognitolm
Migration to PostgreSQL
bff1056
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)),
};
}