measurr 0.2.0 → 0.4.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/README.md +118 -31
- package/dist/analytics.d.ts +20 -7
- package/dist/dataset.d.ts +61 -33
- package/dist/guidance.d.ts +1 -1
- package/dist/{index-dj3369zz.js → index-4rmjas9h.js} +165 -44
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/period.d.ts +12 -3
- package/dist/plan.d.ts +6 -4
- package/dist/query.d.ts +40 -30
- package/dist/testing.d.ts +9 -4
- package/dist/testing.js +35 -22
- package/package.json +1 -1
- package/src/analytics.ts +230 -46
- package/src/dataset.ts +153 -65
- package/src/guidance.ts +1 -1
- package/src/index.ts +5 -2
- package/src/period.ts +10 -4
- package/src/plan.ts +40 -21
- package/src/query.ts +75 -26
- package/src/testing.ts +59 -33
package/src/plan.ts
CHANGED
|
@@ -6,11 +6,7 @@ import type {
|
|
|
6
6
|
} from './compile.js'
|
|
7
7
|
import type { AnyDataset, AnyDimension, AnyMeasure } from './dataset.js'
|
|
8
8
|
import type { ExprNode, ParamValue } from './expr.js'
|
|
9
|
-
import {
|
|
10
|
-
DEFAULT_PERIOD,
|
|
11
|
-
type PeriodInput,
|
|
12
|
-
type ResolvedPeriod
|
|
13
|
-
} from './period.js'
|
|
9
|
+
import type { PeriodInput, ResolvedPeriod } from './period.js'
|
|
14
10
|
import { DEFAULT_LIMIT } from './query.js'
|
|
15
11
|
|
|
16
12
|
/** A parsed query with every default filled in and duplicates removed. */
|
|
@@ -65,7 +61,7 @@ export function normalizeQuery(
|
|
|
65
61
|
// Order matters: the first grouped dimension leads the rows.
|
|
66
62
|
groupBy: [...new Set(parsed.groupBy ?? [])],
|
|
67
63
|
filters,
|
|
68
|
-
period: parsed.period ??
|
|
64
|
+
period: parsed.period ?? dataset.defaultPeriod,
|
|
69
65
|
time: parsed.time ?? dataset.defaultTime,
|
|
70
66
|
compareToPrevious: parsed.compareToPrevious ?? false,
|
|
71
67
|
sort: parsed.sort
|
|
@@ -81,14 +77,17 @@ export function normalizeQuery(
|
|
|
81
77
|
*/
|
|
82
78
|
export const allTenants: unique symbol = Symbol('measurr.allTenants')
|
|
83
79
|
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
export type TenantId = string | number
|
|
81
|
+
|
|
82
|
+
/** One tenant's id, a list of them (an empty list matches no rows), or `allTenants`. */
|
|
83
|
+
export type Tenant = TenantId | readonly TenantId[] | typeof allTenants
|
|
86
84
|
|
|
87
85
|
/** What the planner needs besides the query: values from the hooks and resolved labels. */
|
|
88
86
|
export type PlanContext = {
|
|
89
87
|
tenant: Tenant
|
|
90
88
|
timezone: string
|
|
91
|
-
period:
|
|
89
|
+
/** Null for an all-time period: no time filter. */
|
|
90
|
+
period: ResolvedPeriod | null
|
|
92
91
|
/** The moment the query runs. */
|
|
93
92
|
now: Date
|
|
94
93
|
/** Keys for filters on open dimensions, after label resolution, by dimension. */
|
|
@@ -215,17 +214,33 @@ function baseWhere(
|
|
|
215
214
|
tenant: Tenant,
|
|
216
215
|
period: { time: string; start: Date; end: Date } | null
|
|
217
216
|
): ExprNode[] {
|
|
218
|
-
const where: ExprNode[] =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
217
|
+
const where: ExprNode[] = []
|
|
218
|
+
if (tenant !== allTenants) {
|
|
219
|
+
// `analytics.query` refuses this first; the planner fails closed on its own too.
|
|
220
|
+
if (!dataset.tenantColumn) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Dataset ${dataset.key} has no tenant column; only allTenants may query it`
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
const ids = typeof tenant === 'object' ? [...new Set(tenant)] : [tenant]
|
|
226
|
+
const [only] = ids
|
|
227
|
+
// One id compiles exactly like the scalar; an empty list renders as a false condition.
|
|
228
|
+
where.push(
|
|
229
|
+
ids.length === 1 && only !== undefined
|
|
230
|
+
? {
|
|
223
231
|
kind: 'compare',
|
|
224
232
|
op: '=',
|
|
225
233
|
left: dataset.tenantColumn,
|
|
226
|
-
right: param(
|
|
234
|
+
right: param(only)
|
|
227
235
|
}
|
|
228
|
-
|
|
236
|
+
: {
|
|
237
|
+
kind: 'in',
|
|
238
|
+
item: dataset.tenantColumn,
|
|
239
|
+
values: ids.map(param),
|
|
240
|
+
negated: false
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
}
|
|
229
244
|
if (dataset.scope) where.push(dataset.scope)
|
|
230
245
|
|
|
231
246
|
if (period) {
|
|
@@ -357,11 +372,15 @@ export function planQuery(
|
|
|
357
372
|
}
|
|
358
373
|
|
|
359
374
|
const where = [
|
|
360
|
-
...baseWhere(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
375
|
+
...baseWhere(
|
|
376
|
+
dataset,
|
|
377
|
+
context.tenant,
|
|
378
|
+
context.period && {
|
|
379
|
+
time: query.time,
|
|
380
|
+
start: context.period.start,
|
|
381
|
+
end: context.period.end
|
|
382
|
+
}
|
|
383
|
+
),
|
|
365
384
|
...query.filters.map((filter) => filterNode(dataset, filter, context))
|
|
366
385
|
]
|
|
367
386
|
|
package/src/query.ts
CHANGED
|
@@ -109,10 +109,31 @@ export type ResultTotals<M, MeasureKey extends string> = Simplify<{
|
|
|
109
109
|
[Key in MeasureKey & keyof M]: MeasureValue<M[Key]>
|
|
110
110
|
}>
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
/** The days a result covers: local days `from` to `to` (inclusive), or all time. */
|
|
113
|
+
export type ResultPeriod =
|
|
114
|
+
| { from: string; to: string; timezone: string }
|
|
115
|
+
| { all: true; timezone: string }
|
|
116
|
+
|
|
117
|
+
/** The period query `Q` names; null or none means the dataset's `Default`. */
|
|
118
|
+
type QueryPeriod<Q, Default> = Q extends unknown
|
|
119
|
+
? 'period' extends keyof Q
|
|
120
|
+
? OrDefault<Q['period' & keyof Q], Default>
|
|
121
|
+
: Default
|
|
122
|
+
: never
|
|
123
|
+
type OrDefault<P, Default> = P extends null | undefined ? Default : P
|
|
124
|
+
type PeriodShape<P> = P extends { all: true }
|
|
125
|
+
? Extract<ResultPeriod, { all: true }>
|
|
126
|
+
: Exclude<ResultPeriod, { all: true }>
|
|
127
|
+
|
|
128
|
+
export type QueryResult<
|
|
129
|
+
Row,
|
|
130
|
+
Totals,
|
|
131
|
+
Period extends ResultPeriod = ResultPeriod
|
|
132
|
+
> = {
|
|
133
|
+
period: Period
|
|
114
134
|
rows: Row[]
|
|
115
135
|
totals: Totals
|
|
136
|
+
/** Only for a range: an all-time period has no previous period. */
|
|
116
137
|
previous?: {
|
|
117
138
|
period: { from: string; to: string; timezone: string }
|
|
118
139
|
rows: Row[]
|
|
@@ -124,9 +145,13 @@ export type QueryResult<Row, Totals> = {
|
|
|
124
145
|
|
|
125
146
|
/** The typed result of query `Q` against the datasets `DS`. */
|
|
126
147
|
export type ResultOf<DS, Q> =
|
|
127
|
-
DS extends Dataset<infer K, infer M, infer Dm, string>
|
|
148
|
+
DS extends Dataset<infer K, infer M, infer Dm, string, infer P>
|
|
128
149
|
? Q extends { dataset: K; measures: readonly (infer Mk extends string)[] }
|
|
129
|
-
? QueryResult<
|
|
150
|
+
? QueryResult<
|
|
151
|
+
ResultRow<M, Dm, Mk, GroupKeys<Q>>,
|
|
152
|
+
ResultTotals<M, Mk>,
|
|
153
|
+
PeriodShape<QueryPeriod<Q, P>>
|
|
154
|
+
>
|
|
130
155
|
: never
|
|
131
156
|
: never
|
|
132
157
|
|
|
@@ -135,24 +160,29 @@ export type ResultOf<DS, Q> =
|
|
|
135
160
|
|
|
136
161
|
const isoDate = z.iso.date().describe('A date, YYYY-MM-DD.')
|
|
137
162
|
|
|
138
|
-
|
|
139
|
-
.
|
|
140
|
-
z.object({
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
const periodSchema = z.union([
|
|
164
|
+
z.object({
|
|
165
|
+
last: z.object({
|
|
166
|
+
days: z
|
|
167
|
+
.number()
|
|
168
|
+
.int()
|
|
169
|
+
.min(1)
|
|
170
|
+
.max(MAX_PERIOD_DAYS)
|
|
171
|
+
.describe('Number of days, ending today.')
|
|
172
|
+
})
|
|
173
|
+
}),
|
|
174
|
+
z.object({ from: isoDate, to: isoDate.describe('Last day, inclusive.') }),
|
|
175
|
+
z.object({ preset: z.enum(PERIOD_PRESETS) }),
|
|
176
|
+
z.object({ all: z.literal(true) }).describe('Every row, with no time filter.')
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
/** How the schema names a dataset's default period, so a model knows what omitting it means. */
|
|
180
|
+
function describePeriod(period: PeriodInput): string {
|
|
181
|
+
if ('all' in period) return 'all time (no time filter)'
|
|
182
|
+
if ('last' in period) return `the last ${period.last.days} days`
|
|
183
|
+
if ('from' in period) return `${period.from} to ${period.to}`
|
|
184
|
+
return period.preset
|
|
185
|
+
}
|
|
156
186
|
|
|
157
187
|
function describeList(
|
|
158
188
|
entries: [string, { label: string; description?: string | undefined }][]
|
|
@@ -285,7 +315,11 @@ export function datasetQuerySchema(dataset: AnyDataset) {
|
|
|
285
315
|
.describe('Every filter must match.')
|
|
286
316
|
}),
|
|
287
317
|
// Nullable as well: LLM tool callers send null for a field they leave out.
|
|
288
|
-
period: periodSchema
|
|
318
|
+
period: periodSchema
|
|
319
|
+
.describe(
|
|
320
|
+
`Which days to count, in the organization timezone. Defaults to ${describePeriod(dataset.defaultPeriod)}. A range covers at most two years.`
|
|
321
|
+
)
|
|
322
|
+
.nullish(),
|
|
289
323
|
...(timeKeys && {
|
|
290
324
|
time: z
|
|
291
325
|
.enum(timeKeys)
|
|
@@ -297,7 +331,9 @@ export function datasetQuerySchema(dataset: AnyDataset) {
|
|
|
297
331
|
compareToPrevious: z
|
|
298
332
|
.boolean()
|
|
299
333
|
.optional()
|
|
300
|
-
.describe(
|
|
334
|
+
.describe(
|
|
335
|
+
'Also return the previous period of the same length. Not for all-time periods.'
|
|
336
|
+
),
|
|
301
337
|
sort: z
|
|
302
338
|
.object({
|
|
303
339
|
by: z.enum(sortKeys),
|
|
@@ -328,6 +364,15 @@ export function datasetQuerySchema(dataset: AnyDataset) {
|
|
|
328
364
|
message: `Sort by one of the asked measures or grouped dimensions: ${returned.join(', ')}`
|
|
329
365
|
})
|
|
330
366
|
}
|
|
367
|
+
|
|
368
|
+
const period = query.period ?? dataset.defaultPeriod
|
|
369
|
+
if (query.compareToPrevious && 'all' in period) {
|
|
370
|
+
context.addIssue({
|
|
371
|
+
code: 'custom',
|
|
372
|
+
path: ['compareToPrevious'],
|
|
373
|
+
message: 'An all-time period has no previous period to compare with'
|
|
374
|
+
})
|
|
375
|
+
}
|
|
331
376
|
})
|
|
332
377
|
}
|
|
333
378
|
|
|
@@ -339,11 +384,15 @@ export function buildQuerySchema(datasets: readonly AnyDataset[]) {
|
|
|
339
384
|
return z.discriminatedUnion('dataset', [first, ...rest])
|
|
340
385
|
}
|
|
341
386
|
|
|
342
|
-
const
|
|
387
|
+
const rangeOutput = z.object({
|
|
343
388
|
from: z.string(),
|
|
344
389
|
to: z.string(),
|
|
345
390
|
timezone: z.string()
|
|
346
391
|
})
|
|
392
|
+
const periodOutput = z.union([
|
|
393
|
+
rangeOutput,
|
|
394
|
+
z.object({ all: z.literal(true), timezone: z.string() })
|
|
395
|
+
])
|
|
347
396
|
const measureValue = z.number().nullable()
|
|
348
397
|
const dimensionValue = z.object({
|
|
349
398
|
key: z.union([z.string(), z.number()]).nullable(),
|
|
@@ -360,7 +409,7 @@ function resultSchemaOf<Row extends z.ZodType, Totals extends z.ZodType>(
|
|
|
360
409
|
rows: z.array(row),
|
|
361
410
|
totals,
|
|
362
411
|
previous: z
|
|
363
|
-
.object({ period:
|
|
412
|
+
.object({ period: rangeOutput, rows: z.array(row), totals })
|
|
364
413
|
.optional(),
|
|
365
414
|
notes: z.array(z.string()),
|
|
366
415
|
truncated: z.boolean()
|
package/src/testing.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* Framework-free: `checkDatasetContract` returns a report and `assertDatasetContract` throws
|
|
4
4
|
* with every failure listed, so it works from `bun:test`, vitest or a script.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type AnalyticsSource,
|
|
8
|
+
createAnalytics,
|
|
9
|
+
isAnalyticsError
|
|
10
|
+
} from './analytics.js'
|
|
7
11
|
import {
|
|
8
12
|
type AnyDataset,
|
|
9
13
|
type AnyDimension,
|
|
@@ -11,6 +15,7 @@ import {
|
|
|
11
15
|
measure
|
|
12
16
|
} from './dataset.js'
|
|
13
17
|
import type { PeriodInput } from './period.js'
|
|
18
|
+
import { type Tenant, type TenantId, allTenants } from './plan.js'
|
|
14
19
|
import { MAX_MEASURES } from './query.js'
|
|
15
20
|
|
|
16
21
|
export type ContractCheck = {
|
|
@@ -23,8 +28,11 @@ export type ContractReport = { ok: boolean; checks: ContractCheck[] }
|
|
|
23
28
|
export type ContractOptions = {
|
|
24
29
|
dataset: AnyDataset
|
|
25
30
|
source: AnalyticsSource
|
|
26
|
-
/**
|
|
27
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A tenant with seeded rows in the period. `allTenants` for a dataset without a tenant
|
|
33
|
+
* column, which no single tenant may query.
|
|
34
|
+
*/
|
|
35
|
+
tenant: TenantId | typeof allTenants
|
|
28
36
|
/** A tenant with no rows; defaults to a sentinel that no real tenant uses. */
|
|
29
37
|
emptyTenant?: string | number
|
|
30
38
|
/** A timezone far from UTC makes bucketing mistakes visible. Defaults to Pacific/Auckland. */
|
|
@@ -46,8 +54,9 @@ type ProbeResult = {
|
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* 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,
|
|
50
|
-
*
|
|
57
|
+
* total, that labels resolve, that another tenant's rows never count (or, for a dataset
|
|
58
|
+
* without a tenant column, that a scoped caller is refused), and that day buckets agree with
|
|
59
|
+
* periods in the timezone. Fields marked with `access` are checked like any other.
|
|
51
60
|
*/
|
|
52
61
|
export async function checkDatasetContract(
|
|
53
62
|
options: ContractOptions
|
|
@@ -78,14 +87,15 @@ export async function checkDatasetContract(
|
|
|
78
87
|
const analytics = createAnalytics({
|
|
79
88
|
datasets: [probe],
|
|
80
89
|
sources: { main: options.source },
|
|
81
|
-
tenant: (ctx: { tenant:
|
|
90
|
+
tenant: (ctx: { tenant: Tenant }) => ctx.tenant,
|
|
91
|
+
authorizeField: () => true,
|
|
82
92
|
timezone: () => timezone,
|
|
83
93
|
...(options.now && { now: options.now })
|
|
84
94
|
})
|
|
85
95
|
|
|
86
96
|
const run = async (
|
|
87
97
|
query: Record<string, unknown>,
|
|
88
|
-
tenant:
|
|
98
|
+
tenant: Tenant = options.tenant
|
|
89
99
|
): Promise<ProbeResult> => {
|
|
90
100
|
const input = { dataset: dataset.key, period, limit: 100, ...query }
|
|
91
101
|
return (await analytics.query(input as never, {
|
|
@@ -112,10 +122,12 @@ export async function checkDatasetContract(
|
|
|
112
122
|
|
|
113
123
|
const total = (await run({ measures: [COUNT] })).totals[COUNT] ?? 0
|
|
114
124
|
if (total === 0) {
|
|
125
|
+
const scope =
|
|
126
|
+
options.tenant === allTenants ? 'The dataset' : `Tenant ${options.tenant}`
|
|
115
127
|
checks.push({
|
|
116
128
|
name: 'seeded rows',
|
|
117
129
|
ok: false,
|
|
118
|
-
message:
|
|
130
|
+
message: `${scope} has no rows in the period; the checks below prove little`
|
|
119
131
|
})
|
|
120
132
|
}
|
|
121
133
|
|
|
@@ -204,33 +216,47 @@ export async function checkDatasetContract(
|
|
|
204
216
|
}
|
|
205
217
|
}
|
|
206
218
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// An empty tenant list is a scope that matches nothing, never a missing filter.
|
|
220
|
+
for (const [name, tenant] of [
|
|
221
|
+
['an empty tenant', emptyTenant],
|
|
222
|
+
['an empty tenant list', []]
|
|
223
|
+
] as const) {
|
|
224
|
+
await check(`tenant isolation: ${name}`, async () => {
|
|
225
|
+
// Rows without a tenant cannot be isolated; a scoped caller must be refused instead.
|
|
226
|
+
if (!dataset.tenantColumn) {
|
|
227
|
+
const refused = await run({ measures: [COUNT] }, tenant).then(
|
|
228
|
+
() => false,
|
|
229
|
+
(error: unknown) => isAnalyticsError(error, 'forbidden')
|
|
216
230
|
)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
231
|
+
return refused
|
|
232
|
+
? undefined
|
|
233
|
+
: `${name} may query a dataset without a tenant column`
|
|
234
|
+
}
|
|
222
235
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
const measures = Object.keys(probe.measures)
|
|
237
|
+
const leaked: [string, number | null][] = []
|
|
238
|
+
for (let start = 0; start < measures.length; start += MAX_MEASURES) {
|
|
239
|
+
const batch = measures.slice(start, start + MAX_MEASURES)
|
|
240
|
+
const result = await run({ measures: batch }, tenant)
|
|
241
|
+
leaked.push(
|
|
242
|
+
...Object.entries(result.totals).filter(
|
|
243
|
+
([, value]) => value !== 0 && value !== null
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
if (leaked.length > 0) {
|
|
248
|
+
return `${name} sees ${leaked.map(([key, value]) => `${key}=${value}`).join(', ')}`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const [key, entry] of dimensions) {
|
|
252
|
+
if (!entry.groupable) continue
|
|
253
|
+
const grouped = await run({ measures: [COUNT], groupBy: [key] }, tenant)
|
|
254
|
+
if (grouped.rows.length > 0)
|
|
255
|
+
return `${name} sees ${grouped.rows.length} ${key} groups`
|
|
256
|
+
}
|
|
257
|
+
return undefined
|
|
258
|
+
})
|
|
259
|
+
}
|
|
234
260
|
|
|
235
261
|
for (const time of Object.keys(dataset.time)) {
|
|
236
262
|
await check(`day buckets of ${time} in ${timezone}`, async () => {
|