refactor: separate Overpass and installation services

This commit is contained in:
Sameh Khamis committed 2026-09-12 12:59:39 -07:00
1 parent 781613fbb8
commit 213edd80ce
14 files changed
+1455 -841

No files matched your search

@@ -0,0 +1,134 @@
import { MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE } from './constants.js';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
const _militaryInstallationCache = new Map();
/**
* Resolve the READ tiers for one installation request, in order: fresh memory,
* then disk. Returns UPSTREAM when neither can answer.
*
* Disk is skipped while a request for this key is already in flight — the
* caller joins that instead of paying a read. Exported so the tier ORDER is
* testable against a real temp directory without a Vite server, mirroring
* resolveOverpassPreflight.
*
* @param {object} options
* @param {string} options.cacheKey
* @param {Map<string, {payload: object, cachedAt: number}>} options.memoryCache
* @param {Map<string, Promise>} options.inFlight
* @param {() => Promise<?{payload: object, cachedAt: number}>} options.readDisk
* @param {number} [options.now]
* @param {number} [options.cacheMs]
* @returns {Promise<{source: 'HIT'|'DISK'|'UPSTREAM', entry: ?object}>}
*/
async function resolveMilitaryInstallationTier({
cacheKey,
memoryCache,
inFlight,
readDisk,
now = Date.now(),
cacheMs = MILITARY_INSTALLATION_CACHE_MS,
}) {
const cached = memoryCache.get(cacheKey);
if (cached && now - cached.cachedAt <= cacheMs) return { source: 'HIT', entry: cached };
if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null };
const disk = await readDisk();
return disk ? { source: 'DISK', entry: disk } : { source: 'UPSTREAM', entry: null };
}
/**
* Bring a stored installation entry up to the current payload shape.
*
* Entries written before the saturation guard shipped carry no `saturated`
* field, and the disk TTL is 30 DAYS — so without this a cached, truncated
* 700-element snapped response would keep skipping the exact-viewport retry for
* a month, quietly starving in-view sites. Saturation is DERIVED from the
* element count rather than invalidating those entries, so warm caches survive
* the upgrade.
* @param {?{payload: object, cachedAt: number}} entry
* @returns {?{payload: object, cachedAt: number}}
*/
function migrateMilitaryInstallationEntry(entry) {
if (!entry?.payload || typeof entry.payload.saturated === 'boolean') return entry;
const elements = Array.isArray(entry.payload.elements) ? entry.payload.elements : [];
return {
...entry,
payload: {
...entry.payload,
saturated: elements.length >= MILITARY_INSTALLATION_ELEMENT_CAP,
},
};
}
/** Whether a stored installation entry is still inside its TTL. */
function militaryInstallationDiskFresh(
entry,
maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS,
now = Date.now(),
) {
if (!entry || !Number.isFinite(entry.cachedAt) || !Array.isArray(entry.payload?.elements)) return false;
return now - entry.cachedAt <= maxAgeMs;
}
/** Cache key -> stable disk-cache file path. */
function militaryInstallationDiskPath(cacheKey, dir = MILITARY_INSTALLATION_DISK_DIR) {
return path.join(dir, `${createHash('sha1').update(cacheKey).digest('hex')}.json`);
}
/**
* Read a disk-cached installation entry. maxAgeMs Infinity = any age (the
* serve-stale path when Overpass is down).
* @returns {Promise<?{payload: object, cachedAt: number}>}
*/
async function readMilitaryInstallationDisk(
cacheKey,
maxAgeMs,
dir = MILITARY_INSTALLATION_DISK_DIR,
) {
try {
const entry = JSON.parse(await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'));
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
return migrateMilitaryInstallationEntry(entry);
} catch {
return null;
}
}
/**
* Persist one installation payload ATOMICALLY: serialize to a temp sibling,
* then rename over the target. A crash or a full disk mid-write leaves the
* PREVIOUS entry intact — an in-place overwrite would shred the last-good copy
* and take serve-stale down with it, exactly when it is needed most.
* @returns {Promise<boolean>} Whether the entry landed.
*/
async function writeMilitaryInstallationDisk(
cacheKey,
entry,
dir = MILITARY_INSTALLATION_DISK_DIR,
) {
const target = militaryInstallationDiskPath(cacheKey, dir);
// Same directory, so the rename is atomic on POSIX rather than a cross-device copy.
const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
try {
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(temp, JSON.stringify(entry));
await fsp.rename(temp, target);
return true;
} catch (err) {
console.warn('[Installations Proxy] disk cache write failed:', err?.message || err);
await fsp.rm(temp, { force: true }).catch(() => {});
return false;
}
}
function trimMilitaryInstallationCache() {
while (_militaryInstallationCache.size > MILITARY_INSTALLATION_MAX_CACHE) {
const oldest = _militaryInstallationCache.keys().next().value;
if (oldest === undefined) break;
_militaryInstallationCache.delete(oldest);
}
}
export { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk, migrateMilitaryInstallationEntry, militaryInstallationDiskFresh, militaryInstallationDiskPath };
@@ -0,0 +1,48 @@
import path from 'node:path';
// ---------------------------------------------------------------------------
// Military-installation context proxy
// ---------------------------------------------------------------------------
// This narrow endpoint deliberately does not expose arbitrary Overpass QL to
// the browser. It returns only allow-listed mapped context and rejects global,
// cross-dateline, or oversized requests before touching public OSM mirrors.
const MILITARY_INSTALLATION_CACHE_MS = 5 * 60_000;
const MILITARY_INSTALLATION_STALE_MS = 60 * 60_000;
const MILITARY_INSTALLATION_MAX_CACHE = 80;
const MILITARY_INSTALLATION_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
/**
* Upstream element cap. A response that hits it exactly is SATURATED — Overpass
* truncated, so off-viewport features from the snapped bbox may have crowded out
* in-viewport ones. Callers re-ask for the exact viewport in that case.
*/
const MILITARY_INSTALLATION_ELEMENT_CAP = 700;
/**
* Disk-cache TTL for mapped installations (ms) — 30 days.
*
* Owner playtest 2026-08-18: "search nearby sites" was slow because every look
* around paid a live Overpass round trip, and the 5-minute in-memory tier died
* with the dev server. Mapped military features change on a survey timescale,
* not a session one, so a month-old answer is still the right answer — the same
* reasoning the Overpass proxy already applies to admin boundaries.
*/
const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000;
/** Disk-cache directory for mapped installation payloads. */
const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'military-installations');
/**
* Cache-key grid step in degrees (~5.5 km).
*
* The browser sends the raw view rectangle, so every pixel of pan minted a new
* key and a new upstream query. Snapping the bbox OUTWARD onto a coarse grid
* makes neighbouring viewports share one entry, and because the snap only ever
* grows the box, the cached answer is always a superset of what was asked for.
*/
const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05;
export { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS, MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, MILITARY_INSTALLATION_BBOX_STEP_DEG };
@@ -0,0 +1,60 @@
import { MILITARY_INSTALLATION_BBOX_STEP_DEG } from './constants.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
/**
* Snap a request bbox outward onto the shared installation cache grid.
* @param {{south:number, west:number, north:number, east:number}} box
* @param {number} [stepDeg]
* @returns {{south:number, west:number, north:number, east:number}}
*/
function quantizeMilitaryInstallationBox(box, stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG) {
// Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line
// in binary floating point, which would otherwise snap a whole cell too far.
const snap = (value, grow) => {
const cells = Number((value / stepDeg).toFixed(9));
return Number(((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6));
};
return {
south: Math.max(-90, snap(box.south, -1)),
west: Math.max(-180, snap(box.west, -1)),
north: Math.min(90, snap(box.north, 1)),
east: Math.min(180, snap(box.east, 1)),
};
}
/**
* Stable disk/memory cache key for an installation bbox.
*
* The key's precision must match the precision of the bounds the QUERY uses, or
* two different queries collide on one entry. Snapped boxes live on a 0.05 deg
* grid, so 3 decimals is exact for them; an `exact=1` request carries the raw
* viewport at 5 decimals and must be keyed at 5, otherwise two nearby exact
* viewports would share an answer and the second would be missing the edge
* strip it just exposed.
* @param {{south:number, west:number, north:number, east:number}} box
* @param {number} [decimals]
*/
function militaryInstallationCacheKey(box, decimals = 3) {
return [box.south, box.west, box.north, box.east]
.map((value) => value.toFixed(decimals))
.join(',');
}
function validMilitaryInstallationBox(params) {
const south = requiredFiniteQueryNumber(params, 'south');
const west = requiredFiniteQueryNumber(params, 'west');
const north = requiredFiniteQueryNumber(params, 'north');
const east = requiredFiniteQueryNumber(params, 'east');
if (![south, west, north, east].every(Number.isFinite)) return null;
if (south < -90 || north > 90 || west < -180 || east > 180 || south >= north || west >= east) return null;
if (north - south > 10 || east - west > 10) return null;
return { south, west, north, east };
}
/** Safe, evidence-based reason for an installation upstream failure. */
function militaryInstallationFailureReason(error) {
if (['rate_limited', 'timeout', 'query_failed'].includes(error?.installationReason)) return error.installationReason;
return ['AbortError', 'TimeoutError'].includes(error?.name) ? 'timeout' : 'unavailable';
}
export { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason };