import type { CursorSweepReport, MailboxCursorStore } from './cursor-store.js'; import type { ExpectationSweepReport, PersistedExpectationStore } from './expectation-store.js'; import type { InboundMailRecordSweepReport, InboundMailStore } from './record-store.js'; import type { HousekeepingTrigger } from './types.js'; /** One store that could not be swept at all, and why. */ export interface InboundMailSweepFailure { readonly store: 'cursors' | 'records' | 'expectations' | 'disclosure'; readonly detail: string; } /** * What one full pass removed, across all three stores. * * Each store's report is NULLABLE, and that is the point rather than a * convenience: the three used to be swept in one sequential expression, so a * throw from the first meant the second and third never ran and a caller could * not tell, a full record store and an expired expectation book both survived * a "successful" boot because one cursor file had a bad byte in it. A store * that could not be swept says so here, in `failures`, and the other two are * swept anyway. */ export interface InboundMailHousekeepingReport { readonly sweptAt: number; readonly trigger: HousekeepingTrigger; readonly cursors: CursorSweepReport | null; readonly records: InboundMailRecordSweepReport | null; readonly expectations: ExpectationSweepReport | null; /** Empty on a pass where every store swept. */ readonly failures: readonly InboundMailSweepFailure[]; /** One-line summary a surface can render without reading the itemised lists. */ readonly summary: string; } /** * A report AS PERSISTED, which is not the report as produced. * * WHAT A DISCLOSURE ACTUALLY NEEDS, and why it is less than the report: * * A disclosure log exists to answer "what was removed, and why" for something * that is now gone. Everything it needs is therefore about the REMOVED. The * in-memory `ExpectationSweepReport` also carries `survivors`, the full * `VerificationExpectation` objects that did NOT go, because * `InboundExpectationRegistry.hydrate()` feeds them straight into the live * book at boot. That is a same-process hand-off between two objects, and it * has no business being on disk. * * Writing it anyway made `email-inbound-housekeeping.json` a SECOND COPY of * the expectation store: every recipient alias, service domain and purpose, * duplicated into a file that expiry reaping never touches. The expectation * store reaps an expired grant within its window; the copy of it in the * disclosure log survived for the next twenty sweeps regardless, a store * nobody declared, holding the exact data the declared one is careful about. * * So `survivors` is dropped on the way to disk. `retained` already carries the * count, and a count is what a disclosure needs about the things that stayed: * naming them is not disclosure, it is retention. * * `removed` is kept, because the removed ARE the disclosure, but bounded. A * sweep of a full record store can remove thousands of rows, and twenty of * those reports is a log far larger than the stores it describes. Past * `MAX_DISCLOSED_REMOVALS` the entries are dropped and `removedTotal` says how * many there really were, so the count is never quietly wrong. */ export type DisclosedSweep = Omit & { readonly removed: readonly T['removed'][number][]; /** How many removals the pass actually made, when `removed` was truncated. */ readonly removedTotal: number; }; export interface DisclosedHousekeepingReport { readonly sweptAt: number; readonly trigger: HousekeepingTrigger; readonly cursors: DisclosedSweep | null; readonly records: DisclosedSweep | null; readonly expectations: DisclosedSweep | null; readonly failures: readonly InboundMailSweepFailure[]; readonly summary: string; } /** * How long a disclosure entry is kept, in addition to the count cap. * * BOTH BOUNDS, because the count cap alone is an age cap only by accident. A * daemon sweeping every six hours fills twenty entries in five days, so the * count looks like it reaps by age, but the reaping is done by ARRIVALS, and a * store nothing writes to has none. A mailbox that goes quiet, a surface * switched off, a daemon that stops running: in every one of those the twentieth * entry is the last one written and it stays forever, which is the same * "persisted state with no GC" the log exists to record about other files. * * §9's rule is that anything persisted reaps on a clock as well as on a bound, * and ninety days is the same order as the record store's own retention: long * enough that "what happened to my mail last month" is answerable, short enough * that nothing here is indefinite. */ export declare const DEFAULT_DISCLOSURE_RETENTION_MS: number; /** * Validate a report read back from the log BY CONTENT. * * `listDisclosures()` used to hand back `log.reports` on the strength of it * being an array, the only structure here read without validation, in a file * whose own header states the rule (§9: reap, bound, validate by content, * sweep, disclose). A hand-edited or half-written entry then flowed straight * into whatever rendered it. * * Only the fields a reader is entitled to rely on are checked, and anything * failing is dropped rather than repaired, the same rule the three stores * apply to their own records. */ export declare function validateDisclosedHousekeepingReport(value: unknown): DisclosedHousekeepingReport | null; export interface InboundMailHousekeeperOptions { readonly cursors: MailboxCursorStore; readonly records: InboundMailStore; readonly expectations: PersistedExpectationStore; /** Where the disclosure log is written. */ readonly disclosurePath: string; /** Age bound on the log's own entries. Defaults to `DEFAULT_DISCLOSURE_RETENTION_MS`. */ readonly disclosureRetentionMs?: number | undefined; readonly now?: (() => number) | undefined; } /** * Sweeps all three inbound-mail stores and records the disclosure. Construct * once at daemon boot, call `runRecoverySweep()` before the watcher serves * any mail, and `start()` to keep sweeping on a timer. */ export declare class InboundMailHousekeeper { private readonly cursors; private readonly records; private readonly expectations; private readonly disclosure; private readonly disclosureRetentionMs; private readonly now; private timer; private lastReport; /** * Orders the disclosure log's read-modify-write. See `sweep`. * * The log is not a snapshot of anything in memory, each write is the file's * own previous contents plus one entry, so the unit that has to be * serialised is the READ AND THE WRITE TOGETHER, not the write alone. */ private readonly writes; constructor(options: InboundMailHousekeeperOptions); /** The most recent report this process produced, or null before the first sweep. */ getLastReport(): InboundMailHousekeepingReport | null; /** * Disclosure history from disk, newest last. * * An unreadable log is an empty history rather than an error: the log exists * to explain reaping, and refusing to reap because the explanation of the * last reap will not parse is the tail wagging the dog. */ listDisclosures(): Promise; /** * One full pass over all three stores, with the result disclosed to disk. * * Each store is swept INDEPENDENTLY. They were swept in one sequential * expression, which made the first store's failure the second and third * store's failure too, one unreadable cursor file left records past their * retention and expired expectations sitting on disk, with no report, no * disclosure, and nothing anywhere saying why. Three separate attempts, three * separate answers, and the pass itself always produces a report. */ sweep(trigger: HousekeepingTrigger): Promise; /** * The recovery pass. Runs before the watcher serves any mail, so a cursor * for a de-configured account, a torn record, or an already-expired * expectation is removed rather than honoured on the first message after a * restart. */ runRecoverySweep(): Promise; /** * Keep sweeping on an interval. A long-lived daemon that only swept at boot * would never sweep at all, so this is not optional wiring. The timer is * unref'd so it never keeps the process alive by itself. */ start(intervalMs: number): void; stop(): void; } //# sourceMappingURL=housekeeping.d.ts.map