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/LICENSE +21 -0
- package/README.md +340 -0
- package/dist/analytics.d.ts +133 -0
- package/dist/compile.d.ts +84 -0
- package/dist/dataset.d.ts +303 -0
- package/dist/dialects/postgres.d.ts +16 -0
- package/dist/dialects/postgres.js +51 -0
- package/dist/expr.d.ts +210 -0
- package/dist/guidance.d.ts +5 -0
- package/dist/index-e6k30sfp.js +218 -0
- package/dist/index-m0czpsyv.js +1292 -0
- package/dist/index-nrjdssft.js +200 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +78 -0
- package/dist/period.d.ts +44 -0
- package/dist/plan.d.ts +86 -0
- package/dist/query.d.ts +619 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +191 -0
- package/package.json +84 -0
- package/src/analytics.ts +663 -0
- package/src/compile.ts +332 -0
- package/src/dataset.ts +783 -0
- package/src/dialects/postgres.ts +74 -0
- package/src/expr.ts +539 -0
- package/src/guidance.ts +10 -0
- package/src/index.ts +74 -0
- package/src/period.ts +212 -0
- package/src/plan.ts +588 -0
- package/src/query.ts +399 -0
- package/src/testing.ts +283 -0
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
|
+
}
|