mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(outlines): harden shared cache and provider lifecycle
This commit is contained in:
1 parent
9559e7a282
commit
177b5951a0
27 files changed
+3363
-248
No files matched your search
@@ -1,13 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readResponseJsonCapped } from '../common/http.js';
|
||||
|
||||
/**
|
||||
* One gate for every request this server sends to a Nominatim instance.
|
||||
*
|
||||
* Place search and voice outlines share it, so the public instance sees one
|
||||
* application: one request at a time, at least 1.1 s apart, a short queue, a
|
||||
* daily ceiling per install, and a pause after the server says to stop.
|
||||
* Place search and voice outlines share it. Public requests reserve one start
|
||||
* at a time across processes sharing this installation's state file, remain at
|
||||
* least 1.1 s apart, use a short queue and daily ceiling, and pause after the
|
||||
* server says to stop. Independent installations do not share one app-wide
|
||||
* limiter; each must identify itself and enforce the public policy locally.
|
||||
*/
|
||||
|
||||
/** The public instance. Only this host is subject to the public-use limits. */
|
||||
@@ -108,13 +111,110 @@ function gateError(code, message, extra = {}) {
|
||||
return Object.assign(new Error(message), { code, ...extra });
|
||||
}
|
||||
|
||||
const HTTP_MONTHS = Object.freeze([
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]);
|
||||
const HTTP_WEEKDAYS = Object.freeze([
|
||||
'Sun',
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
]);
|
||||
const HTTP_WEEKDAYS_LONG = Object.freeze([
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
]);
|
||||
|
||||
/** Strict RFC 9110 HTTP-date, including the two obsolete recipient formats. */
|
||||
function parseHttpDate(raw, now) {
|
||||
let match = raw.match(
|
||||
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/,
|
||||
);
|
||||
let weekday, day, month, year, hour, minute, second;
|
||||
if (match) {
|
||||
[, weekday, day, month, year, hour, minute, second] = match;
|
||||
weekday = HTTP_WEEKDAYS.indexOf(weekday);
|
||||
} else {
|
||||
match = raw.match(
|
||||
/^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/,
|
||||
);
|
||||
if (match) {
|
||||
[, weekday, day, month, year, hour, minute, second] = match;
|
||||
weekday = HTTP_WEEKDAYS_LONG.indexOf(weekday);
|
||||
const currentYear = new Date(now).getUTCFullYear();
|
||||
if (!Number.isFinite(currentYear)) return null;
|
||||
year = Math.floor(currentYear / 100) * 100 + Number(year);
|
||||
if (year > currentYear + 50) year -= 100;
|
||||
} else {
|
||||
match = raw.match(
|
||||
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
[, weekday, month, day, hour, minute, second, year] = match;
|
||||
weekday = HTTP_WEEKDAYS.indexOf(weekday);
|
||||
}
|
||||
}
|
||||
day = Number(day);
|
||||
month = HTTP_MONTHS.indexOf(month);
|
||||
year = Number(year);
|
||||
hour = Number(hour);
|
||||
minute = Number(minute);
|
||||
second = Number(second);
|
||||
if (
|
||||
weekday < 0 ||
|
||||
month < 0 ||
|
||||
year < 1601 ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59
|
||||
)
|
||||
return null;
|
||||
const at = Date.UTC(year, month, day, hour, minute, second);
|
||||
const date = new Date(at);
|
||||
return date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month &&
|
||||
date.getUTCDate() === day &&
|
||||
date.getUTCHours() === hour &&
|
||||
date.getUTCMinutes() === minute &&
|
||||
date.getUTCSeconds() === second &&
|
||||
date.getUTCDay() === weekday
|
||||
? at
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Seconds or an HTTP date → milliseconds from now, or null. */
|
||||
export function parseRetryAfter(value, now = Date.now()) {
|
||||
if (value == null || value === '') return null;
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
||||
const at = Date.parse(String(value));
|
||||
return Number.isFinite(at) ? Math.max(0, at - now) : null;
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const seconds = Number(raw);
|
||||
const milliseconds = seconds * 1000;
|
||||
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
|
||||
}
|
||||
// Numeric-looking but invalid delay-seconds must not be reinterpreted as a date.
|
||||
if (/^[+-]?(?:\d|\.\d)/.test(raw)) return null;
|
||||
const at = parseHttpDate(raw, now);
|
||||
return at === null ? null : Math.max(0, at - now);
|
||||
}
|
||||
|
||||
/** UTC calendar day, the unit of the daily cap. */
|
||||
@@ -122,30 +222,28 @@ function utcDay(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const sleepSync = (ms) =>
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
|
||||
/**
|
||||
* Public-service state shared by every process using the same file: today's
|
||||
* request count and any pause the server asked for.
|
||||
* request count, the next reserved start, and any pause the server asked for.
|
||||
*
|
||||
* Every change is a read-modify-write under an exclusive lock directory, and
|
||||
* the file is replaced atomically, so two dev servers sharing a checkout add
|
||||
* to one count instead of overwriting each other. Memory keeps the highest
|
||||
* values this process has seen, so a failed or unreadable file never lowers
|
||||
* the count or shortens a pause; a persistence failure is reported once and
|
||||
* in `status()`.
|
||||
* Every change is a read-modify-write under an OS-owned SQLite transaction,
|
||||
* and the JSON file is replaced atomically, so two dev servers sharing a
|
||||
* checkout add to one count instead of overwriting each other. A process exit
|
||||
* releases only that process's transaction; there is no stale path to delete
|
||||
* and therefore no stale reclaimer that can remove a successor's live lock.
|
||||
* Memory keeps the highest values this process has seen, so a failed or
|
||||
* unreadable file never lowers the count or shortens a pause; a persistence
|
||||
* failure is reported once and in `status()`.
|
||||
*/
|
||||
export function createGateStateStore({
|
||||
file = null,
|
||||
lockStaleMs = 5000,
|
||||
lockWaitMs = 500,
|
||||
onError = (error) =>
|
||||
console.warn(
|
||||
`[Nominatim] could not persist usage state: ${error?.message || error}`,
|
||||
),
|
||||
} = {}) {
|
||||
let memory = { day: '', count: 0, pausedUntil: 0 };
|
||||
let memory = { day: '', count: 0, pausedUntil: 0, nextStartAt: 0 };
|
||||
let lastError = null;
|
||||
|
||||
const report = (error) => {
|
||||
@@ -153,6 +251,64 @@ export function createGateStateStore({
|
||||
lastError = error;
|
||||
};
|
||||
|
||||
const pauseMarkerPrefix = file ? `${path.basename(file)}.pause.` : null;
|
||||
|
||||
/**
|
||||
* A refusal must remain visible even while another process owns the SQLite
|
||||
* transaction. Immutable marker names make that write atomic and monotonic:
|
||||
* concurrent writers cannot shorten one another's pause, and the next lock
|
||||
* owner folds every marker into the normal JSON state.
|
||||
*/
|
||||
function pendingPauses({ strict = false } = {}) {
|
||||
if (!file) return [];
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(path.dirname(file));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
return [];
|
||||
}
|
||||
const pauses = [];
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(pauseMarkerPrefix)) continue;
|
||||
const encoded = name.slice(pauseMarkerPrefix.length).split('.', 1)[0];
|
||||
if (!/^\d+$/.test(encoded)) continue;
|
||||
const pausedUntil = Number(encoded);
|
||||
if (!Number.isSafeInteger(pausedUntil)) {
|
||||
const error = new Error('invalid durable Nominatim pause marker');
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
continue;
|
||||
}
|
||||
pauses.push({
|
||||
path: path.join(path.dirname(file), name),
|
||||
pausedUntil,
|
||||
});
|
||||
}
|
||||
return pauses;
|
||||
}
|
||||
|
||||
function markPause(at) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const pausedUntil = Math.ceil(at);
|
||||
const marker = `${file}.pause.${pausedUntil}.${process.pid}.${process.hrtime.bigint()}`;
|
||||
fs.writeFileSync(marker, '', { flag: 'wx' });
|
||||
return pausedUntil;
|
||||
}
|
||||
|
||||
function clearPersistedPauses(pausedUntil) {
|
||||
for (const marker of pendingPauses()) {
|
||||
if (marker.pausedUntil > pausedUntil) continue;
|
||||
try {
|
||||
fs.unlinkSync(marker.path);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merge = (a, b) => ({
|
||||
day: a.day >= b.day ? a.day : b.day,
|
||||
count:
|
||||
@@ -162,21 +318,58 @@ export function createGateStateStore({
|
||||
? a.count
|
||||
: b.count,
|
||||
pausedUntil: Math.max(a.pausedUntil || 0, b.pausedUntil || 0),
|
||||
nextStartAt: Math.max(a.nextStartAt || 0, b.nextStartAt || 0),
|
||||
});
|
||||
|
||||
function readDisk() {
|
||||
if (!file) return null;
|
||||
const parseState = (parsed) => {
|
||||
const validDay =
|
||||
parsed?.day === '' ||
|
||||
(typeof parsed?.day === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(parsed.day) &&
|
||||
new Date(`${parsed.day}T00:00:00.000Z`)
|
||||
.toISOString()
|
||||
.startsWith(parsed.day));
|
||||
const validCount =
|
||||
Number.isInteger(parsed?.count) &&
|
||||
parsed.count >= 0 &&
|
||||
(parsed.day !== '' || parsed.count === 0);
|
||||
const validPausedUntil =
|
||||
Number.isFinite(parsed?.pausedUntil) && parsed.pausedUntil >= 0;
|
||||
// nextStartAt was added after the original state format. Its absence is
|
||||
// the one supported migration; a present value must still be trustworthy.
|
||||
const validNextStartAt =
|
||||
parsed?.nextStartAt === undefined ||
|
||||
(Number.isFinite(parsed.nextStartAt) && parsed.nextStartAt >= 0);
|
||||
if (
|
||||
!parsed ||
|
||||
Array.isArray(parsed) ||
|
||||
typeof parsed !== 'object' ||
|
||||
!validDay ||
|
||||
!validCount ||
|
||||
!validPausedUntil ||
|
||||
!validNextStartAt
|
||||
)
|
||||
throw new Error('invalid durable Nominatim usage state');
|
||||
return {
|
||||
day: parsed.day,
|
||||
count: parsed.count,
|
||||
pausedUntil: parsed.pausedUntil,
|
||||
nextStartAt: parsed.nextStartAt ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
function readDisk({ strict = false } = {}) {
|
||||
if (!file) {
|
||||
if (strict)
|
||||
throw new Error('no durable Nominatim usage state is configured');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return {
|
||||
day: typeof parsed?.day === 'string' ? parsed.day : '',
|
||||
count: Number.isFinite(parsed?.count) ? Math.max(0, parsed.count) : 0,
|
||||
pausedUntil: Number.isFinite(parsed?.pausedUntil)
|
||||
? parsed.pausedUntil
|
||||
: 0,
|
||||
};
|
||||
return parseState(JSON.parse(fs.readFileSync(file, 'utf8')));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') report(error);
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -184,34 +377,61 @@ export function createGateStateStore({
|
||||
function current() {
|
||||
const disk = readDisk();
|
||||
if (disk) memory = merge(memory, disk);
|
||||
const pending = pendingPauses();
|
||||
if (pending.length)
|
||||
memory = merge(memory, {
|
||||
...memory,
|
||||
pausedUntil: Math.max(...pending.map((item) => item.pausedUntil)),
|
||||
});
|
||||
return memory;
|
||||
}
|
||||
|
||||
function withLock(work) {
|
||||
const lock = `${file}.lock`;
|
||||
const deadline = Date.now() + lockWaitMs;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (;;) {
|
||||
try {
|
||||
fs.mkdirSync(lock);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
const waitMs = Number.isFinite(lockWaitMs)
|
||||
? Math.max(0, Math.floor(lockWaitMs))
|
||||
: 500;
|
||||
const database = new DatabaseSync(`${file}.lock.sqlite`, {
|
||||
timeout: waitMs,
|
||||
});
|
||||
let transactionOpen = false;
|
||||
try {
|
||||
database.exec('BEGIN IMMEDIATE');
|
||||
transactionOpen = true;
|
||||
const result = work();
|
||||
database.exec('COMMIT');
|
||||
transactionOpen = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (transactionOpen) {
|
||||
try {
|
||||
if (Date.now() - fs.statSync(lock).mtimeMs > lockStaleMs)
|
||||
fs.rmSync(lock, { recursive: true, force: true });
|
||||
database.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Another process released it meanwhile.
|
||||
// Closing the connection below still releases this owner's lock.
|
||||
}
|
||||
if (Date.now() > deadline)
|
||||
throw new Error('usage state is locked by another process');
|
||||
sleepSync(5);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
database.close();
|
||||
} catch {
|
||||
// The transaction has already been committed or rolled back. Closing
|
||||
// is best effort during exceptional teardown and never removes a lock.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeDisk(next) {
|
||||
const temp = `${file}.${process.pid}.${process.hrtime.bigint()}.tmp`;
|
||||
try {
|
||||
return work();
|
||||
fs.writeFileSync(temp, JSON.stringify(next));
|
||||
fs.renameSync(temp, file);
|
||||
} finally {
|
||||
fs.rmSync(lock, { recursive: true, force: true });
|
||||
try {
|
||||
fs.unlinkSync(temp);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,10 +444,9 @@ export function createGateStateStore({
|
||||
try {
|
||||
withLock(() => {
|
||||
const next = merge(memory, change(current()));
|
||||
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temp, JSON.stringify(next));
|
||||
fs.renameSync(temp, file);
|
||||
writeDisk(next);
|
||||
memory = next;
|
||||
clearPersistedPauses(next.pausedUntil);
|
||||
lastError = null;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -247,16 +466,80 @@ export function createGateStateStore({
|
||||
day,
|
||||
count: state.day === day ? state.count + 1 : 1,
|
||||
pausedUntil: state.pausedUntil,
|
||||
nextStartAt: state.nextStartAt,
|
||||
})).count;
|
||||
},
|
||||
reserve(day, { now, dailyCap, minSpacingMs, latestStartAt = Infinity }) {
|
||||
if (!file) {
|
||||
const error = new Error(
|
||||
'no durable Nominatim usage state is configured',
|
||||
);
|
||||
report(error);
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
try {
|
||||
let result;
|
||||
withLock(() => {
|
||||
const disk = readDisk({ strict: true });
|
||||
if (disk) memory = merge(memory, disk);
|
||||
const pending = pendingPauses({ strict: true });
|
||||
if (pending.length)
|
||||
memory = merge(memory, {
|
||||
...memory,
|
||||
pausedUntil: Math.max(...pending.map((item) => item.pausedUntil)),
|
||||
});
|
||||
const state = memory;
|
||||
if ((state.pausedUntil || 0) > now) {
|
||||
result = {
|
||||
status: 'paused',
|
||||
retryAfterMs: state.pausedUntil - now,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const count = state.day === day ? state.count : 0;
|
||||
if (count >= dailyCap) {
|
||||
result = { status: 'cap' };
|
||||
return;
|
||||
}
|
||||
const startAt = Math.max(now, state.nextStartAt || 0);
|
||||
if (startAt > latestStartAt) {
|
||||
result = { status: 'abandoned' };
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
day,
|
||||
count: count + 1,
|
||||
pausedUntil: state.pausedUntil || 0,
|
||||
nextStartAt: startAt + minSpacingMs,
|
||||
};
|
||||
writeDisk(next);
|
||||
memory = next;
|
||||
clearPersistedPauses(next.pausedUntil);
|
||||
lastError = null;
|
||||
result = { status: 'reserved', startAt, count: next.count };
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
report(error);
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
},
|
||||
pausedUntil() {
|
||||
return current().pausedUntil || 0;
|
||||
},
|
||||
pauseUntil(at) {
|
||||
if (file) {
|
||||
try {
|
||||
at = markPause(at);
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
}
|
||||
update((state) => ({ ...state, pausedUntil: at }));
|
||||
},
|
||||
status: () => ({
|
||||
file,
|
||||
durable: Boolean(file),
|
||||
persisted: Boolean(file) && !lastError,
|
||||
error: lastError ? String(lastError.message || lastError) : null,
|
||||
}),
|
||||
@@ -289,9 +572,8 @@ export function createNominatimGate({
|
||||
timeoutMs = 9000,
|
||||
} = {}) {
|
||||
let queue = Promise.resolve();
|
||||
let lastStartedAt = -Infinity;
|
||||
let pending = 0;
|
||||
let pausedUntil = 0;
|
||||
const pausedUntilByEndpoint = new Map();
|
||||
let upstreamRequests = 0;
|
||||
let usageStore = usage;
|
||||
|
||||
@@ -304,14 +586,18 @@ export function createNominatimGate({
|
||||
*/
|
||||
function pause(ms, config) {
|
||||
const until = now() + Math.max(0, ms);
|
||||
if (until > pausedUntil) pausedUntil = until;
|
||||
if (config?.isPublic) usageStore.pauseUntil(pausedUntil);
|
||||
const key = String(config?.endpoint || '');
|
||||
if (until > (pausedUntilByEndpoint.get(key) || 0))
|
||||
pausedUntilByEndpoint.set(key, until);
|
||||
if (config?.isPublic) usageStore.pauseUntil(until);
|
||||
}
|
||||
|
||||
function pauseEnd(config) {
|
||||
const endpointPause =
|
||||
pausedUntilByEndpoint.get(String(config?.endpoint || '')) || 0;
|
||||
return config?.isPublic
|
||||
? Math.max(pausedUntil, usageStore.pausedUntil())
|
||||
: pausedUntil;
|
||||
? Math.max(endpointPause, usageStore.pausedUntil())
|
||||
: endpointPause;
|
||||
}
|
||||
|
||||
function pausedError(config) {
|
||||
@@ -324,19 +610,12 @@ export function createNominatimGate({
|
||||
if (!config.endpoint)
|
||||
return gateError('NOMINATIM_DISABLED', 'Place service is not configured');
|
||||
if (now() < pauseEnd(config)) return pausedError(config);
|
||||
if (config.isPublic && usageStore.count(utcDay(now())) >= config.dailyCap)
|
||||
return gateError(
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
'Daily place lookup allowance is used',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One upstream request; classifies refusals and pauses the gate. */
|
||||
async function send(url, config, maxBytes, signal) {
|
||||
if (config.isPublic) usageStore.increment(utcDay(now()));
|
||||
upstreamRequests += 1;
|
||||
lastStartedAt = now();
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
@@ -405,16 +684,45 @@ export function createNominatimGate({
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for the spacing slot, then check the request is still wanted. */
|
||||
/** Atomically reserve the public slot, then wait until it may start. */
|
||||
async function takeTurn(config, queuedAt, signal) {
|
||||
const spacing = config.isPublic ? minSpacingMs : 0;
|
||||
const waitMs = Math.max(0, spacing - (now() - lastStartedAt));
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const refused = admissionError(config);
|
||||
if (refused) throw refused;
|
||||
// An operator-selected endpoint is not governed by the public instance's
|
||||
// shared budget, pacing, or durable-state requirement.
|
||||
if (!config.isPublic) return;
|
||||
const reserved = usageStore.reserve(utcDay(now()), {
|
||||
now: now(),
|
||||
dailyCap: config.dailyCap,
|
||||
minSpacingMs,
|
||||
latestStartAt: queuedAt + maxWaitMs,
|
||||
});
|
||||
if (reserved.status === 'unavailable')
|
||||
throw gateError(
|
||||
'NOMINATIM_STATE_UNAVAILABLE',
|
||||
'Public place service usage state is unavailable',
|
||||
{ cause: reserved.error },
|
||||
);
|
||||
if (reserved.status === 'cap')
|
||||
throw gateError(
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
'Daily place lookup allowance is used',
|
||||
);
|
||||
if (reserved.status === 'paused')
|
||||
throw gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
|
||||
retryAfterMs: reserved.retryAfterMs,
|
||||
});
|
||||
if (reserved.status === 'abandoned')
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const waitMs = Math.max(0, reserved.startAt - now());
|
||||
if (waitMs) await sleep(waitMs);
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
// An outage or refusal that happened while this waited stops it here.
|
||||
const refused = admissionError(config);
|
||||
if (refused) throw refused;
|
||||
// A refusal from another process after this reservation still stops it.
|
||||
// The reserved daily slot remains spent; it cannot safely be reassigned.
|
||||
if (now() < pauseEnd(config)) throw pausedError(config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,7 +773,7 @@ export function createNominatimGate({
|
||||
return {
|
||||
requestJson,
|
||||
settings: current,
|
||||
/** Share the daily count and pause through a file (composition chooses it). */
|
||||
/** Share public admission, daily count and pauses through one install's file. */
|
||||
persistUsage(file) {
|
||||
usageStore = createGateStateStore({ file });
|
||||
},
|
||||
|
||||
@@ -192,12 +192,95 @@ const ADMIN_EVIDENCE = Object.freeze({
|
||||
},
|
||||
});
|
||||
|
||||
const KNOWN_ADMIN_TYPES = new Set(
|
||||
Object.values(ADMIN_EVIDENCE).flatMap((evidence) => evidence.types),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nominatim address types whose meaning is stable enough for the area consumer's
|
||||
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
|
||||
* vary by country, so they remain unverified instead of being guessed.
|
||||
*/
|
||||
const VERIFIED_ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
province: 'admin1',
|
||||
county: 'admin2',
|
||||
state_district: 'admin2',
|
||||
});
|
||||
|
||||
const EXPLICIT_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
zone: 'retired',
|
||||
});
|
||||
|
||||
function explicitNameEvidence(value) {
|
||||
const normalized = String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
const prefix = normalized.match(
|
||||
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
|
||||
)?.[1];
|
||||
const suffix = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
)?.[1];
|
||||
const levels = new Set(
|
||||
[prefix, suffix]
|
||||
.filter(Boolean)
|
||||
.map((type) => EXPLICIT_NAME_LEVEL[type])
|
||||
.filter(Boolean),
|
||||
);
|
||||
if (levels.size > 1) return { contradictory: true, level: null };
|
||||
return { contradictory: false, level: levels.values().next().value || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified administrative identity carried by the selected result itself.
|
||||
* Never infer it from the request, desired kind, rank, or a separate geocoder
|
||||
* anchor. A retired name is preserved without a level so downstream policy can
|
||||
* reject it explicitly; every other contradiction stays wholly unverified.
|
||||
*/
|
||||
function selectedAdminIdentity(kind, row) {
|
||||
if (kind !== 'admin') return null;
|
||||
const adminLevel =
|
||||
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
|
||||
const authoritativeAdminArea = String(row?.name || '').trim();
|
||||
if (!adminLevel || !authoritativeAdminArea) return null;
|
||||
// Validate the complete authoritative name. `adminArea` is truncated only
|
||||
// for display after every prefix/suffix signal has been considered.
|
||||
const { contradictory, level: nameLevel } = explicitNameEvidence(
|
||||
authoritativeAdminArea,
|
||||
);
|
||||
if (contradictory) return null;
|
||||
const adminArea = authoritativeAdminArea.slice(0, 160);
|
||||
if (nameLevel === 'retired') return { adminArea };
|
||||
if (nameLevel && nameLevel !== adminLevel) return null;
|
||||
return { adminArea, adminLevel };
|
||||
}
|
||||
|
||||
/** Whether an administrative boundary is at the level the ask names. */
|
||||
export function adminLevelFits(kind, row) {
|
||||
const evidence = ADMIN_EVIDENCE[kind];
|
||||
if (!evidence) return true;
|
||||
const type = String(row?.addresstype || '').toLowerCase();
|
||||
if (type && evidence.types.includes(type)) return true;
|
||||
// A recognized type is direct evidence. Rank is only a fallback when the
|
||||
// upstream answer omits the type or supplies a value we do not understand;
|
||||
// the overlapping county/city rank bands must not override `county`.
|
||||
if (KNOWN_ADMIN_TYPES.has(type)) return false;
|
||||
const rank = Number(row?.place_rank);
|
||||
if (Number.isFinite(rank))
|
||||
return rank >= evidence.ranks[0] && rank <= evidence.ranks[1];
|
||||
@@ -452,6 +535,7 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
skipped.push(`${label}: too large`);
|
||||
continue;
|
||||
}
|
||||
const adminIdentity = selectedAdminIdentity(kind, row);
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
return {
|
||||
@@ -464,6 +548,7 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
row.osm_type && row.osm_id != null
|
||||
? `${row.osm_type}/${row.osm_id}`
|
||||
: null,
|
||||
...(adminIdentity || {}),
|
||||
center: finiteLat(lat) && finiteLon(lon) ? { lat, lon } : null,
|
||||
polygons,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user