| 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 = "work_items" | "pull_requests" | "workflow_runs" | "deployments" | "spend" | "agent_sessions"; |
| 19 | |
| 20 | export const DATASET_IDS: readonly DatasetId[] = ["work_items", "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 | work_items: { |
| 101 | label: "Work items", |
| 102 | service: "work", |
| 103 | needs: "member", |
| 104 | times: ["created_at", "closed_at"], |
| 105 | dimensions: ["repo", "label", "state", "author_kind", "assignee_kind", "milestone"], |
| 106 | measures: ["time_to_close_hours", "comments"], |
| 107 | rates: ["closed"], |
| 108 | }, |
| 109 | pull_requests: { |
| 110 | label: "Pull requests", |
| 111 | service: "work", |
| 112 | needs: "member", |
| 113 | times: ["created_at", "merged_at", "closed_at"], |
| 114 | dimensions: ["repo", "label", "state", "author_kind", "base_branch"], |
| 115 | measures: ["cycle_time_hours", "time_to_first_review_hours", "review_count", "additions", "deletions", "changed_files"], |
| 116 | rates: ["merged"], |
| 117 | }, |
| 118 | workflow_runs: { |
| 119 | label: "Workflow runs", |
| 120 | service: "actions", |
| 121 | needs: "member", |
| 122 | times: ["started_at", "completed_at"], |
| 123 | dimensions: ["repo", "workflow", "branch", "event", "conclusion", "runner_kind"], |
| 124 | measures: ["duration_seconds", "queue_seconds"], |
| 125 | rates: ["succeeded"], |
| 126 | }, |
| 127 | deployments: { |
| 128 | label: "Deployments", |
| 129 | service: "deployments", |
| 130 | needs: "member", |
| 131 | times: ["created_at"], |
| 132 | dimensions: ["repo", "project", "environment", "state"], |
| 133 | measures: ["duration_seconds", "time_to_restore_hours"], |
| 134 | rates: ["failed"], |
| 135 | }, |
| 136 | spend: { |
| 137 | label: "Spend", |
| 138 | service: "billing", |
| 139 | needs: "billing", |
| 140 | times: ["day"], |
| 141 | dimensions: ["product", "project", "person", "model"], |
| 142 | measures: ["amount_micros"], |
| 143 | rates: [], |
| 144 | }, |
| 145 | agent_sessions: { |
| 146 | label: "Agent sessions", |
| 147 | service: "agents", |
| 148 | needs: "member", |
| 149 | times: ["started_at"], |
| 150 | dimensions: ["agent", "repo", "outcome", "model", "trigger"], |
| 151 | measures: ["duration_seconds", "cost_micros", "tokens"], |
| 152 | rates: ["succeeded"], |
| 153 | }, |
| 154 | }; |
| 155 | |
| 156 | /** The most rows a query returns. */ |
| 157 | export const DATASET_MAX_ROWS = 100; |
| 158 | /** The most filters on one query. */ |
| 159 | export const DATASET_MAX_FILTERS = 10; |
| 160 | /** The most values in an `in` filter. */ |
| 161 | export const DATASET_MAX_IN_VALUES = 50; |
| 162 | /** The longest `{ from, to }` range, in days. */ |
| 163 | export const DATASET_MAX_RANGE_DAYS = 366; |
| 164 | |
| 165 | const DAY_MS = 86_400_000; |
| 166 | const TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z)?$/; |
| 167 | |
| 168 | /** A range end as milliseconds since the epoch, or null when it is not a real date or RFC 3339 UTC time. */ |
| 169 | export function datasetTime(text: string): number | null { |
| 170 | const match = TIME.exec(text); |
| 171 | if (!match) return null; |
| 172 | const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; |
| 173 | const [hour, minute, second] = [Number(match[4] ?? 0), Number(match[5] ?? 0), Number(match[6] ?? 0)]; |
| 174 | const millis = Number((match[7] ?? "").padEnd(3, "0") || 0); |
| 175 | if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null; |
| 176 | if (hour > 23 || minute > 59 || second > 59) return null; |
| 177 | return Date.UTC(year, month - 1, day, hour, minute, second, millis); |
| 178 | } |
| 179 | |
| 180 | function daysInMonth(year: number, month: number): number { |
| 181 | if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; |
| 182 | return [4, 6, 9, 11].includes(month) ? 30 : 31; |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * What is wrong with a query, or null. The same rules, and the same |
| 187 | * words, as `DatasetQuery::validate` in Rust. It checks the query against |
| 188 | * the catalog only; who may run it is the owning service's to decide. |
| 189 | */ |
| 190 | export function datasetQueryError(query: DatasetQuery): string | null { |
| 191 | const spec = DATASETS[query.dataset]; |
| 192 | if (!spec) return `There is no dataset called ${query.dataset}.`; |
| 193 | const name = query.dataset; |
| 194 | const { op } = query.measure; |
| 195 | const field = query.measure.field ?? null; |
| 196 | if (op === "count") { |
| 197 | if (field !== null) return "count takes no field."; |
| 198 | } else if (op === "rate") { |
| 199 | if (field === null) return "rate needs a field."; |
| 200 | if (!spec.rates.includes(field)) return `${name} has no yes-or-no field ${field} to take the rate of.`; |
| 201 | } else { |
| 202 | if (field === null) return `${op} needs a field.`; |
| 203 | if (!spec.measures.includes(field)) return `${name} has no number field ${field}.`; |
| 204 | } |
| 205 | const groupBy = query.group_by ?? null; |
| 206 | if (groupBy !== null && !spec.dimensions.includes(groupBy)) return `${name} can't be grouped by ${groupBy}.`; |
| 207 | const time = query.time ?? null; |
| 208 | if (time !== null && !spec.times.includes(time)) return `${name} has no time field ${time}.`; |
| 209 | const filters = query.filters ?? []; |
| 210 | if (filters.length > DATASET_MAX_FILTERS) return `A query takes at most ${DATASET_MAX_FILTERS} filters.`; |
| 211 | for (const filter of filters) { |
| 212 | if (spec.dimensions.includes(filter.field)) { |
| 213 | if (filter.op === "in") { |
| 214 | if (!Array.isArray(filter.value) || filter.value.length === 0 || filter.value.length > DATASET_MAX_IN_VALUES) { |
| 215 | return `in on ${filter.field} takes a list of 1 to ${DATASET_MAX_IN_VALUES} values.`; |
| 216 | } |
| 217 | } else if (filter.op === "eq" || filter.op === "neq") { |
| 218 | if (typeof filter.value !== "string") return `${filter.op} on ${filter.field} takes text.`; |
| 219 | } else { |
| 220 | return `${filter.field} is text: filter it with eq, neq or in.`; |
| 221 | } |
| 222 | } else if (spec.measures.includes(filter.field)) { |
| 223 | if (filter.op === "in") return `${filter.field} is a number: filter it with eq, neq, gte or lte.`; |
| 224 | if (typeof filter.value !== "number" || !Number.isFinite(filter.value)) return `${filter.op} on ${filter.field} takes a number.`; |
| 225 | } else { |
| 226 | return `${name} can't be filtered by ${filter.field}.`; |
| 227 | } |
| 228 | } |
| 229 | const range = query.range ?? null; |
| 230 | if (range !== null && typeof range === "object") { |
| 231 | const from = datasetTime(range.from); |
| 232 | const to = datasetTime(range.to); |
| 233 | if (from === null || to === null) return "A range's from and to are dates or RFC 3339 UTC times."; |
| 234 | if (from >= to) return "A range's from comes before its to."; |
| 235 | if (to - from > DATASET_MAX_RANGE_DAYS * DAY_MS) return `A range is at most ${DATASET_MAX_RANGE_DAYS} days.`; |
| 236 | } |
| 237 | const limit = query.limit ?? null; |
| 238 | if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > DATASET_MAX_ROWS)) return `limit is between 1 and ${DATASET_MAX_ROWS}.`; |
| 239 | return null; |
| 240 | } |
| 241 | |
| 242 | /** A query from untrusted JSON: well-formed and valid, or why not. Unknown keys are dropped. */ |
| 243 | export function parseDatasetQuery(input: unknown): { query: DatasetQuery } | { error: string } { |
| 244 | if (!isObject(input)) return { error: "A query is an object." }; |
| 245 | if (typeof input.dataset !== "string" || !(DATASET_IDS as readonly string[]).includes(input.dataset)) { |
| 246 | return { error: `There is no dataset called ${String(input.dataset)}.` }; |
| 247 | } |
| 248 | const measure = input.measure; |
| 249 | if (!isObject(measure) || typeof measure.op !== "string" || !(DATASET_MEASURE_OPS as readonly string[]).includes(measure.op)) { |
| 250 | return { error: "measure.op is count, sum, avg, p50, p95 or rate." }; |
| 251 | } |
| 252 | if (!optional(measure.field, "string")) return { error: "measure.field is text." }; |
| 253 | if (!optional(input.group_by, "string")) return { error: "group_by is text." }; |
| 254 | if (!optional(input.time, "string")) return { error: "time is text." }; |
| 255 | if (input.interval != null && !(DATASET_INTERVALS as readonly unknown[]).includes(input.interval)) return { error: "interval is day, week or month." }; |
| 256 | if (input.limit != null && typeof input.limit !== "number") return { error: "limit is a number." }; |
| 257 | let filters: DatasetFilter[] | null = null; |
| 258 | if (input.filters != null) { |
| 259 | if (!Array.isArray(input.filters)) return { error: "filters is a list." }; |
| 260 | filters = []; |
| 261 | for (const filter of input.filters) { |
| 262 | if (!isObject(filter) || typeof filter.field !== "string" || !(DATASET_FILTER_OPS as readonly unknown[]).includes(filter.op)) { |
| 263 | return { error: "A filter has a field and an op: eq, neq, in, gte or lte." }; |
| 264 | } |
| 265 | const value = filter.value; |
| 266 | const ok = |
| 267 | typeof value === "string" || |
| 268 | (typeof value === "number" && Number.isFinite(value)) || |
| 269 | (Array.isArray(value) && value.every((item) => typeof item === "string")); |
| 270 | if (!ok) return { error: "A filter's value is text, a number or a list of text." }; |
| 271 | filters.push({ field: filter.field, op: filter.op as DatasetFilterOp, value: value as DatasetFilter["value"] }); |
| 272 | } |
| 273 | } |
| 274 | let range: DatasetRange | null = null; |
| 275 | if (input.range != null) { |
| 276 | if (typeof input.range === "string" && (DATASET_RANGE_PRESETS as readonly string[]).includes(input.range)) range = input.range as DatasetRangePreset; |
| 277 | else if (isObject(input.range) && typeof input.range.from === "string" && typeof input.range.to === "string") |
| 278 | range = { from: input.range.from, to: input.range.to }; |
| 279 | else return { error: "range is 7d, 30d, 90d or { from, to }." }; |
| 280 | } |
| 281 | const query: DatasetQuery = { |
| 282 | dataset: input.dataset as DatasetId, |
| 283 | measure: { op: measure.op as DatasetMeasureOp, field: (measure.field as string | null | undefined) ?? null }, |
| 284 | group_by: (input.group_by as string | null | undefined) ?? null, |
| 285 | interval: (input.interval as DatasetInterval | null | undefined) ?? null, |
| 286 | time: (input.time as string | null | undefined) ?? null, |
| 287 | filters, |
| 288 | range, |
| 289 | limit: (input.limit as number | null | undefined) ?? null, |
| 290 | }; |
| 291 | const error = datasetQueryError(query); |
| 292 | return error ? { error } : { query }; |
| 293 | } |
| 294 | |
| 295 | function isObject(value: unknown): value is Record<string, unknown> { |
| 296 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 297 | } |
| 298 | |
| 299 | function optional(value: unknown, type: "string"): boolean { |
| 300 | return value === undefined || value === null || typeof value === type; |
| 301 | } |