measurr 0.1.0 → 0.3.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/dist/query.d.ts CHANGED
@@ -80,14 +80,30 @@ export type ResultRow<M, Dm, MeasureKey extends string, GroupKey extends string>
80
80
  export type ResultTotals<M, MeasureKey extends string> = Simplify<{
81
81
  [Key in MeasureKey & keyof M]: MeasureValue<M[Key]>;
82
82
  }>;
83
- export type QueryResult<Row, Totals> = {
84
- period: {
85
- from: string;
86
- to: string;
87
- timezone: string;
88
- };
83
+ /** The days a result covers: local days `from` to `to` (inclusive), or all time. */
84
+ export type ResultPeriod = {
85
+ from: string;
86
+ to: string;
87
+ timezone: string;
88
+ } | {
89
+ all: true;
90
+ timezone: string;
91
+ };
92
+ /** The period query `Q` names; null or none means the dataset's `Default`. */
93
+ type QueryPeriod<Q, Default> = Q extends unknown ? 'period' extends keyof Q ? OrDefault<Q['period' & keyof Q], Default> : Default : never;
94
+ type OrDefault<P, Default> = P extends null | undefined ? Default : P;
95
+ type PeriodShape<P> = P extends {
96
+ all: true;
97
+ } ? Extract<ResultPeriod, {
98
+ all: true;
99
+ }> : Exclude<ResultPeriod, {
100
+ all: true;
101
+ }>;
102
+ export type QueryResult<Row, Totals, Period extends ResultPeriod = ResultPeriod> = {
103
+ period: Period;
89
104
  rows: Row[];
90
105
  totals: Totals;
106
+ /** Only for a range: an all-time period has no previous period. */
91
107
  previous?: {
92
108
  period: {
93
109
  from: string;
@@ -101,28 +117,10 @@ export type QueryResult<Row, Totals> = {
101
117
  truncated: boolean;
102
118
  };
103
119
  /** The typed result of query `Q` against the datasets `DS`. */
104
- export type ResultOf<DS, Q> = DS extends Dataset<infer K, infer M, infer Dm, string> ? Q extends {
120
+ export type ResultOf<DS, Q> = DS extends Dataset<infer K, infer M, infer Dm, string, infer P> ? Q extends {
105
121
  dataset: K;
106
122
  measures: readonly (infer Mk extends string)[];
107
- } ? QueryResult<ResultRow<M, Dm, Mk, GroupKeys<Q>>, ResultTotals<M, Mk>> : never : never;
108
- export declare const periodSchema: z.ZodUnion<readonly [z.ZodObject<{
109
- last: z.ZodObject<{
110
- days: z.ZodNumber;
111
- }, z.core.$strip>;
112
- }, z.core.$strip>, z.ZodObject<{
113
- from: z.ZodISODate;
114
- to: z.ZodISODate;
115
- }, z.core.$strip>, z.ZodObject<{
116
- preset: z.ZodEnum<{
117
- lastMonth: "lastMonth";
118
- lastWeek: "lastWeek";
119
- thisMonth: "thisMonth";
120
- thisWeek: "thisWeek";
121
- thisYear: "thisYear";
122
- today: "today";
123
- yesterday: "yesterday";
124
- }>;
125
- }, z.core.$strip>]>;
123
+ } ? QueryResult<ResultRow<M, Dm, Mk, GroupKeys<Q>>, ResultTotals<M, Mk>, PeriodShape<QueryPeriod<Q, P>>> : never : never;
126
124
  /** The input schema branch for one dataset. */
127
125
  export declare function datasetQuerySchema(dataset: AnyDataset): z.ZodObject<{
128
126
  dataset: z.ZodLiteral<string>;
@@ -250,6 +248,8 @@ export declare function datasetQuerySchema(dataset: AnyDataset): z.ZodObject<{
250
248
  today: "today";
251
249
  yesterday: "yesterday";
252
250
  }>;
251
+ }, z.core.$strip>, z.ZodObject<{
252
+ all: z.ZodLiteral<true>;
253
253
  }, z.core.$strip>]>>>;
254
254
  time?: z.ZodOptional<z.ZodEnum<{
255
255
  [x: string]: string;
@@ -393,6 +393,8 @@ export declare function buildQuerySchema(datasets: readonly AnyDataset[]): z.Zod
393
393
  today: "today";
394
394
  yesterday: "yesterday";
395
395
  }>;
396
+ }, z.core.$strip>, z.ZodObject<{
397
+ all: z.ZodLiteral<true>;
396
398
  }, z.core.$strip>]>>>;
397
399
  time?: z.ZodOptional<z.ZodEnum<{
398
400
  [x: string]: string;
@@ -534,6 +536,8 @@ export declare function buildQuerySchema(datasets: readonly AnyDataset[]): z.Zod
534
536
  today: "today";
535
537
  yesterday: "yesterday";
536
538
  }>;
539
+ }, z.core.$strip>, z.ZodObject<{
540
+ all: z.ZodLiteral<true>;
537
541
  }, z.core.$strip>]>>>;
