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/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 ?? DEFAULT_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
- /** One tenant's id, or `allTenants`. */
85
- export type Tenant = string | number | typeof allTenants
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: ResolvedPeriod
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
- tenant === allTenants
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(tenant)
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(dataset, context.tenant, {
361
- time: query.time,
362
- start: context.period.start,
363
- end: context.period.end
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
- export type QueryResult<Row, Totals> = {
113
- period: { from: string; to: string; timezone: string }
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<ResultRow<M, Dm, Mk, GroupKeys<Q>>, ResultTotals<M, Mk>>
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
- 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
- )
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.nullish(),
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('Also return the previous period of the same length.'),
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 periodOutput = z.object({
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: periodOutput, rows: z.array(row), totals })
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 { type AnalyticsSource, createAnalytics } from './analytics.js'
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
- /** A tenant with seeded rows in the period. */
27
- tenant: string | number
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, and that day buckets
50
- * agree with periods in the timezone.
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: string | number }) => 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: string | number = options.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: `Tenant ${options.tenant} has no rows in the period; the checks below prove little`
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
- 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
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
- if (leaked.length > 0) {
220
- return `an empty tenant sees ${leaked.map(([key, value]) => `${key}=${value}`).join(', ')}`
221
- }
231
+ return refused
232
+ ? undefined
233
+ : `${name} may query a dataset without a tenant column`
234
+ }
222
235
 
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
- })
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 () => {