nda improvements

This commit is contained in:
2026-07-28 17:35:44 -05:00
parent 4653f53ff3
commit c9202d0b80
16 changed files with 35467 additions and 237 deletions

117
src/background-task.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { FastifyBaseLogger } from 'fastify';
import { query, queryOne } from './db.js';
/**
* Long-running jobs that must not sit in front of an HTTP request: the NDA
* refresh walk and the signature sync. Both talk to a rate-limited API for
* minutes at a time, so the route starts them and returns, and the UI polls
* the state out of app_meta.
*
* At most one run of each at a time, claimed atomically so two clicks on the
* same button cannot start two jobs.
*/
export interface TaskKeys {
/** idle | running | error:<message> */
state: string;
/** ISO timestamp of the last completed run. */
lastAt: string;
/** JSON result of the last completed run. */
lastResult: string;
}
export async function getMeta(key: string): Promise<string | null> {
const row = await queryOne<{ value: string | null }>(
'SELECT value FROM app_meta WHERE key = $1',
[key],
);
return row?.value ?? null;
}
export async function setMeta(key: string, value: string): Promise<void> {
await query(
`INSERT INTO app_meta (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[key, value],
);
}
/**
* The conditional upsert *is* the lock: it returns nothing when somebody else
* already holds the slot.
*/
async function claim(keys: TaskKeys): Promise<boolean> {
const claimed = await queryOne<{ key: string }>(
`INSERT INTO app_meta (key, value) VALUES ($1, 'running')
ON CONFLICT (key) DO UPDATE SET value = 'running', updated_at = now()
WHERE app_meta.value <> 'running'
RETURNING key`,
[keys.state],
);
return Boolean(claimed);
}
/**
* Tasks live in this process, so a "running" found at startup can only be the
* remains of a killed one.
*/
export async function clearStaleTask(
keys: TaskKeys,
label: string,
log: FastifyBaseLogger,
): Promise<void> {
const stale = await queryOne<{ key: string }>(
`UPDATE app_meta SET value = 'idle', updated_at = now()
WHERE key = $1 AND value = 'running' RETURNING key`,
[keys.state],
);
if (stale) log.warn(`[${label}] found a stale "running" state at startup, reset to idle`);
}
/**
* Claims the slot and runs `job` detached. `false` means somebody else is
* already running it — the caller answers 409.
*/
export async function startTask<T>(
keys: TaskKeys,
label: string,
log: FastifyBaseLogger,
job: () => Promise<T>,
): Promise<boolean> {
if (!(await claim(keys))) return false;
void (async () => {
try {
const result = await job();
await setMeta(keys.lastResult, JSON.stringify(result));
await setMeta(keys.lastAt, new Date().toISOString());
await setMeta(keys.state, 'idle');
} catch (err) {
const message = (err as Error).message;
log.error(`[${label}] failed: ${message}`);
// Kept in the state so the UI can show why, rather than a silent idle.
await setMeta(keys.state, `error:${message}`.slice(0, 400)).catch(() => {});
}
})();
return true;
}
export interface TaskState {
state: string;
last_at: string | null;
last_result: unknown;
}
export async function readTaskState(keys: TaskKeys): Promise<TaskState> {
const [state, lastAt, lastResult] = await Promise.all([
getMeta(keys.state),
getMeta(keys.lastAt),
getMeta(keys.lastResult),
]);
return {
state: state ?? 'idle',
last_at: lastAt,
last_result: lastResult ? (JSON.parse(lastResult) as unknown) : null,
};
}