import { type BucketGrain, type MetricBucketOptions, type MetricBucketRow, type SubscriptionRecord } from "@uptimizr/db"; import { type ComparisonOp, type SubscriptionFiring } from "@uptimizr/schema"; import type { MetricDefinition } from "@uptimizr/metrics"; /** * **Subscription predicate evaluation** (#311, ADR 0051 §6 / sketch §F.2). * * Everything that decides whether a standing question is answered "yes" right * now lives here, and it is a pure function of *rows* — the only I/O is the one * injected {@link BucketReader}, which is the same * `store.metricBuckets(projectId, …)` the insight routes call. That keeps the * whole predicate vocabulary unit-testable without a database, a timer or a * server, and keeps the four stores interchangeable: the series a predicate sees * is the parity-tested portable bucket series, not per-dialect SQL of its own. * * ## Why the window is not snapped the way the insight routes snap theirs * * `baseline`, `movers` and `anomalies` floor `until` to the last **complete** * bucket, because they compare periods and a partial period reads as a collapse * every morning. A subscription is the opposite question — *is something wrong * right now* — so its window ends at the instant of evaluation and its final * bucket is deliberately partial. `since` is still floored, so the series the * store returns is bucket-aligned and every row is attributed exactly once. * * ## Why `threshold.column` must be the metric's headline column * * The portable series reproduces exactly one column per metric — the registry's * `comparable.primary` (`perf_summary` → `p50_fps`). Comparing any other column * would mean running the metric's own endpoint handler, which is per-metric code * the scheduler would have to re-enter, and would drag five dialects' quantile * semantics into an alert threshold. Asking for another column is a `400` that * *names* the right one — see {@link thresholdColumnFor}. */ /** The one store read a predicate may issue, injected so tests need no store. */ export type BucketReader = (opts: MetricBucketOptions) => Promise; /** Live presence, injected the same way (the `presence` predicate's only input). */ export type PresenceReader = () => number; /** What an evaluation needs besides the subscription itself. */ export interface EvaluateDeps { readBuckets: BucketReader; /** Concurrent live sessions; only read by the `presence` predicate. */ activeSessions?: PresenceReader; /** Injected clock. Defaults to `Date.now`. */ now?: () => number; } /** The half-open window an evaluation measured. */ export interface EvaluationWindow { since: number; until: number; } /** * The outcome of one evaluation. * * A non-firing evaluation still carries its numbers and a `reason`: that is what * `POST /api/v1/subscriptions/:id/test` answers with, and "it did not fire * because the window held 12 samples and `minSample` is 20" is the difference * between a subscription an operator can tune and one they delete. */ export interface EvaluationResult { fired: boolean; /** Human-readable account of the outcome, firing or not. */ reason: string; window: EvaluationWindow; /** The observed value the predicate judged, when there is a single one. */ value: number | null; /** What it was judged against — a level, an expectation, a previous window. */ expected: number | null; /** The window's denominator: events, or distinct sessions. */ sampleSize: number; /** The dimension value behind the outcome (`new_value`, attributed anomaly). */ dimensionValue: string | null; /** The bucket series the evaluation read, for the delivery summary. */ series: readonly MetricBucketRow[]; /** Grain the series was read at. */ bucket: BucketGrain; } /** Resolve the series grain: the declared one, else hourly for short windows. */ export declare function bucketFor(sub: SubscriptionRecord): BucketGrain; /** * The window one evaluation measures: `[floor(now − window), now)`. * * `since` is floored so the series is bucket-aligned; `until` is not, so the * partial bucket containing *this minute* is included. See the module note. */ export declare function resolveWindow(windowMs: number, bucket: BucketGrain, now: number): EvaluationWindow; /** Compare two numbers with a subscription's operator. */ export declare function compare(left: number, op: ComparisonOp, right: number): boolean; /** * The column a `threshold` predicate on `metric` may name — its registry * `comparable.primary`, or `null` when the metric declares none. */ export declare function thresholdColumnFor(metric: MetricDefinition): string | null; /** Whether a metric can back a store-evaluated subscription at all. */ export declare function isSubscribableMetric(metricId: string): boolean; /** * Evaluate one subscription now. * * Every predicate kind but `presence` issues **one** bucket read; `movers` and * `new_value` cover both their windows with a single spanning scan, the same * trick `movers` itself uses, so both windows are guaranteed to have seen one * snapshot of the data. */ export declare function evaluateSubscription(deps: EvaluateDeps, sub: SubscriptionRecord, metric: MetricDefinition): Promise; /** Shape an evaluation into the firing record stored and delivered. */ export declare function toFiring(sub: SubscriptionRecord, result: EvaluationResult, at: number): SubscriptionFiring; /** * The bounded `format=summary` block a webhook carries alongside the firing * (sketch §F.3: "so the receiver can act without a second call"). * * It is the registry's own summary of `insight_baseline` over exactly the window * that fired — a real registry metric with real declared units and caveats, * rather than a hand-rolled bag of numbers. That is deliberate: the receiver * reads the same envelope it would get from `GET /api/v1/insights/baseline`, so * anything that can already render one can render this. * * `null` when the predicate read no series (`presence`) or the summariser * declines the rows. */ export declare function summaryFor(sub: SubscriptionRecord, result: EvaluationResult): Record | null; //# sourceMappingURL=evaluate.d.ts.map