| 1 | /** |
| 2 | * Datasets: the safe query layer behind dashboards in Artifacts. Mirrors |
| 3 | * `crates/contracts/src/datasets.rs`; a Rust test keeps the catalog the |
| 4 | * same and runs both validators over `datasets.fixtures.json`. |
| 5 | * |
| 6 | * Not SQL. A query names a dataset from a declared catalog, one measure, |
| 7 | * at most one dimension to group by, a time interval, filters on declared |
| 8 | * fields and a range. The service that owns the data (`service` in the |
| 9 | * catalog) runs it for the viewer, over only what the viewer can read, with |
| 10 | * a fixed query per measure and dimension, and caps the rows. |
| 11 | * |
| 12 | * Values never live in a folio: not in its Yjs document, versions, text, |
| 13 | * preview, index or templates. Only the query does. |
| 14 | * |
| 15 | * Wire shapes are snake_case. |
| 16 | */ |
| 17 | |
| 18 | export type DatasetId = "issues" | "pull_requests" | "workflow_runs" | "deployments" | "spend" | "agent_sessions"; |
| 19 | |
| 20 | export const DATASET_IDS: readonly DatasetId[] = ["issues", "pull_requests", "workflow_runs", "deployments", "spend", "agent_sessions"]; |
| 21 | |
| 22 | /** How rows are summed up. `count` takes no field; `rate` takes a rate field; the rest a measure field. */ |
| 23 | export type DatasetMeasureOp = "count" | "sum" | "avg" | "p50" | "p95" | "rate"; |
| 24 | export const DATASET_MEASURE_OPS: readonly DatasetMeasureOp[] = ["count", "sum", "avg", "p50", "p95", "rate"]; |
| 25 | |
| 26 | export type DatasetInterval = "day" | "week" | "month"; |
| 27 | export const DATASET_INTERVALS: readonly DatasetInterval[] = ["day", "week", "month"]; |
| 28 | |
| 29 | export type DatasetFilterOp = "eq" | "neq" | "in" | "gte" | "lte"; |
| 30 | export const DATASET_FILTER_OPS: readonly DatasetFilterOp[] = ["eq", "neq", "in", "gte", "lte"]; |
| 31 | |
| 32 | export type DatasetRangePreset = "7d" | "30d" | "90d"; |
| 33 | export const DATASET_RANGE_PRESETS: readonly DatasetRangePreset[] = ["7d", "30d", "90d"]; |
| 34 | |
| 35 | /** |
| 36 | * A time range: a preset counted back from now, or between two times. |
| 37 | * `from` and `to` are dates (`2026-10-01`) or RFC 3339 UTC times |
| 38 | * (`2026-10-01T09:00:00Z`); `to` is exclusive. |
| 39 | */ |
| 40 | export type DatasetRange = DatasetRangePreset | { from: string; to: string }; |
| 41 | |
| 42 | export type DatasetFilter = { |
| 43 | field: string; |
| 44 | op: DatasetFilterOp; |
| 45 | /** Text for `eq`/`neq` on a dimension, a list for `in`, a number on a measure. */ |
| 46 | value: string | number | string[]; |
| 47 | }; |
| 48 | |
| 49 | export type DatasetQuery = { |
| 50 | dataset: DatasetId; |
| 51 | measure: { op: DatasetMeasureOp; field?: string | null }; |
| 52 | /** A declared dimension only. */ |
| 53 | group_by?: string | null; |
| 54 | /** A time series, over the dataset's time field. */ |
| 55 | interval?: DatasetInterval | null; |
| 56 | /** Which of the dataset's time fields the range and interval use; its first when left out. */ |
| 57 | time?: string | null; |
| 58 | filters?: DatasetFilter[] | null; |
| 59 | /** The tile's own range; else the dashboard's. */ |
| 60 | range?: DatasetRange | null; |
| 61 | /** At most `DATASET_MAX_ROWS`. */ |
| 62 | limit?: number | null; |
| 63 | }; |
| 64 | |
| 65 | export type DatasetColumnType = "string" | "number" | "time" | "money"; |
| 66 | |
| 67 | export type DatasetResult = { |
| 68 | columns: { name: string; type: DatasetColumnType }[]; |
| 69 | rows: (string | number | null)[][]; |
| 70 | /** More rows matched than were returned. */ |
| 71 | truncated: boolean; |
| 72 | /** The viewer cannot read everything the query covers: shown as "Based on what you can see". */ |
| 73 | partial: boolean; |
| 74 | /** When the numbers were computed, RFC 3339. */ |
| 75 | as_of: string; |
| 76 | }; |
| 77 | |
| 78 | /** The services that own datasets, each answering `query_dataset` for its own. */ |
| 79 | export type DatasetService = "work" | "actions" | "deployments" | "billing" | "agents"; |
| 80 | |
| 81 | /** What a viewer needs to query a dataset: membership, or the workspace's billing role. */ |
| 82 | export type DatasetNeeds = "member" | "billing"; |
| 83 | |
| 84 | export type DatasetSpec = { |
| 85 | label: string; |
| 86 | service: DatasetService; |
| 87 | needs: DatasetNeeds; |
| 88 | /** Time fields, the default first. */ |
| 89 | times: string[]; |
| 90 | /** Fields to group and filter by (text). */ |
| 91 | dimensions: string[]; |
| 92 | /** Number fields for sum, avg, p50, p95, and number filters. */ |
| 93 | measures: string[]; |
| 94 | /** Yes-or-no fields `rate` gives the share of. */ |
| 95 | rates: string[]; |
| 96 | }; |
| 97 | |
| 98 | /** The catalog. One dataset per line: the Rust mirror test reads it that way. */ |
| 99 | export const DATASETS: Record<DatasetId, DatasetSpec> = { |
| 100 | issues: { label: "Issues", service: "work", needs: "member", times: ["created_at", "closed_at"], dimensions: ["repo", "label", "state", "author_kind", "assignee_kind", "milestone"], measures: ["time_to_close_hours", "comments"], rates: ["closed"] }, |
| 101 | pull_requests: { label: "Pull requests", service: "work", needs: "member", times: ["created_at", "merged_at", "closed_at"], dimensions: ["repo", "label", "state", "author_kind", "base_branch"], measures: ["cycle_time_hours", "time_to_first_review_hours", "review_count", "additions", "deletions", "changed_files"], rates: ["merged"] }, |
| 102 | workflow_runs: { label: "Workflow runs", service: "actions", needs: "member", times: ["started_at", "completed_at"], dimensions: ["repo", "workflow", "branch", "event", "conclusion", "runner_kind"], measures: ["duration_seconds", "queue_seconds"], rates: ["succeeded"] }, |
| 103 | deployments: { label: "Deployments", service: "deployments", needs: "member", times: ["created_at"], dimensions: ["repo", "project", "environment", "state"], measures: ["duration_seconds", "time_to_restore_hours"], rates: ["failed"] }, |
| 104 | spend: { label: "Spend", service: "billing", needs: "billing", times: ["day"], dimensions: ["product", "project", "person", "model"], measures: ["amount_micros"], rates: [] }, |
| 105 | agent_sessions: { label: "Agent sessions", service: "agents", needs: "member", times: ["started_at"], dimensions: ["agent", "repo", "outcome", "model", "trigger"], measures: ["duration_seconds", "cost_micros", "tokens"], rates: ["succeeded"] }, |
| 106 | }; |
| 107 | |
| 108 | /** The most rows a query returns. */ |
| 109 | export const DATASET_MAX_ROWS = 100; |
| 110 | /** The most filters on one query. */ |
| 111 | export const DATASET_MAX_FILTERS = 10; |
| 112 | /** The most values in an `in` filter. */ |
| 113 | export const DATASET_MAX_IN_VALUES = 50; |
| 114 | /** The longest `{ from, to }` range, in days. */ |
| 115 | export const DATASET_MAX_RANGE_DAYS = 366; |
| 116 | |
| 117 | const DAY_MS = 86_400_000; |
| 118 | const TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z)?$/; |
| 119 | |
| 120 | /** A range end as milliseconds since the epoch, or null when it is not a real date or RFC 3339 UTC time. */ |
| 121 | export function datasetTime(text: string): number | null { |
| 122 | const match = TIME.exec(text); |
| 123 | if (!match) return null; |
| 124 | const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; |
| 125 | const [hour, minute, second] = [Number(match[4] ?? 0), Number(match[5] ?? 0), Number(match[6] ?? 0)]; |
| 126 | const millis = Number((match[7] ?? "").padEnd(3, "0") || 0); |
| 127 | if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null; |
| 128 | if (hour > 23 || minute > 59 || second > 59) return null; |
| 129 | return Date.UTC(year, month - 1, day, hour, minute, second, millis); |
| 130 | } |
| 131 | |
| 132 | function daysInMonth(year: number, month: number): number { |
| 133 | if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; |
| 134 | return [4, 6, 9, 11].includes(month) ? 30 : 31; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * What is wrong with a query, or null. The same rules, and the same |
| 139 | * words, as `DatasetQuery::validate` in Rust. It checks the query against |
| 140 | * the catalog only; who may run it is the owning service's to decide. |
| 141 | */ |
| 142 | export function datasetQueryError(query: DatasetQuery): string | null { |
| 143 | const spec = DATASETS[query.dataset]; |
| 144 | if (!spec) return `There is no dataset called ${query.dataset}.`; |
| 145 | const name = query.dataset; |
| 146 | const { op } = query.measure; |
| 147 | const field = query.measure.field ?? null; |
| 148 | if (op === "count") { |
| 149 | if (field !== null) return "count takes no field."; |
| 150 | } else if (op === "rate") { |
| 151 | if (field === null) return "rate needs a field."; |
| 152 | if (!spec.rates.includes(field)) return `${name} has no yes-or-no field ${field} to take the rate of.`; |
| 153 | } else { |
| 154 | if (field === null) return `${op} needs a field.`; |
| 155 | if (!spec.measures.includes(field)) return `${name} has no number field ${field}.`; |
| 156 | } |
| 157 | const groupBy = query.group_by ?? null; |
| 158 | if (groupBy !== null && !spec.dimensions.includes(groupBy)) return `${name} can't be grouped by ${groupBy}.`; |
| 159 | const time = query.time ?? null; |
| 160 | if (time !== null && !spec.times.includes(time)) return `${name} has no time field ${time}.`; |
| 161 | const filters = query.filters ?? []; |
| 162 | if (filters.length > DATASET_MAX_FILTERS) return `A query takes at most ${DATASET_MAX_FILTERS} filters.`; |
| 163 | for (const filter of filters) { |
| 164 | if (spec.dimensions.includes(filter.field)) { |
| 165 | if (filter.op === "in") { |
| 166 | if (!Array.isArray(filter.value) || filter.value.length === 0 || filter.value.length > DATASET_MAX_IN_VALUES) { |
| 167 | return `in on ${filter.field} takes a list of 1 to ${DATASET_MAX_IN_VALUES} values.`; |
| 168 | } |
| 169 | } else if (filter.op === "eq" || filter.op === "neq") { |
| 170 | if (typeof filter.value !== "string") return `${filter.op} on ${filter.field} takes text.`; |
| 171 | } else { |
| 172 | return `${filter.field} is text: filter it with eq, neq or in.`; |
| 173 | } |
| 174 | } else if (spec.measures.includes(filter.field)) { |
| 175 | if (filter.op === "in") return `${filter.field} is a number: filter it with eq, neq, gte or lte.`; |
| 176 | if (typeof filter.value !== "number" || !Number.isFinite(filter.value)) return `${filter.op} on ${filter.field} takes a number.`; |
| 177 | } else { |
| 178 | return `${name} can't be filtered by ${filter.field}.`; |
| 179 | } |
| 180 | } |
| 181 | const range = query.range ?? null; |
| 182 | if (range !== null && typeof range === "object") { |
| 183 | const from = datasetTime(range.from); |
| 184 | const to = datasetTime(range.to); |
| 185 | if (from === null || to === null) return "A range's from and to are dates or RFC 3339 UTC times."; |
| 186 | if (from >= to) return "A range's from comes before its to."; |
| 187 | if (to - from > DATASET_MAX_RANGE_DAYS * DAY_MS) return `A range is at most ${DATASET_MAX_RANGE_DAYS} days.`; |
| 188 | } |
| 189 | const limit = query.limit ?? null; |
| 190 | if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > DATASET_MAX_ROWS)) return `limit is between 1 and ${DATASET_MAX_ROWS}.`; |
| 191 | return null; |
| 192 | } |
| 193 | |
| 194 | /** A query from untrusted JSON: well-formed and valid, or why not. Unknown keys are dropped. */ |
| 195 | export function parseDatasetQuery(input: unknown): { query: DatasetQuery } | { error: string } { |
| 196 | if (!isObject(input)) return { error: "A query is an object." }; |
| 197 | if (typeof input.dataset !== "string" || !(DATASET_IDS as readonly string[]).includes(input.dataset)) { |
| 198 | return { error: `There is no dataset called ${String(input.dataset)}.` }; |
| 199 | } |
| 200 | const measure = input.measure; |
| 201 | if (!isObject(measure) || typeof measure.op !== "string" || !(DATASET_MEASURE_OPS as readonly string[]).includes(measure.op)) { |
| 202 | return { error: "measure.op is count, sum, avg, p50, p95 or rate." }; |
| 203 | } |
| 204 | if (!optional(measure.field, "string")) return { error: "measure.field is text." }; |
| 205 | if (!optional(input.group_by, "string")) return { error: "group_by is text." }; |
| 206 | if (!optional(input.time, "string")) return { error: "time is text." }; |
| 207 | if (input.interval != null && !(DATASET_INTERVALS as readonly unknown[]).includes(input.interval)) return { error: "interval is day, week or month." }; |
| 208 | if (input.limit != null && typeof input.limit !== "number") return { error: "limit is a number." }; |
| 209 | let filters: DatasetFilter[] | null = null; |
| 210 | if (input.filters != null) { |
| 211 | if (!Array.isArray(input.filters)) return { error: "filters is a list." }; |
| 212 | filters = []; |
| 213 | for (const filter of input.filters) { |
| 214 | if (!isObject(filter) || typeof filter.field !== "string" || !(DATASET_FILTER_OPS as readonly unknown[]).includes(filter.op)) { |
| 215 | return { error: "A filter has a field and an op: eq, neq, in, gte or lte." }; |
| 216 | } |
| 217 | const value = filter.value; |
| 218 | const ok = |
| 219 | typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) || (Array.isArray(value) && value.every((item) => typeof item === "string")); |
| 220 | if (!ok) return { error: "A filter's value is text, a number or a list of text." }; |
| 221 | filters.push({ field: filter.field, op: filter.op as DatasetFilterOp, value: value as DatasetFilter["value"] }); |
| 222 | } |
| 223 | } |
| 224 | let range: DatasetRange | null = null; |
| 225 | if (input.range != null) { |
| 226 | if (typeof input.range === "string" && (DATASET_RANGE_PRESETS as readonly string[]).includes(input.range)) range = input.range as DatasetRangePreset; |
| 227 | else if (isObject(input.range) && typeof input.range.from === "string" && typeof input.range.to === "string") range = { from: input.range.from, to: input.range.to }; |
| 228 | else return { error: "range is 7d, 30d, 90d or { from, to }." }; |
| 229 | } |
| 230 | const query: DatasetQuery = { |
| 231 | dataset: input.dataset as DatasetId, |
| 232 | measure: { op: measure.op as DatasetMeasureOp, field: (measure.field as string | null | undefined) ?? null }, |
| 233 | group_by: (input.group_by as string | null | undefined) ?? null, |
| 234 | interval: (input.interval as DatasetInterval | null | undefined) ?? null, |
| 235 | time: (input.time as string | null | undefined) ?? null, |
| 236 | filters, |
| 237 | range, |
| 238 | limit: (input.limit as number | null | undefined) ?? null, |
| 239 | }; |
| 240 | const error = datasetQueryError(query); |
| 241 | return error ? { error } : { query }; |
| 242 | } |
| 243 | |
| 244 | function isObject(value: unknown): value is Record<string, unknown> { |
| 245 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 246 | } |
| 247 | |
| 248 | function optional(value: unknown, type: "string"): boolean { |
| 249 | return value === undefined || value === null || typeof value === type; |
| 250 | } |