/** * Usage ledger (usage-ledger plan — Ticket 1). * * A per-day, per-provider, per-capability record of billable call * attempts, stored as `/usage.json` (DESIGN D1: a sibling * of `config.json`/`state.json`, never a `state.json` v2 — the quota * store keeps exclusive ownership of `state.json`). Every * {@link ConsumptionEvent} recorded by a consumption sink increments * the matching counters under the UTC calendar date of `event.at` * (DESIGN D2). * * Boundary rules: * - This module owns the ledger's schema, its pure merge/prune * algebra, its fail-open reader, and its path resolution. The * fs-writing sink (Ticket 2, `createUsageLedgerSink`) composes * them under the async-file-lock with an atomic temp+rename. * - No static fs import: all file I/O flows through injectable deps * (`readUsageLedger`'s optional reader; the Ticket 2 sink's fs * deps). The default reader lazily imports `node:fs/promises` so * the production `usage` command can call `readUsageLedger(path)` * bare (DESIGN D8: silent-on-corrupt, credential-free). * - Fail-open on read, mirroring `quota-store.ts`'s state reads: a * corrupt, wrong-version, or unreadable ledger yields an empty * ledger plus a routed warning — never a thrown error, never a * destructive rewrite on read. * - Retention (DESIGN D5): 90 days, pruned on day-roll only — the * first write whose UTC day key is absent from the loaded ledger * drops out-of-window day keys in the same write. No other prune * path; no config surface in v1. */ import type { ProviderId } from "../providers/types.js"; import type { ConsumptionEvent, ConsumptionSink } from "./consumption.js"; /** Ledger schema version; a mismatched version on read fails open. */ export declare const USAGE_LEDGER_VERSION: 1; /** Ledger file name — a config-root sibling of `config.json` (DESIGN D1). */ export declare const USAGE_LEDGER_FILENAME = "usage.json"; /** Default retention window in days (DESIGN D5: constant, no config surface in v1). */ export declare const DEFAULT_USAGE_RETENTION_DAYS = 90; /** * Per-provider, per-capability counters for one UTC day. Every axis is * maintained by {@link mergeEventIntoLedger}: * - `attempts` — every {@link ConsumptionEvent} (retries included). * - `firstTries` — events with `attempt === 1`. * - `exactUnits` — sum of exact amounts (0 today; reserved — no * production emitter yet). * - `estimateUnits` — sum of estimate values (search/reader/map/repo * default to 1 each). * - `unknownCount` — events whose amount kind is `"unknown"`. */ export interface UsageCounters { attempts: number; firstTries: number; exactUnits: number; estimateUnits: number; unknownCount: number; } /** * The on-disk ledger shape. `days` maps a UTC calendar date * `"YYYY-MM-DD"` to per-provider counter records; capability keys are * the emitted `capabilityId` verbatim (no normalization at the ledger * layer). `version` gates schema evolution via a strict-equality check * on read. */ export interface UsageLedger { readonly version: typeof USAGE_LEDGER_VERSION; /** * UTC calendar date `"YYYY-MM-DD"` → per-provider counters. The * provider map is `Partial` for the same reason `quota-store.ts`'s * schema is: strict TS cannot construct a full `Record` key-by-key. The on-disk JSON shape is unchanged from DESIGN D2 * — only recorded providers appear as keys. */ readonly days: Record>>>; } /** A schema-v1 ledger with no recorded days. */ export declare function emptyUsageLedger(): UsageLedger; /** * The UTC calendar-date bucket key (`"YYYY-MM-DD"`) for a millisecond * instant. Deterministic and timezone-independent (DESIGN D2: UTC day * bucketing keyed off `event.at` — local-time bucketing would need a * TZ injection for no user value). Lexicographic order on these keys * is chronological order, which {@link pruneExpiredDays} relies on. */ export declare function usageDayKey(at: number): string; /** * Whether `key` is a CANONICAL UTC calendar-date key: `"YYYY-MM-DD"`, * zero-padded, naming a real date. Round-trips through the millisecond * instant so impossible-but-parseable dates (`"2026-02-30"`, which * `Date.parse` normalizes to March 2) and non-padded forms (`"2026-8-6"`) * are rejected. Consumers that compare day keys lexicographically * (window filters, pruning) must skip keys that fail this check — a * malformed key's sort position is meaningless. */ export declare function isCanonicalUsageDayKey(key: string): boolean; export interface MergeEventOptions { /** * Retention window in days. When provided AND the event's UTC day key * is absent from the ledger (a day-roll), out-of-window day keys are * dropped in the same merge — exactly one prune pass per day-roll, * no other prune path (DESIGN D5). */ readonly retentionDays?: number; } /** * Merge one {@link ConsumptionEvent} into a ledger, incrementing the * event's provider/capability counters under the UTC day key of * `event.at`. Pure: returns a new ledger, never mutates the input. * * When `options.retentionDays` is set and the event's day key is new * (day-roll), the merge also prunes expired days relative to the new * day — the single prune pass of DESIGN D5. Same-day merges never * prune. */ export declare function mergeEventIntoLedger(ledger: UsageLedger, event: ConsumptionEvent, options?: MergeEventOptions): UsageLedger; /** * Drop day keys outside the retention window. Pure: returns a new * ledger, never mutates the input. * * The window is exactly `retentionDays` day keys: the reference day * inclusive plus `retentionDays - 1` days back. The key exactly * `retentionDays` older than the reference is dropped (the 90-day * window of DESIGN D5). Day keys compare lexicographically, which for * `"YYYY-MM-DD"` is chronological. * * A reference key that is not a parsable UTC date leaves the ledger * unchanged (defensive — pruning must never destroy history on a * malformed input). So does a window that is not a positive integer, or * one whose computed cutoff instant is outside the ~±8.64e15 ms range * ECMAScript Dates can represent (review P2 — the cutoff date is * validated, never thrown on). */ export declare function pruneExpiredDays(ledger: UsageLedger, retentionDays: number, referenceDayKey: string): UsageLedger; export interface UsageLedgerReadDeps { /** * Injectable file reader (returns raw file contents). Default: the * real filesystem, imported lazily so this module carries no static * fs import. */ readonly readFile?: (filePath: string) => Promise; /** * Warning channel for fail-open conditions (corrupt JSON, version * mismatch, unreadable file). Default: no-op — the production * `usage` command reads bare so its silent-on-corrupt contract * holds (DESIGN D8); the Ticket 2 sink injects its own channel. */ readonly onWarning?: (message: string) => void; } /** * Read and parse the ledger at `filePath`. Fail-open, never throws: * - missing file (ENOENT) → empty ledger, no warning; * - corrupt JSON / non-object payload / version mismatch / * non-object `days` → empty ledger + warning; * - any other read failure → empty ledger + warning. * * Never rewrites the file on read (DESIGN D2: no destructive rewrite * on the read path). */ export declare function readUsageLedger(filePath: string, deps?: UsageLedgerReadDeps): Promise; export interface UsageLedgerSinkOptions { /** * Absolute path to the ledger file — typically * {@link resolveUsageLedgerPath}. */ readonly filePath: string; /** * Injectable reader, shared shape with * {@link UsageLedgerReadDeps.readFile}. Default: the real filesystem. */ readonly readFile?: (filePath: string) => Promise; /** * Injectable atomic write (temp-file + rename, DESIGN D4). Default: * config-store's `atomicReplaceFile`, imported lazily so this module * keeps no static fs import. */ readonly writeFile?: (filePath: string, contents: string) => Promise; /** * Injectable critical-section serializer for the read-modify-write. * Default: `withAsyncFileLock` over the ledger's directory with lock * identity `usage.json` — lock file `/usage.json.lock` (DESIGN * D4) — imported lazily. */ readonly lock?: (criticalSection: () => Promise) => Promise; /** * Injectable clock. Defensive fallback only: shared execution always * stamps `event.at` before the sink sees the event. */ readonly now?: () => number; /** * Best-effort warning channel. Every internal failure — read, lock, * or write — surfaces here as a REDACTED, detail-free message; * `record()` never throws (DESIGN D3). Default: stderr, mirroring * the quota-store sink. */ readonly onWarning?: (message: string) => void; /** Retention window in days (DESIGN D5). Default: 90. */ readonly retentionDays?: number; } /** * Build the fs-writing {@link ConsumptionSink} for `usage.json` (DESIGN * D3/D4): one {@link ConsumptionEvent} becomes a read-modify-write under * the async file lock (`/usage.json.lock` — the `wx` lockfile * serializes writers in-process and cross-process), committed via an * atomic temp+rename, with the single day-roll prune pass of D5 * (`retentionDays` flowing into {@link mergeEventIntoLedger}). * * Fail-open like the rest of the ledger: `record()` NEVER throws. A * corrupt or unreadable ledger yields an empty ledger (plus a routed * warning) and the write recreates the file; a lock or write failure * becomes one redacted warning and the recorded promise resolves so * shared execution never observes an accounting failure. */ export declare function createUsageLedgerSink(options: UsageLedgerSinkOptions): ConsumptionSink; /** * Resolve the absolute path to `usage.json`. Defaults to * `/usage.json` where `` is * `resolveConfigRoot()` (`SCOUTLINE_CONFIG_DIR` || `~/.scoutline`) — * the same dedicated root as `config.json` and `state.json` (DESIGN * D1). Pure: `path.join` over its inputs, no I/O. */ export declare function resolveUsageLedgerPath(root?: string): string; //# sourceMappingURL=usage-ledger.d.ts.map