/** * Per-(scanner, asset) acceptance high-water-mark store. * * Tracks the produced_at of the newest accepted signal for each (scanner, asset) * pair — one value per pair, permanent, never grows beyond that. This is the mark * that supersedes re-delivered / replayed signals. * * Two implementations share the seam: * - MapAcceptanceRecord — in-memory; wiped on restart (tests / API-disabled seam). * - FileAcceptanceRecord — file-backed: an atomic write per accept, reloaded on boot, * so a process restart (container SIGKILL) does NOT re-accept an already-accepted * signal. The mark is persisted BEFORE set() returns, hence before the accepted * signal is acted on (onAccepted → ingest → trade). Note: the write uses * temp-file-then-rename without fsync, so an OS crash or power loss mid-write can * leave a partial or zero-length file. In that case load() fails closed (throws), * which causes intake to refuse to start rather than re-accepting already-traded * signals. */ /** Minimal logger seam — the record only emits an error line on a bad load. */ export interface AcceptanceRecordLogger { error?(message: unknown, meta?: unknown): void; } export interface AcceptanceRecord { get(scannerId: string, asset: string): number | undefined; set(scannerId: string, asset: string, producedAt: number): void; /** * Drop every entry under `scannerId` whose key starts with `slotPrefix` and * whose stored value is <= `now`. Used to bound the additive signal_id dedup * layer: each slot stores its signal's `valid_until`, so an expired slot is * prunable by its own value. Entries WITHOUT the prefix (real (scanner, asset) * high-water-marks) are never touched. Returns the number of slots removed. */ pruneExpiredSlots(scannerId: string, slotPrefix: string, now: number): number; } /** * Default nested-Map-backed implementation. * * Liveness clock: updateLiveness is a separate concern handled at the * process-signal-post and process-error-post level. */ export declare class MapAcceptanceRecord implements AcceptanceRecord { private store; get(scannerId: string, asset: string): number | undefined; set(scannerId: string, asset: string, producedAt: number): void; pruneExpiredSlots(scannerId: string, slotPrefix: string, now: number): number; } /** * File-backed implementation — the process-restart dedup seam. * * Persists the whole high-water-mark map to one JSON file using a * temp-file-then-rename on every accept, and reloads it in the constructor (at * boot, before the HTTP API listens). Because set() persists SYNCHRONOUSLY before * it returns, the mark survives a process restart (container SIGKILL) and the * re-delivered signal is superseded instead of re-accepted. * * Durability caveat: the write uses writeFileSync + renameSync without fsync. * The rename is atomic with respect to a process crash, but an OS crash or power * loss mid-write can leave a partial or zero-length file. See "Fail-closed on load" * below for how that case is handled. * * Fail-closed on load: construction THROWS on any corrupt or unreadable (non-ENOENT) * file. Starting with an empty store on corruption is fail-open: an empty store * re-accepts already-traded signals within valid_until → double-trade. Only clean * states silently succeed: ENOENT (clean first boot) and a valid empty object {}. * A partial file from a power-loss scenario therefore causes intake to refuse to * start, not to re-accept — the safe outcome. * * Fail-closed on write: if the write throws (disk full / permission), set() * throws, the signal is NOT accepted, and the scaffold retries it while still valid — * far safer than accepting a signal whose dedup mark we could not record. * * Concurrency: set() is fully synchronous (Map mutate + writeFileSync + renameSync, * no await), so two concurrent intake POSTs cannot interleave their writes on the * event loop — each set() runs to completion atomically. */ export declare class FileAcceptanceRecord implements AcceptanceRecord { private readonly filePath; private readonly logger?; private store; private readonly tmpPath; constructor(filePath: string, logger?: AcceptanceRecordLogger | undefined); get(scannerId: string, asset: string): number | undefined; set(scannerId: string, asset: string, producedAt: number): void; pruneExpiredSlots(scannerId: string, slotPrefix: string, now: number): number; /** * Load the persisted map into `store`. * * Contract (fail-closed): * - ENOENT (missing file) → returns, store stays empty (clean first boot). * - Valid empty object {} → returns, store stays empty (persist() floor). * - Valid populated object → loads marks into store. * - Any other read error (EACCES…) → THROWS — do NOT start empty on a permission error. * - JSON parse failure → THROWS — corrupt bytes are not empty marks. * - Non-object top-level value → THROWS — only objects carry dedup marks; a * number/null/string/array root was not written by this class → treat as corruption. */ private load; /** Write the whole map to a temp file then rename over the target. * Atomic for a process crash; an OS/power crash without fsync can leave a * partial file (load() fails closed on that, refusing to start). */ private persist; } //# sourceMappingURL=acceptance-record.d.ts.map