deckhearth/lib/pricing/geo-cache.js

111 lines
3.1 KiB
JavaScript
Raw Permalink Normal View History

import { createHash } from 'node:crypto';
import { sql } from '../sql.js';
import { geocodeUsZip } from './nominatim.js';
import { fetchGameStores } from './overpass.js';
export const STORES_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/**
* @param {number} lat
* @param {number} lng
* @param {number} radiusMeters
* @returns {string}
*/
export function buildStoresCacheKey(lat, lng, radiusMeters) {
const payload = `${lat.toFixed(5)}:${lng.toFixed(5)}:${radiusMeters}`;
return createHash('sha256').update(payload).digest('hex').slice(0, 64);
}
/**
* @param {Date | string | null | undefined} fetchedAt
* @param {number} [ttlMs]
* @returns {boolean}
*/
export function isStoresCacheFresh(fetchedAt, ttlMs = STORES_CACHE_TTL_MS) {
if (!fetchedAt) return false;
const ts = fetchedAt instanceof Date ? fetchedAt.getTime() : new Date(fetchedAt).getTime();
if (!Number.isFinite(ts)) return false;
return Date.now() - ts < ttlMs;
}
/**
* @param {string} zip
* @param {{ fetchImpl?: typeof fetch }} [options]
* @returns {Promise<{ lat: number, lng: number, displayName: string, cached: boolean } | null>}
*/
export async function getZipCoords(zip, options = {}) {
const cached = await sql`
SELECT lat, lng, display_name
FROM geo_zip_cache
WHERE zip = ${zip}
LIMIT 1
`;
if (cached.rows.length > 0) {
const row = cached.rows[0];
return {
lat: Number(row.lat),
lng: Number(row.lng),
displayName: row.display_name ?? zip,
cached: true,
};
}
const geocoded = await geocodeUsZip(zip, options);
if (!geocoded) {
return null;
}
await sql`
INSERT INTO geo_zip_cache (zip, lat, lng, display_name)
VALUES (${zip}, ${geocoded.lat}, ${geocoded.lng}, ${geocoded.displayName})
ON CONFLICT (zip) DO UPDATE SET
lat = EXCLUDED.lat,
lng = EXCLUDED.lng,
display_name = EXCLUDED.display_name,
geocoded_at = CURRENT_TIMESTAMP
`;
return { ...geocoded, cached: false };
}
/**
* @param {number} lat
* @param {number} lng
* @param {number} radiusMeters
* @param {{ fetchImpl?: typeof fetch, forceRefresh?: boolean }} [options]
* @returns {Promise<{ stores: unknown[], cached: boolean }>}
*/
export async function getStoresNear(lat, lng, radiusMeters, options = {}) {
const cacheKey = buildStoresCacheKey(lat, lng, radiusMeters);
if (!options.forceRefresh) {
const cached = await sql`
SELECT stores, fetched_at
FROM geo_stores_cache
WHERE cache_key = ${cacheKey}
LIMIT 1
`;
if (cached.rows.length > 0 && isStoresCacheFresh(cached.rows[0].fetched_at)) {
return { stores: cached.rows[0].stores, cached: true };
}
}
const stores = await fetchGameStores(lat, lng, radiusMeters, options);
await sql`
INSERT INTO geo_stores_cache (cache_key, lat, lng, radius_meters, stores, fetched_at)
VALUES (${cacheKey}, ${lat}, ${lng}, ${radiusMeters}, ${JSON.stringify(stores)}::jsonb, CURRENT_TIMESTAMP)
ON CONFLICT (cache_key) DO UPDATE SET
lat = EXCLUDED.lat,
lng = EXCLUDED.lng,
radius_meters = EXCLUDED.radius_meters,
stores = EXCLUDED.stores,
fetched_at = CURRENT_TIMESTAMP
`;
return { stores, cached: false };
}