measurr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts ADDED
@@ -0,0 +1,399 @@
1
+ import { z } from 'zod'
2
+ import type {
3
+ AnyDataset,
4
+ AnyDimension,
5
+ AnyMeasure,
6
+ Dataset,
7
+ DimensionValue,
8
+ MeasureValue
9
+ } from './dataset.js'
10
+ import {
11
+ MAX_PERIOD_DAYS,
12
+ type PeriodInput,
13
+ type PeriodPreset
14
+ } from './period.js'
15
+
16
+ export const MAX_MEASURES = 4
17
+ export const MAX_GROUP_BY = 2
18
+ export const MAX_LIMIT = 100
19
+ export const DEFAULT_LIMIT = 20
20
+
21
+ const PERIOD_PRESETS = [
22
+ 'today',
23
+ 'yesterday',
24
+ 'thisWeek',
25
+ 'lastWeek',
26
+ 'thisMonth',
27
+ 'lastMonth',
28
+ 'thisYear'
29
+ ] as const satisfies readonly PeriodPreset[]
30
+
31
+ // ---------------------------------------------------------------------------------------------
32
+ // Static types
33
+
34
+ type EmptinessFilter<Name> = { dimension: Name; op: 'isEmpty' | 'isNotEmpty' }
35
+
36
+ type FilterFor<Name extends string, Dim> = Dim extends {
37
+ category: 'closed'
38
+ keyType?: infer K
39
+ }
40
+ ?
41
+ | { dimension: Name; op: 'in' | 'notIn'; values: readonly K[] }
42
+ | EmptinessFilter<Name>
43
+ : Dim extends { category: 'open' }
44
+ ? // Open sets take keys or labels; labels are resolved at query time.
45
+ | { dimension: Name; op: 'in' | 'notIn'; values: readonly string[] }
46
+ | EmptinessFilter<Name>
47
+ : Dim extends { category: 'numeric' }
48
+ ?
49
+ | { dimension: Name; op: 'between'; from: number; to: number }
50
+ | EmptinessFilter<Name>
51
+ :
52
+ | { dimension: Name; op: 'between'; from: string; to: string }
53
+ | EmptinessFilter<Name>
54
+
55
+ /** Dimensions a query may group by: all but `filterOnly` ones. */
56
+ export type GroupableKey<Dm> = {
57
+ [K in keyof Dm & string]: Dm[K] extends { groupable: false } ? never : K
58
+ }[keyof Dm & string]
59
+
60
+ /** Dimensions a query may filter by: all but `groupOnly` ones. */
61
+ export type FilterableKey<Dm> = {
62
+ [K in keyof Dm & string]: Dm[K] extends { filterable: false } ? never : K
63
+ }[keyof Dm & string]
64
+
65
+ export type DatasetFilter<Dm> = {
66
+ [Name in FilterableKey<Dm>]: FilterFor<Name, Dm[Name]>
67
+ }[FilterableKey<Dm>]
68
+
69
+ /** The query one dataset accepts. Distributes over a union of datasets. */
70
+ export type DatasetQuery<DS> =
71
+ DS extends Dataset<infer K, infer M, infer Dm, infer T>
72
+ ? {
73
+ dataset: K
74
+ measures: readonly (keyof M & string)[]
75
+ groupBy?: readonly GroupableKey<Dm>[] | undefined
76
+ filters?: readonly DatasetFilter<Dm>[] | undefined
77
+ /** Null or omitted: the default period. */
78
+ period?: PeriodInput | null | undefined
79
+ time?: T | undefined
80
+ compareToPrevious?: boolean | undefined
81
+ sort?:
82
+ | {
83
+ by: (keyof M & string) | GroupableKey<Dm>
84
+ direction?: 'asc' | 'desc' | undefined
85
+ }
86
+ | undefined
87
+ limit?: number | undefined
88
+ }
89
+ : never
90
+
91
+ type GroupKeys<Q> = Q extends { groupBy: readonly (infer G extends string)[] }
92
+ ? G
93
+ : never
94
+
95
+ type Simplify<T> = { [K in keyof T]: T[K] } & {}
96
+
97
+ export type ResultRow<
98
+ M,
99
+ Dm,
100
+ MeasureKey extends string,
101
+ GroupKey extends string
102
+ > = Simplify<
103
+ { [G in GroupKey & keyof Dm]: DimensionValue<Dm[G]> } & {
104
+ [Key in MeasureKey & keyof M]: MeasureValue<M[Key]>
105
+ }
106
+ >
107
+
108
+ export type ResultTotals<M, MeasureKey extends string> = Simplify<{
109
+ [Key in MeasureKey & keyof M]: MeasureValue<M[Key]>
110
+ }>
111
+
112
+ export type QueryResult<Row, Totals> = {
113
+ period: { from: string; to: string; timezone: string }
114
+ rows: Row[]
115
+ totals: Totals
116
+ previous?: {
117
+ period: { from: string; to: string; timezone: string }
118
+ rows: Row[]
119
+ totals: Totals
120
+ }
121
+ notes: string[]
122
+ truncated: boolean
123
+ }
124
+
125
+ /** The typed result of query `Q` against the datasets `DS`. */
126
+ export type ResultOf<DS, Q> =
127
+ DS extends Dataset<infer K, infer M, infer Dm, string>
128
+ ? Q extends { dataset: K; measures: readonly (infer Mk extends string)[] }
129
+ ? QueryResult<ResultRow<M, Dm, Mk, GroupKeys<Q>>, ResultTotals<M, Mk>>
130
+ : never
131
+ : never
132
+
133
+ // ---------------------------------------------------------------------------------------------
134
+ // Runtime schemas
135
+
136
+ const isoDate = z.iso.date().describe('A date, YYYY-MM-DD.')
137
+
138
+ export const periodSchema = z
139
+ .union([
140
+ z.object({
141
+ last: z.object({
142
+ days: z
143
+ .number()
144
+ .int()
145
+ .min(1)
146
+ .max(MAX_PERIOD_DAYS)
147
+ .describe('Number of days, ending today.')
148
+ })
149
+ }),
150
+ z.object({ from: isoDate, to: isoDate.describe('Last day, inclusive.') }),
151
+ z.object({ preset: z.enum(PERIOD_PRESETS) })
152
+ ])
153
+ .describe(
154
+ 'Which days to count, in the organization timezone. Defaults to the last 30 days. At most two years.'
155
+ )
156
+
157
+ function describeList(
158
+ entries: [string, { label: string; description?: string | undefined }][]
159
+ ) {
160
+ return entries
161
+ .map(
162
+ ([key, entry]) =>
163
+ `${key}: ${entry.label}${entry.description ? ` (${entry.description})` : ''}`
164
+ )
165
+ .join(' · ')
166
+ }
167
+
168
+ function nonEmpty(values: string[]): [string, ...string[]] | null {
169
+ const [first, ...rest] = values
170
+ return first === undefined ? null : [first, ...rest]
171
+ }
172
+
173
+ function filterSchema(key: string, dim: AnyDimension) {
174
+ const dimension = z.literal(key)
175
+ const description = `${dim.label}${dim.description ? `: ${dim.description}` : ''}`
176
+ const emptiness = z.object({
177
+ dimension,
178
+ op: z.enum(['isEmpty', 'isNotEmpty'])
179
+ })
180
+
181
+ switch (dim.category) {
182
+ case 'closed': {
183
+ const labels: [string, { label: string }][] = Object.entries(
184
+ dim.values
185
+ ).map(([value, label]) => [value, { label }])
186
+ const keys = nonEmpty(labels.map(([value]) => value))
187
+ if (!keys) throw new Error(`Dimension ${key} lists no values`)
188
+
189
+ const values = z.array(z.enum(keys)).min(1).describe(describeList(labels))
190
+ return z
191
+ .discriminatedUnion('op', [
192
+ z.object({ dimension, op: z.enum(['in', 'notIn']), values }),
193
+ emptiness
194
+ ])
195
+ .describe(description)
196
+ }
197
+ case 'open':
198
+ return z
199
+ .discriminatedUnion('op', [
200
+ z.object({
201
+ dimension,
202
+ op: z.enum(['in', 'notIn']),
203
+ values: z
204
+ .array(z.string())
205
+ .min(1)
206
+ .describe('Keys or names; names match case-insensitively.')
207
+ }),
208
+ emptiness
209
+ ])
210
+ .describe(description)
211
+ case 'numeric':
212
+ return z
213
+ .discriminatedUnion('op', [
214
+ z.object({
215
+ dimension,
216
+ op: z.literal('between'),
217
+ from: z.number(),
218
+ to: z.number()
219
+ }),
220
+ emptiness
221
+ ])
222
+ .describe(`${description} (inclusive range)`)
223
+ case 'time':
224
+ return z
225
+ .discriminatedUnion('op', [
226
+ z.object({
227
+ dimension,
228
+ op: z.literal('between'),
229
+ from: isoDate,
230
+ to: isoDate
231
+ }),
232
+ emptiness
233
+ ])
234
+ .describe(`${description} (bucket start dates, inclusive)`)
235
+ }
236
+ }
237
+
238
+ /** The input schema branch for one dataset. */
239
+ export function datasetQuerySchema(dataset: AnyDataset) {
240
+ const measures: [string, AnyMeasure][] = Object.entries(dataset.measures)
241
+ const dimensions: [string, AnyDimension][] = Object.entries(
242
+ dataset.dimensions
243
+ )
244
+ const groupable = dimensions.filter(([, dim]) => dim.groupable)
245
+ const filterable = dimensions.filter(([, dim]) => dim.filterable)
246
+
247
+ const measureKeys = nonEmpty(measures.map(([key]) => key))
248
+ if (!measureKeys) throw new Error(`Dataset ${dataset.key} has no measures`)
249
+ const groupKeys = nonEmpty(groupable.map(([key]) => key))
250
+ const timeKeys = nonEmpty(Object.keys(dataset.time))
251
+ const sortKeys =
252
+ nonEmpty([...measureKeys, ...(groupKeys ?? [])]) ?? measureKeys
253
+
254
+ const notes = groupable
255
+ .filter(([, dim]) => dim.kind === 'manyToMany' && dim.note)
256
+ .map(([key, dim]) => `${key}: ${dim.kind === 'manyToMany' ? dim.note : ''}`)
257
+
258
+ const [firstFilter, ...otherFilters] = filterable.map(([key, dim]) =>
259
+ filterSchema(key, dim)
260
+ )
261
+
262
+ return z
263
+ .object({
264
+ dataset: z
265
+ .literal(dataset.key)
266
+ .describe(`${dataset.label}. ${dataset.description}`),
267
+ measures: z
268
+ .array(z.enum(measureKeys))
269
+ .min(1)
270
+ .max(MAX_MEASURES)
271
+ .describe(describeList(measures)),
272
+ ...(groupKeys && {
273
+ groupBy: z
274
+ .array(z.enum(groupKeys))
275
+ .max(MAX_GROUP_BY)
276
+ .optional()
277
+ .describe([describeList(groupable), ...notes].join('\n'))
278
+ }),
279
+ ...(firstFilter && {
280
+ filters: z
281
+ .array(
282
+ z.discriminatedUnion('dimension', [firstFilter, ...otherFilters])
283
+ )
284
+ .optional()
285
+ .describe('Every filter must match.')
286
+ }),
287
+ // Nullable as well: LLM tool callers send null for a field they leave out.
288
+ period: periodSchema.nullish(),
289
+ ...(timeKeys && {
290
+ time: z
291
+ .enum(timeKeys)
292
+ .optional()
293
+ .describe(
294
+ `Which time field the period applies to; time dimensions name their own. Defaults to ${dataset.defaultTime}. ${describeList(Object.entries(dataset.time))}`
295
+ )
296
+ }),
297
+ compareToPrevious: z
298
+ .boolean()
299
+ .optional()
300
+ .describe('Also return the previous period of the same length.'),
301
+ sort: z
302
+ .object({
303
+ by: z.enum(sortKeys),
304
+ direction: z.enum(['asc', 'desc']).optional()
305
+ })
306
+ .optional()
307
+ .describe(
308
+ 'One of the asked measures or grouped dimensions. Defaults to time order when grouped by time, otherwise the first measure, descending.'
309
+ ),
310
+ limit: z
311
+ .number()
312
+ .int()
313
+ .min(1)
314
+ .max(MAX_LIMIT)
315
+ .optional()
316
+ .describe(`Maximum number of groups. Defaults to ${DEFAULT_LIMIT}.`)
317
+ })
318
+ .superRefine((query, context) => {
319
+ // Sorting by something the query does not return would be silently ignored.
320
+ // groupBy is spread in conditionally above, which hides its type here.
321
+ const { groupBy } = query as { groupBy?: string[] | undefined }
322
+ const by = query.sort?.by
323
+ const returned: string[] = [...query.measures, ...(groupBy ?? [])]
324
+ if (by !== undefined && !returned.includes(by)) {
325
+ context.addIssue({
326
+ code: 'custom',
327
+ path: ['sort', 'by'],
328
+ message: `Sort by one of the asked measures or grouped dimensions: ${returned.join(', ')}`
329
+ })
330
+ }
331
+ })
332
+ }
333
+
334
+ /** A discriminated union on `dataset`, one branch per dataset. */
335
+ export function buildQuerySchema(datasets: readonly AnyDataset[]) {
336
+ const [first, ...rest] = datasets.map(datasetQuerySchema)
337
+ if (!first) throw new Error('querySchema needs at least one dataset')
338
+
339
+ return z.discriminatedUnion('dataset', [first, ...rest])
340
+ }
341
+
342
+ const periodOutput = z.object({
343
+ from: z.string(),
344
+ to: z.string(),
345
+ timezone: z.string()
346
+ })
347
+ const measureValue = z.number().nullable()
348
+ const dimensionValue = z.object({
349
+ key: z.union([z.string(), z.number()]).nullable(),
350
+ label: z.string()
351
+ })
352
+
353
+ /** The envelope every result shares; only the row and totals shapes differ per dataset. */
354
+ function resultSchemaOf<Row extends z.ZodType, Totals extends z.ZodType>(
355
+ row: Row,
356
+ totals: Totals
357
+ ) {
358
+ return z.object({
359
+ period: periodOutput,
360
+ rows: z.array(row),
361
+ totals,
362
+ previous: z
363
+ .object({ period: periodOutput, rows: z.array(row), totals })
364
+ .optional(),
365
+ notes: z.array(z.string()),
366
+ truncated: z.boolean()
367
+ })
368
+ }
369
+
370
+ /** The result schema for one dataset. Row fields exist only for grouped dimensions. */
371
+ export function datasetResultSchema(dataset: AnyDataset) {
372
+ const measures = Object.fromEntries(
373
+ Object.entries(dataset.measures).map(([key, entry]) => [
374
+ key,
375
+ measureValue.optional().describe((entry as AnyMeasure).label)
376
+ ])
377
+ )
378
+ const dimensions = Object.fromEntries(
379
+ Object.entries(dataset.dimensions).map(([key, entry]) => [
380
+ key,
381
+ dimensionValue.optional().describe((entry as AnyDimension).label)
382
+ ])
383
+ )
384
+
385
+ return resultSchemaOf(
386
+ z.object({ ...dimensions, ...measures }),
387
+ z.object(measures)
388
+ )
389
+ }
390
+
391
+ /**
392
+ * Any dataset's result, without the per-dataset keys (`analytics.resultSchema(key)` has
393
+ * those): rows hold `{ key, label }` per grouped dimension and a number or null per measure.
394
+ * For contracts that must not depend on the dataset list, such as an LLM tool's output.
395
+ */
396
+ export const AnalyticsResultSchema = resultSchemaOf(
397
+ z.record(z.string(), z.union([dimensionValue, measureValue])),
398
+ z.record(z.string(), measureValue)
399
+ )
package/src/testing.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The contract test kit: the same checks for every dataset, run against a seeded database.
3
+ * Framework-free: `checkDatasetContract` returns a report and `assertDatasetContract` throws
4
+ * with every failure listed, so it works from `bun:test`, vitest or a script.
5
+ */
6
+ import { type AnalyticsSource, createAnalytics } from './analytics.js'
7
+ import {
8
+ type AnyDataset,
9
+ type AnyDimension,
10
+ dimension,
11
+ measure
12
+ } from './dataset.js'
13
+ import type { PeriodInput } from './period.js'
14
+ import { MAX_MEASURES } from './query.js'
15
+
16
+ export type ContractCheck = {
17
+ name: string
18
+ ok: boolean
19
+ message: string | undefined
20
+ }
21
+ export type ContractReport = { ok: boolean; checks: ContractCheck[] }
22
+
23
+ export type ContractOptions = {
24
+ dataset: AnyDataset
25
+ source: AnalyticsSource
26
+ /** A tenant with seeded rows in the period. */
27
+ tenant: string | number
28
+ /** A tenant with no rows; defaults to a sentinel that no real tenant uses. */
29
+ emptyTenant?: string | number
30
+ /** A timezone far from UTC makes bucketing mistakes visible. Defaults to Pacific/Auckland. */
31
+ timezone?: string
32
+ /** Defaults to the last 365 days. */
33
+ period?: PeriodInput
34
+ now?: () => Date
35
+ }
36
+
37
+ const COUNT = '__contractCount'
38
+ const dayKey = (time: string) => `__contractDay_${time}`
39
+
40
+ type ProbeRow = Record<string, { key: unknown; label: string } | number | null>
41
+ type ProbeResult = {
42
+ rows: ProbeRow[]
43
+ totals: Record<string, number | null>
44
+ truncated: boolean
45
+ }
46
+
47
+ /**
48
+ * Checks that every measure, grouping and filter compiles and runs, that groups add up to the
49
+ * total, that labels resolve, that another tenant's rows never count, and that day buckets
50
+ * agree with periods in the timezone.
51
+ */
52
+ export async function checkDatasetContract(
53
+ options: ContractOptions
54
+ ): Promise<ContractReport> {
55
+ const { dataset } = options
56
+ const timezone = options.timezone ?? 'Pacific/Auckland'
57
+ const period = options.period ?? { last: { days: 365 } }
58
+ const emptyTenant =
59
+ options.emptyTenant ??
60
+ (typeof options.tenant === 'number' ? -1 : '__contract_empty_tenant__')
61
+
62
+ const probe: AnyDataset = {
63
+ ...dataset,
64
+ measures: {
65
+ ...dataset.measures,
66
+ [COUNT]: measure.count({ label: 'Rows' })
67
+ },
68
+ dimensions: {
69
+ ...dataset.dimensions,
70
+ ...Object.fromEntries(
71
+ Object.keys(dataset.time).map((time) => [
72
+ dayKey(time),
73
+ dimension.timeBucket(time, 'day')
74
+ ])
75
+ )
76
+ }
77
+ }
78
+ const analytics = createAnalytics({
79
+ datasets: [probe],
80
+ sources: { main: options.source },
81
+ tenant: (ctx: { tenant: string | number }) => ctx.tenant,
82
+ timezone: () => timezone,
83
+ ...(options.now && { now: options.now })
84
+ })
85
+
86
+ const run = async (
87
+ query: Record<string, unknown>,
88
+ tenant: string | number = options.tenant
89
+ ): Promise<ProbeResult> => {
90
+ const input = { dataset: dataset.key, period, limit: 100, ...query }
91
+ return (await analytics.query(input as never, {
92
+ tenant
93
+ })) as unknown as ProbeResult
94
+ }
95
+
96
+ const checks: ContractCheck[] = []
97
+ const check = async (
98
+ name: string,
99
+ body: () => Promise<string | undefined>
100
+ ) => {
101
+ try {
102
+ const problem = await body()
103
+ checks.push({ name, ok: problem === undefined, message: problem })
104
+ } catch (error) {
105
+ checks.push({
106
+ name,
107
+ ok: false,
108
+ message: error instanceof Error ? error.message : String(error)
109
+ })
110
+ }
111
+ }
112
+
113
+ const total = (await run({ measures: [COUNT] })).totals[COUNT] ?? 0
114
+ if (total === 0) {
115
+ checks.push({
116
+ name: 'seeded rows',
117
+ ok: false,
118
+ message: `Tenant ${options.tenant} has no rows in the period; the checks below prove little`
119
+ })
120
+ }
121
+
122
+ for (const key of Object.keys(dataset.measures)) {
123
+ await check(`measure ${key}`, async () => {
124
+ const value = (await run({ measures: [key] })).totals[key]
125
+ if (value !== null && typeof value !== 'number')
126
+ return `returned ${String(value)}`
127
+ if (typeof value === 'number' && Number.isNaN(value))
128
+ return 'returned NaN'
129
+ return undefined
130
+ })
131
+ }
132
+
133
+ const dimensions = Object.entries<AnyDimension>(dataset.dimensions)
134
+ for (const [key, entry] of dimensions) {
135
+ if (entry.groupable) {
136
+ await check(`group by ${key}`, async () => {
137
+ const result = await run({ measures: [COUNT], groupBy: [key] })
138
+ if (entry.kind === 'manyToMany' || result.truncated) return undefined
139
+
140
+ const sum = result.rows.reduce(
141
+ (acc, row) => acc + Number(row[COUNT] ?? 0),
142
+ 0
143
+ )
144
+ return sum === total
145
+ ? undefined
146
+ : `groups add up to ${sum}, the total is ${total}`
147
+ })
148
+ }
149
+
150
+ if (entry.filterable) {
151
+ await check(`filter ${key} by emptiness`, async () => {
152
+ const empty = await run({
153
+ measures: [COUNT],
154
+ filters: [{ dimension: key, op: 'isEmpty' }]
155
+ })
156
+ const notEmpty = await run({
157
+ measures: [COUNT],
158
+ filters: [{ dimension: key, op: 'isNotEmpty' }]
159
+ })
160
+ const sum = (empty.totals[COUNT] ?? 0) + (notEmpty.totals[COUNT] ?? 0)
161
+ return sum === total
162
+ ? undefined
163
+ : `isEmpty + isNotEmpty is ${sum}, the total is ${total}`
164
+ })
165
+ }
166
+
167
+ if (entry.filterable && entry.groupable && entry.category === 'open') {
168
+ await check(`filter ${key} by label`, async () => {
169
+ const grouped = await run({ measures: [COUNT], groupBy: [key] })
170
+ const first = grouped.rows.find((row) => {
171
+ const value = row[key]
172
+ return (
173
+ typeof value === 'object' && value !== null && value.key !== null
174
+ )
175
+ })
176
+ const value = first?.[key]
177
+ if (!first || typeof value !== 'object' || value === null)
178
+ return undefined
179
+
180
+ const filtered = await run({
181
+ measures: [COUNT],
182
+ filters: [{ dimension: key, op: 'in', values: [value.label] }]
183
+ })
184
+ const expected = Number(first[COUNT])
185
+ const actual = filtered.totals[COUNT]
186
+ return actual === expected
187
+ ? undefined
188
+ : `filtering by the label "${value.label}" counts ${actual}, its group counts ${expected}`
189
+ })
190
+ }
191
+
192
+ if (entry.filterable && entry.category === 'closed') {
193
+ await check(`filter ${key} by value`, async () => {
194
+ const [first] = Object.keys(
195
+ entry.kind === 'enum' || entry.kind === 'computed' ? entry.values : {}
196
+ )
197
+ if (first === undefined) return 'lists no values'
198
+ await run({
199
+ measures: [COUNT],
200
+ filters: [{ dimension: key, op: 'in', values: [first] }]
201
+ })
202
+ return undefined
203
+ })
204
+ }
205
+ }
206
+
207
+ await check('tenant isolation', async () => {
208
+ const measures = Object.keys(probe.measures)
209
+ const leaked: [string, number | null][] = []
210
+ for (let start = 0; start < measures.length; start += MAX_MEASURES) {
211
+ const batch = measures.slice(start, start + MAX_MEASURES)
212
+ const other = await run({ measures: batch }, emptyTenant)
213
+ leaked.push(
214
+ ...Object.entries(other.totals).filter(
215
+ ([, value]) => value !== 0 && value !== null
216
+ )
217
+ )
218
+ }
219
+ if (leaked.length > 0) {
220
+ return `an empty tenant sees ${leaked.map(([key, value]) => `${key}=${value}`).join(', ')}`
221
+ }
222
+
223
+ for (const [key, entry] of dimensions) {
224
+ if (!entry.groupable) continue
225
+ const grouped = await run(
226
+ { measures: [COUNT], groupBy: [key] },
227
+ emptyTenant
228
+ )
229
+ if (grouped.rows.length > 0)
230
+ return `an empty tenant sees ${grouped.rows.length} ${key} groups`
231
+ }
232
+ return undefined
233
+ })
234
+
235
+ for (const time of Object.keys(dataset.time)) {
236
+ await check(`day buckets of ${time} in ${timezone}`, async () => {
237
+ const days = await run({
238
+ measures: [COUNT],
239
+ groupBy: [dayKey(time)],
240
+ time,
241
+ sort: { by: COUNT, direction: 'desc' }
242
+ })
243
+
244
+ for (const row of days.rows.slice(0, 5)) {
245
+ const bucket = row[dayKey(time)]
246
+ if (
247
+ typeof bucket !== 'object' ||
248
+ bucket === null ||
249
+ typeof bucket.key !== 'string'
250
+ )
251
+ continue
252
+
253
+ const day = await run({
254
+ measures: [COUNT],
255
+ time,
256
+ period: { from: bucket.key, to: bucket.key }
257
+ })
258
+ if (day.totals[COUNT] !== row[COUNT]) {
259
+ return `the ${bucket.key} bucket counts ${row[COUNT]}, the period ${bucket.key} counts ${day.totals[COUNT]}`
260
+ }
261
+ }
262
+ return undefined
263
+ })
264
+ }
265
+
266
+ return { ok: checks.every((entry) => entry.ok), checks }
267
+ }
268
+
269
+ /** Runs `checkDatasetContract` and throws with every failed check listed. */
270
+ export async function assertDatasetContract(
271
+ options: ContractOptions
272
+ ): Promise<ContractReport> {
273
+ const report = await checkDatasetContract(options)
274
+ const failed = report.checks.filter((entry) => !entry.ok)
275
+ if (failed.length > 0) {
276
+ const lines = failed.map((entry) => `- ${entry.name}: ${entry.message}`)
277
+ throw new Error(
278
+ `Dataset ${options.dataset.key} breaks the contract:\n${lines.join('\n')}`
279
+ )
280
+ }
281
+
282
+ return report
283
+ }