538
542
  time?: z.ZodOptional<z.ZodEnum<{
539
543
  [x: string]: string;
@@ -552,11 +556,14 @@ export declare function buildQuerySchema(datasets: readonly AnyDataset[]): z.Zod
552
556
  }, z.core.$strip>[]], "dataset">;
553
557
  /** The result schema for one dataset. Row fields exist only for grouped dimensions. */
554
558
  export declare function datasetResultSchema(dataset: AnyDataset): z.ZodObject<{
555
- period: z.ZodObject<{
559
+ period: z.ZodUnion<readonly [z.ZodObject<{
556
560
  from: z.ZodString;
557
561
  to: z.ZodString;
558
562
  timezone: z.ZodString;
559
- }, z.core.$strip>;
563
+ }, z.core.$strip>, z.ZodObject<{
564
+ all: z.ZodLiteral<true>;
565
+ timezone: z.ZodString;
566
+ }, z.core.$strip>]>;
560
567
  rows: z.ZodArray<z.ZodObject<{
561
568
  [x: string]: z.ZodOptional<z.ZodNullable<z.ZodNumber>> | z.ZodOptional<z.ZodObject<{
562
569
  key: z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
@@ -591,11 +598,14 @@ export declare function datasetResultSchema(dataset: AnyDataset): z.ZodObject<{
591
598
  * For contracts that must not depend on the dataset list, such as an LLM tool's output.
592
599
  */
593
600
  export declare const AnalyticsResultSchema: z.ZodObject<{
594
- period: z.ZodObject<{
601
+ period: z.ZodUnion<readonly [z.ZodObject<{
595
602
  from: z.ZodString;
596
603
  to: z.ZodString;
597
604
  timezone: z.ZodString;
598
- }, z.core.$strip>;
605
+ }, z.core.$strip>, z.ZodObject<{
606
+ all: z.ZodLiteral<true>;
607
+ timezone: z.ZodString;
608
+ }, z.core.$strip>]>;
599
609
  rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
600
610
  key: z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
601
611
  label: z.ZodString;
package/dist/testing.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  createAnalytics2,
4
4
  measure2,
5
5
  dimension2
6
- } from "./index-m0czpsyv.js";
6
+ } from "./index-wrr67sxq.js";
7
7
 
8
8
  // src/testing.ts
9
9
  var COUNT = "__contractCount";
@@ -128,26 +128,31 @@ async function checkDatasetContract(options) {
128
128
  });
129
129
  }
130
130
  }
131
- await check("tenant isolation", async () => {
132
- const measures = Object.keys(probe.measures);
133
- const leaked = [];
134
- for (let start = 0;start < measures.length; start += MAX_MEASURES) {
135
- const batch = measures.slice(start, start + MAX_MEASURES);
136
- const other = await run({ measures: batch }, emptyTenant);
137
- leaked.push(...Object.entries(other.totals).filter(([, value]) => value !== 0 && value !== null));
138
- }
139
- if (leaked.length > 0) {
140
- return `an empty tenant sees ${leaked.map(([key, value]) => `${key}=${value}`).join(", ")}`;
141
- }
142
- for (const [key, entry] of dimensions) {
143
- if (!entry.groupable)
144
- continue;
145
- const grouped = await run({ measures: [COUNT], groupBy: [key] }, emptyTenant);
146
- if (grouped.rows.length > 0)
147
- return `an empty tenant sees ${grouped.rows.length} ${key} groups`;
148
- }
149
- return;
150
- });
131
+ for (const [name, tenant] of [
132
+ ["an empty tenant", emptyTenant],
133
+ ["an empty tenant list", []]
134
+ ]) {
135
+ await check(`tenant isolation: ${name}`, async () => {
136
+ const measures = Object.keys(probe.measures);
137
+ const leaked = [];
138
+ for (let start = 0;start < measures.length; start += MAX_MEASURES) {
139
+ const batch = measures.slice(start, start + MAX_MEASURES);
140
+ const result = await run({ measures: batch }, tenant);
141
+ leaked.push(...Object.entries(result.totals).filter(([, value]) => value !== 0 && value !== null));
142
+ }
143
+ if (leaked.length > 0) {
144
+ return `${name} sees ${leaked.map(([key, value]) => `${key}=${value}`).join(", ")}`;
145
+ }
146
+ for (const [key, entry] of dimensions) {
147
+ if (!entry.groupable)
148
+ continue;
149
+ const grouped = await run({ measures: [COUNT], groupBy: [key] }, tenant);
150
+ if (grouped.rows.length > 0)
151
+ return `${name} sees ${grouped.rows.length} ${key} groups`;
152
+ }
153
+ return;
154
+ });
155
+ }
151
156
  for (const time of Object.keys(dataset.time)) {
152
157
  await check(`day buckets of ${time} in ${timezone}`, async () => {
153
158
  const days = await run({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "measurr",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A small typed semantic layer: datasets of measures and dimensions compiled to one SQL statement per dialect.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
package/src/analytics.ts CHANGED
@@ -5,6 +5,9 @@ import {
5
5
  type NormalizedQuery,
6
6
  type ParsedQuery,
7
7
  type PlanContext,
8
+ type Tenant,
9
+ type TenantId,
10
+ allTenants,
8
11
  normalizeQuery,
9
12
  planLookup,
10
13
  planQuery,
@@ -112,8 +115,13 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
112
115
  datasets: DS
113
116
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
114
117
  sources: Record<string, AnalyticsSource>
115
- /** The tenant every query is scoped to. Queries without one are refused. */
116
- tenant: (ctx: Ctx) => string | number | null | undefined
118
+ /**
119
+ * The tenant every query is scoped to, or a list of them (an empty list matches no rows).
120
+ * Queries without one (null, undefined, '', a list holding one of those) are refused.
121
+ * Return `allTenants` to drop the tenant filter, only for callers allowed to see every
122
+ * tenant's rows.
123
+ */
124
+ tenant: (ctx: Ctx) => Tenant | null | undefined
117
125
  /**
118
126
  * Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
119
127
  * `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
@@ -195,6 +203,18 @@ export type AnalyticsResult<A, Q> =
195
203
 
196
204
  type ShapedResult = QueryResult<Row, Row>
197
205
 
206
+ /** A parsed, authorized query with everything the hooks decide resolved. */
207
+ type Prepared = {
208
+ dataset: AnyDataset
209
+ query: NormalizedQuery
210
+ tenant: Tenant
211
+ timezone: string
212
+ /** Null for all time. */
213
+ period: ResolvedPeriod | null
214
+ previous: ResolvedPeriod | null
215
+ now: Date
216
+ }
217
+
198
218
  export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
199
219
  options: AnalyticsOptions<DS, Ctx>
200
220
  ): Analytics<DS, Ctx> {
@@ -273,7 +293,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
273
293
  }
274
294
 
275
295
  /** Parse, authorize, and resolve everything that comes from hooks. */
276
- const prepare = async (input: unknown, ctx: Ctx) => {
296
+ const prepare = async (input: unknown, ctx: Ctx): Promise<Prepared> => {
277
297
  const parsed = querySchemaFor(undefined).safeParse(input)
278
298
  if (!parsed.success) {
279
299
  throw new AnalyticsError(
@@ -291,8 +311,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
291
311
  )
292
312
  }
293
313
 
294
- const tenant = options.tenant(ctx)
295
- if (tenant === null || tenant === undefined || tenant === '') {
314
+ const tenant: unknown = options.tenant(ctx)
315
+ if (!isTenant(tenant)) {
296
316
  throw new AnalyticsError(
297
317
  'missing_tenant',
298
318
  'Refusing to query without a tenant'
@@ -309,16 +329,19 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
309
329
 
310
330
  const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
311
331
  const at = now()
312
- let period: ResolvedPeriod
332
+ let period: ResolvedPeriod | null
313
333
  try {
314
- period = resolvePeriod(query.period, timezone, at)
334
+ period =
335
+ 'all' in query.period ? null : resolvePeriod(query.period, timezone, at)
315
336
  } catch (error) {
316
337
  if (error instanceof PeriodError) {
317
338
  throw new AnalyticsError('invalid_period', error.message)
318
339
  }
319
340
  throw error
320
341
  }
321
- const previous = query.compareToPrevious ? previousPeriod(period) : null
342
+ // The schema refuses compareToPrevious with an all-time period.
343
+ const previous =
344
+ query.compareToPrevious && period ? previousPeriod(period) : null
322
345
 
323
346
  return { dataset, query, tenant, timezone, period, previous, now: at }
324
347
  }
@@ -340,7 +363,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
340
363
  dialect
341
364
  )
342
365
 
343
- const runPeriod = async (target: ResolvedPeriod) => {
366
+ const runPeriod = async (target: ResolvedPeriod | null) => {
344
367
  const statements = periodStatements(dataset, query, {
345
368
  tenant,
346
369
  timezone,
@@ -356,27 +379,26 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
356
379
  const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
357
380
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
358
381
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
359
- const periodOut = { from: target.from, to: target.to, timezone }
360
382
 
361
- return { period: periodOut, rows, totals, truncated }
383
+ return { rows, totals, truncated }
362
384
  }
363
385
 
386
+ const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
364
387
  const [current, before] = await Promise.all([
365
388
  runPeriod(period),
366
- previous ? runPeriod(previous) : null
389
+ previous &&
390
+ runPeriod(previous).then(({ rows, totals }) => ({
391
+ period: range(previous),
392
+ rows,
393
+ totals
394
+ }))
367
395
  ])
368
396
 
369
397
  return {
370
- period: current.period,
398
+ period: period ? range(period) : { all: true, timezone },
371
399
  rows: current.rows,
372
400
  totals: current.totals,
373
- ...(before && {
374
- previous: {
375
- period: before.period,
376
- rows: before.rows,
377
- totals: before.totals
378
- }
379
- }),
401
+ ...(before && { previous: before }),
380
402
  notes: resultNotes(dataset, query),
381
403
  truncated: current.truncated
382
404
  }
@@ -469,6 +491,26 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
469
491
  }
470
492
  }
471
493
 
494
+ /**
495
+ * Fails closed: only a non-empty string, a finite number, a list of those or `allTenants`
496
+ * scopes a query, even when a JavaScript caller's hook returns something its type does not
497
+ * allow.
498
+ */
499
+ function isTenant(value: unknown): value is Tenant {
500
+ return (
501
+ value === allTenants ||
502
+ isTenantId(value) ||
503
+ (Array.isArray(value) && value.every(isTenantId))
504
+ )
505
+ }
506
+
507
+ function isTenantId(value: unknown): value is TenantId {
508
+ return (
509
+ (typeof value === 'string' && value !== '') ||
510
+ (typeof value === 'number' && Number.isFinite(value))
511
+ )
512
+ }
513
+
472
514
  /** The statement for a period's rows, and for its totals when the query groups. */
473
515
  function periodStatements(
474
516
  dataset: AnyDataset,
@@ -562,7 +604,7 @@ function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
562
604
  async function resolveOpenValues(
563
605
  dataset: AnyDataset,
564
606
  query: NormalizedQuery,
565
- tenant: string | number,
607
+ tenant: Tenant,
566
608
  now: Date,
567
609
  run: (statement: SelectStatement) => Promise<readonly Row[]>,
568
610
  dialect: Dialect
package/src/dataset.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  directChildren,
10
10
  rootOf
11
11
  } from './expr.js'
12
+ import { DEFAULT_PERIOD, type PeriodInput } from './period.js'
12
13
 
13
14
  declare const ratioBrand: unique symbol
14
15
 
@@ -532,7 +533,8 @@ export type Dataset<
532
533
  K extends string = string,
533
534
  M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>,
534
535
  Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>,
535
- T extends string = string
536
+ T extends string = string,
537
+ P extends PeriodInput = PeriodInput
536
538
  > = {
537
539
  readonly key: K
538
540
  readonly label: string
@@ -543,6 +545,8 @@ export type Dataset<
543
545
  readonly scope: ExprNode | null
544
546
  readonly time: Readonly<Record<T, TimeField>>
545
547
  readonly defaultTime: T
548
+ /** Kept as a type, so results know whether a query without a period covers all time. */
549
+ readonly defaultPeriod: P
546
550
  readonly measures: M
547
551
  readonly dimensions: Dm
548
552
  }
@@ -571,7 +575,8 @@ export type DatasetDefinition<
571
575
  K extends string,
572
576
  M extends Record<string, AnyMeasure>,
573
577
  Dm extends Record<string, AnyDimension>,
574
- T extends string
578
+ T extends string,
579
+ P extends PeriodInput
575
580
  > = {
576
581
  key: K
577
582
  label: string
@@ -587,6 +592,11 @@ export type DatasetDefinition<
587
592
  time: Record<T, { column: Expr<'timestamp'>; label: string }>
588
593
  /** The time field periods apply to when a query names none; the first one by default. */
589
594
  defaultTime?: NoInfer<T> | undefined
595
+ /**
596
+ * The period of a query that names none; the last 30 days by default. A dataset of current
597
+ * state (units, organizations) sets `{ all: true }`.
598
+ */
599
+ defaultPeriod?: P | undefined
590
600
  measures: M & CheckMeasures<M>
591
601
  dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>
592
602
  }
@@ -599,8 +609,9 @@ export function defineDataset<
599
609
  const K extends string,
600
610
  const M extends Record<string, AnyMeasure>,
601
611
  const Dm extends Record<string, AnyDimension>,
602
- const T extends string
603
- >(definition: DatasetDefinition<K, M, Dm, T>): Dataset<K, M, Dm, T> {
612
+ const T extends string,
613
+ const P extends PeriodInput = typeof DEFAULT_PERIOD
614
+ >(definition: DatasetDefinition<K, M, Dm, T, P>): Dataset<K, M, Dm, T, P> {
604
615
  const timeEntries = Object.entries<{
605
616
  column: Expr<'timestamp'>
606
617
  label: string
@@ -619,7 +630,7 @@ export function defineDataset<
619
630
  ])
620
631
  ) as Record<T, TimeField>
621
632
 
622
- const dataset: Dataset<K, M, Dm, T> = {
633
+ const dataset: Dataset<K, M, Dm, T, P> = {
623
634
  key: definition.key,
624
635
  label: definition.label,
625
636
  description: definition.description,
@@ -629,6 +640,8 @@ export function defineDataset<
629
640
  scope: definition.scope?.node ?? null,
630
641
  time,
631
642
  defaultTime: definition.defaultTime ?? (firstTime[0] as T),
643
+ // P is DEFAULT_PERIOD's type when the definition names no period.
644
+ defaultPeriod: definition.defaultPeriod ?? (DEFAULT_PERIOD as P),
632
645
  measures: definition.measures,
633
646
  dimensions: definition.dimensions
634
647
  }
package/src/guidance.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export const analyticsToolGuidance = `- Answer every "how many", "how much", "per week" or "split by" question with the analytics tool. Never count by hand from lists or pages.
6
6
  - Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.
7
- - Say which period and filters the numbers cover, using the dates in the result ("1-30 September").
7
+ - Say which period and filters the numbers cover, using the dates in the result ("1-30 September", or "all time").
8
8
  - Compare with the previous period (compareToPrevious) when the change is the point, such as "is it going up".
9
9
  - When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.
10
10
  - Ask one short question only when the period is genuinely ambiguous ("this season") and someone can answer; otherwise take the natural reading and state it.`
package/src/index.ts CHANGED
@@ -65,10 +65,12 @@ export {
65
65
  } from './expr.js'
66
66
  export { analyticsToolGuidance } from './guidance.js'
67
67
  export type { PeriodInput, PeriodPreset } from './period.js'
68
+ export { allTenants, type Tenant, type TenantId } from './plan.js'
68
69
  export {
69
70
  AnalyticsResultSchema,
70
71
  type DatasetFilter,
71
72
  type DatasetQuery,
72
73
  type QueryResult,
73
- type ResultOf
74
+ type ResultOf,
75
+ type ResultPeriod
74
76
  } from './query.js'
package/src/period.ts CHANGED
@@ -13,11 +13,15 @@ export type PeriodPreset =
13
13
  | 'lastMonth'
14
14
  | 'thisYear'
15
15
 
16
- export type PeriodInput =
16
+ /** A range of days: what `resolvePeriod` turns into instants. */
17
+ export type PeriodRange =
17
18
  | { last: { days: number } }
18
19
  | { from: string; to: string }
19
20
  | { preset: PeriodPreset }
20
21
 
22
+ /** A range of days, or `{ all: true }` for no time filter. */
23
+ export type PeriodInput = PeriodRange | { all: true }
24
+
21
25
  export type ResolvedPeriod = {
22
26
  /** First local day, `YYYY-MM-DD`. */
23
27
  from: string
@@ -32,7 +36,9 @@ export type ResolvedPeriod = {
32
36
  }
33
37
 
34
38
  export const MAX_PERIOD_DAYS = 731
35
- export const DEFAULT_PERIOD: PeriodInput = { last: { days: 30 } }
39
+ export const DEFAULT_PERIOD = {
40
+ last: { days: 30 }
41
+ } as const satisfies PeriodInput
36
42
 
37
43
  const DAY_MS = 86_400_000
38
44
 
@@ -140,7 +146,7 @@ export class PeriodError extends Error {
140
146
 
141
147
  /** Resolves a period input to local days and instants in `timezone`. */
142
148
  export function resolvePeriod(
143
- input: PeriodInput,
149
+ input: PeriodRange,
144
150
  timezone: string,
145
151
  now: Date
146
152
  ): ResolvedPeriod {
@@ -154,7 +160,7 @@ export function resolvePeriod(
154
160
  return periodFromDays(from, to, timezone)
155
161
  }
156
162
 
157
- function periodDays(input: PeriodInput, today: string): [string, string] {
163
+ function periodDays(input: PeriodRange, today: string): [string, string] {
158
164
  if ('last' in input) return [addDays(today, -(input.last.days - 1)), today]
159
165
  if ('from' in input) return [input.from, input.to]
160
166
 
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
@@ -75,11 +71,23 @@ export function normalizeQuery(
75
71
  }
76
72
  }
77
73
 
74
+ /**
75
+ * What `tenant(ctx)` returns to query every tenant at once, for platform admins. It is a
76
+ * symbol, so no query input (JSON, a model's tool call) can ever carry it.
77
+ */
78
+ export const allTenants: unique symbol = Symbol('measurr.allTenants')
79
+
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
84
+
78
85
  /** What the planner needs besides the query: values from the hooks and resolved labels. */
79
86
  export type PlanContext = {
80
- tenant: string | number
87
+ tenant: Tenant
81
88
  timezone: string
82
- period: ResolvedPeriod
89
+ /** Null for an all-time period: no time filter. */
90
+ period: ResolvedPeriod | null
83
91
  /** The moment the query runs. */
84
92
  now: Date
85
93
  /** Keys for filters on open dimensions, after label resolution, by dimension. */
@@ -203,17 +211,30 @@ function dimensionNodes(
203
211
  /** Tenant, scope and period: the conditions every statement of a query carries. */
204
212
  function baseWhere(
205
213
  dataset: AnyDataset,
206
- tenant: string | number,
214
+ tenant: Tenant,
207
215
  period: { time: string; start: Date; end: Date } | null
208
216
  ): ExprNode[] {
209
- const where: ExprNode[] = [
210
- {
211
- kind: 'compare',
212
- op: '=',
213
- left: dataset.tenantColumn,
214
- right: param(tenant)
215
- }
216
- ]
217
+ const where: ExprNode[] = []
218
+ if (tenant !== allTenants) {
219
+ const ids = typeof tenant === 'object' ? [...new Set(tenant)] : [tenant]
220
+ const [only] = ids
221
+ // One id compiles exactly like the scalar; an empty list renders as a false condition.
222
+ where.push(
223
+ ids.length === 1 && only !== undefined
224
+ ? {
225
+ kind: 'compare',
226
+ op: '=',
227
+ left: dataset.tenantColumn,
228
+ right: param(only)
229
+ }
230
+ : {
231
+ kind: 'in',
232
+ item: dataset.tenantColumn,
233
+ values: ids.map(param),
234
+ negated: false
235
+ }
236
+ )
237
+ }
217
238
  if (dataset.scope) where.push(dataset.scope)
218
239
 
219
240
  if (period) {
@@ -345,11 +366,15 @@ export function planQuery(
345
366
  }
346
367
 
347
368
  const where = [
348
- ...baseWhere(dataset, context.tenant, {
349
- time: query.time,
350
- start: context.period.start,
351
- end: context.period.end
352
- }),
369
+ ...baseWhere(
370
+ dataset,
371
+ context.tenant,
372
+ context.period && {
373
+ time: query.time,
374
+ start: context.period.start,
375
+ end: context.period.end
376
+ }
377
+ ),
353
378
  ...query.filters.map((filter) => filterNode(dataset, filter, context))
354
379
  ]
355
380
 
@@ -408,7 +433,7 @@ function orderBy(
408
433
  export function planLookup(
409
434
  dataset: AnyDataset,
410
435
  dimensionKey: string,
411
- tenant: string | number,
436
+ tenant: Tenant,
412
437
  values: readonly string[] | null,
413
438
  now: Date
414
439
  ): SelectStatement {