measurr 0.2.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/README.md CHANGED
@@ -10,8 +10,9 @@ returns shaped, typed rows.
10
10
  - Database-neutral definitions: dialects plug in through the `Dialect` interface, and Postgres
11
11
  ships today (`measurr/postgres`).
12
12
  - Tenant-scoped by construction: the engine adds the tenant filter to every statement and
13
- refuses to run without a tenant. Platform admins can query every tenant only through an
14
- explicit `allTenants` value that no query input can carry.
13
+ refuses to run without a tenant. A caller can be scoped to one tenant or a list of them;
14
+ platform admins can query every tenant only through an explicit `allTenants` value that no
15
+ query input can carry.
15
16
  - The query schema is generated with Zod, so it doubles as an LLM tool schema
16
17
  (`z.toJSONSchema`) and as the request validator. It can be built per caller, offering only
17
18
  the datasets they may query.
@@ -154,9 +155,21 @@ What the hook throws or rejects with is swallowed; it cannot fail a query.
154
155
  ## Tenants and platform admins
155
156
 
156
157
  `tenant(ctx)` decides the scope of every statement, including the lookups that resolve filter
157
- labels. It returns a tenant id, or `allTenants` to drop the tenant filter for a caller allowed
158
- to see every tenant. Anything else (null, undefined, `''`, any other value) is refused with
159
- `AnalyticsError('missing_tenant')`, so a missing organization never widens a query.
158
+ labels. It returns a tenant id (`TenantId`, a non-empty string or a finite number), a readonly
159
+ list of them, or `allTenants` to drop the tenant filter for a caller allowed to see every
160
+ tenant. Anything else is refused with `AnalyticsError('missing_tenant')`: null, undefined,
161
+ `''`, a list containing one of those or `allTenants`, or any other value. A missing
162
+ organization never widens a query.
163
+
164
+ A list compiles to `tenantColumn IN (...)` with one parameter per id; a one-element list is
165
+ the same statement as its id. An empty list is a scope that matches no rows: a reseller with
166
+ no connected organizations sees zeros, never everything. Lists are for callers scoped to a set
167
+ of organizations, such as an organization-group admin, a reseller, or either narrowed to a
168
+ region:
169
+
170
+ ```ts
171
+ tenant: (ctx: Ctx) => ctx.visibleOrganizationIds // readonly OrganizationId[]
172
+ ```
160
173
 
161
174
  The scope comes only from `ctx`, which your server builds from the session. `allTenants` is a
162
175
  symbol, so a query (JSON, a model's tool call) cannot carry it. A discriminated context keeps
@@ -187,11 +200,11 @@ name.
187
200
 
188
201
  | Concept | What it is |
189
202
  | --- | --- |
190
- | Dataset | One fact table: tenant column, optional scope, time fields, measures, dimensions, source. |
203
+ | Dataset | One fact table: tenant column, optional scope, time fields, default period, measures, dimensions, source. |
191
204
  | Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). |
192
205
  | Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). |
193
206
  | Query | `dataset`, 1-4 `measures`, 0-2 `groupBy`, `filters`, `period`, `time`, `compareToPrevious`, `sort` (by an asked measure or grouped dimension), `limit` (1-100, default 20). |
194
- | Result | `{ period, rows, totals, previous?, notes, truncated }`. |
207
+ | Result | `{ period, rows, totals, previous?, notes, truncated }`; `period` is `{ from, to, timezone }` or `{ all: true, timezone }`. |
195
208
 
196
209
  **Operators per dimension kind.** Closed and open dimensions take `in`, `notIn`, `isEmpty`
197
210
  and `isNotEmpty`; numeric and time dimensions take `between`, `isEmpty` and `isNotEmpty`. The
@@ -222,6 +235,21 @@ try {
222
235
  The previous period has the same length and ends the day before. Time buckets use the same
223
236
  timezone. `time` picks the time field the period applies to; a time dimension names its own.
224
237
 
238
+ `{ all: true }` drops the time filter: every row counts, and time buckets and parts still group
239
+ by the time field. It has no previous period, so `compareToPrevious` with it is an
240
+ `invalid_query`. The result's `period` is then `{ all: true, timezone }` instead of
241
+ `{ from, to, timezone }`; `previous.period` is always a range. The type follows the query. A
242
+ query that names a range, or names no period on a dataset whose default is a range, gets
243
+ `{ from, to, timezone }`, so `result.period.from` needs no narrowing. A query typed only as
244
+ `AnalyticsQuery` (parsed from the schema) gets the `ResultPeriod` union; narrow it with
245
+ `'all' in result.period`.
246
+
247
+ A query that names no period (or sends null) gets the dataset's `defaultPeriod`, the last 30
248
+ days unless the dataset says otherwise. A dataset that describes current state (units,
249
+ organizations) sets `defaultPeriod: { all: true }`, so "how many units per status" counts
250
+ every unit. The generated schema states each dataset's default, so a model knows what leaving
251
+ `period` out means.
252
+
225
253
  **Totals are a separate, ungrouped statement**, so a many-to-many grouping (an order with
226
254
  two tags appears in two rows) never inflates them. Filters on many-to-many dimensions compile
227
255
  to `EXISTS`, so they never multiply rows either.
@@ -315,8 +343,8 @@ await assertDatasetContract({ dataset: orders, source, tenant: seededTenantId })
315
343
  It checks that every measure runs and returns a number or null, every grouping runs and its
316
344
  groups add up to the total (except many-to-many), `isEmpty` plus `isNotEmpty` equals the total,
317
345
  open dimensions filter by label to their group's count, closed dimensions filter by value, an
318
- empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC timezone
319
- (default `Pacific/Auckland`) agree with single-day periods.
346
+ empty tenant and an empty tenant list see nothing (tenant isolation), and day buckets in a
347
+ far-from-UTC timezone (default `Pacific/Auckland`) agree with single-day periods.
320
348
 
321
349
  ## Extending
322
350
 
@@ -336,11 +364,11 @@ empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC
336
364
 
337
365
  Kept stable for publishing: `createAnalytics` and its options, `allTenants`, `analytics.query`,
338
366
  `explain`, `querySchema`, `resultSchema`, `listDatasets`; `AnalyticsResultSchema`,
339
- `analyticsToolGuidance`, `AnalyticsQueryEvent`; `defineDataset`, `measure.*`,
340
- `dimension.*`, `table` / `Table`, the expression builders; the `Dialect`, `SqlRenderer` and
341
- `AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
342
- `assertDatasetContract`; and the types
343
- `AnalyticsQuery`, `AnalyticsResult`, `Ratio`, `AnalyticsError` codes and their details
367
+ `analyticsToolGuidance`, `AnalyticsQueryEvent`; `defineDataset` (with `defaultPeriod`),
368
+ `measure.*`, `dimension.*`, `table` / `Table`, the expression builders; the `Dialect`,
369
+ `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
370
+ `assertDatasetContract`; and the types `AnalyticsQuery`, `AnalyticsResult`, `ResultPeriod`,
371
+ `PeriodInput`, `Tenant`, `TenantId`, `Ratio`, `AnalyticsError` codes and their details
344
372
  (`isAnalyticsError`). `ExprNode` and `SelectStatement` are
345
373
  exported for dialect authors and may grow new node kinds.
346
374
 
@@ -58,9 +58,10 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
58
58
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
59
59
  sources: Record<string, AnalyticsSource>;
60
60
  /**
61
- * The tenant every query is scoped to. Queries without one (null, undefined, '') are
62
- * refused. Return `allTenants` to drop the tenant filter, only for callers allowed to see
63
- * every tenant's rows.
61
+ * The tenant every query is scoped to, or a list of them (an empty list matches no rows).
62
+ * Queries without one (null, undefined, '', a list holding one of those) are refused.
63
+ * Return `allTenants` to drop the tenant filter, only for callers allowed to see every
64
+ * tenant's rows.
64
65
  */
65
66
  tenant: (ctx: Ctx) => Tenant | null | undefined;
66
67
  /**
package/dist/dataset.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Expr, type ExprNode, type SqlType, type Table, type TableRef, type TimeBucketUnit, type TimePartName } from './expr.js';
2
+ import { DEFAULT_PERIOD, type PeriodInput } from './period.js';
2
3
  declare const ratioBrand: unique symbol;
3
4
  /** A share or ratio between 0 and 1. Branded so nobody renders 0.42 as "0.42%". */
4
5
  export type Ratio = number & {
@@ -241,7 +242,7 @@ export type TimeField = {
241
242
  readonly column: ExprNode;
242
243
  readonly label: string;
243
244
  };
244
- export type Dataset<K extends string = string, M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>, T extends string = string> = {
245
+ export type Dataset<K extends string = string, M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>, T extends string = string, P extends PeriodInput = PeriodInput> = {
245
246
  readonly key: K;
246
247
  readonly label: string;
247
248
  readonly description: string;
@@ -251,6 +252,8 @@ export type Dataset<K extends string = string, M extends Record<string, AnyMeasu
251
252
  readonly scope: ExprNode | null;
252
253
  readonly time: Readonly<Record<T, TimeField>>;
253
254
  readonly defaultTime: T;
255
+ /** Kept as a type, so results know whether a query without a period covers all time. */
256
+ readonly defaultPeriod: P;
254
257
  readonly measures: M;
255
258
  readonly dimensions: Dm;
256
259
  };
@@ -272,7 +275,7 @@ type CheckDimensions<Dm, T extends string> = {
272
275
  time: T;
273
276
  } : unknown;
274
277
  };
275
- export type DatasetDefinition<K extends string, M extends Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension>, T extends string> = {
278
+ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension>, T extends string, P extends PeriodInput> = {
276
279
  key: K;
277
280
  label: string;
278
281
  /** Tells a reader (and a model) what one row of the dataset is. */
@@ -292,6 +295,11 @@ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeas
292
295
  }>;
293
296
  /** The time field periods apply to when a query names none; the first one by default. */
294
297
  defaultTime?: NoInfer<T> | undefined;
298
+ /**
299
+ * The period of a query that names none; the last 30 days by default. A dataset of current
300
+ * state (units, organizations) sets `{ all: true }`.
301
+ */
302
+ defaultPeriod?: P | undefined;
295
303
  measures: M & CheckMeasures<M>;
296
304
  dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>;
297
305
  };
@@ -299,5 +307,5 @@ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeas
299
307
  * Declares a dataset: one fact table with its tenant column, time fields, measures and
300
308
  * dimensions. Keys stay literal types, so queries and result rows are typed from it.
301
309
  */
302
- export declare function defineDataset<const K extends string, const M extends Record<string, AnyMeasure>, const Dm extends Record<string, AnyDimension>, const T extends string>(definition: DatasetDefinition<K, M, Dm, T>): Dataset<K, M, Dm, T>;
310
+ export declare function defineDataset<const K extends string, const M extends Record<string, AnyMeasure>, const Dm extends Record<string, AnyDimension>, const T extends string, const P extends PeriodInput = typeof DEFAULT_PERIOD>(definition: DatasetDefinition<K, M, Dm, T, P>): Dataset<K, M, Dm, T, P>;
303
311
  export {};
@@ -2,4 +2,4 @@
2
2
  * How a model should use an analytics query tool, as markdown bullets for its instructions.
3
3
  * It does not name the tool, so add a line that does.
4
4
  */
5
- export declare 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.\n- Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.\n- Say which period and filters the numbers cover, using the dates in the result (\"1-30 September\").\n- Compare with the previous period (compareToPrevious) when the change is the point, such as \"is it going up\".\n- When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.\n- 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.";
5
+ export declare 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.\n- Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.\n- Say which period and filters the numbers cover, using the dates in the result (\"1-30 September\", or \"all time\").\n- Compare with the previous period (compareToPrevious) when the change is the point, such as \"is it going up\".\n- When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.\n- 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.";
@@ -8,7 +8,9 @@ import { z } from "zod";
8
8
 
9
9
  // src/period.ts
10
10
  var MAX_PERIOD_DAYS = 731;
11
- var DEFAULT_PERIOD = { last: { days: 30 } };
11
+ var DEFAULT_PERIOD = {
12
+ last: { days: 30 }
13
+ };
12
14
  var DAY_MS = 86400000;
13
15
  var formatterCache = new Map;
14
16
  function formatter(timezone) {
@@ -165,8 +167,18 @@ var periodSchema = z.union([
165
167
  })
166
168
  }),
167
169
  z.object({ from: isoDate, to: isoDate.describe("Last day, inclusive.") }),
168
- z.object({ preset: z.enum(PERIOD_PRESETS) })
169
- ]).describe("Which days to count, in the organization timezone. Defaults to the last 30 days. At most two years.");
170
+ z.object({ preset: z.enum(PERIOD_PRESETS) }),
171
+ z.object({ all: z.literal(true) }).describe("Every row, with no time filter.")
172
+ ]);
173
+ function describePeriod(period) {
174
+ if ("all" in period)
175
+ return "all time (no time filter)";
176
+ if ("last" in period)
177
+ return `the last ${period.last.days} days`;
178
+ if ("from" in period)
179
+ return `${period.from} to ${period.to}`;
180
+ return period.preset;
181
+ }
170
182
  function describeList(entries) {
171
183
  return entries.map(([key, entry]) => `${key}: ${entry.label}${entry.description ? ` (${entry.description})` : ""}`).join(" · ");
172
184
  }
@@ -247,11 +259,11 @@ function datasetQuerySchema(dataset) {
247
259
  ...firstFilter && {
248
260
  filters: z.array(z.discriminatedUnion("dimension", [firstFilter, ...otherFilters])).optional().describe("Every filter must match.")
249
261
  },
250
- period: periodSchema.nullish(),
262
+ period: periodSchema.describe(`Which days to count, in the organization timezone. Defaults to ${describePeriod(dataset.defaultPeriod)}. A range covers at most two years.`).nullish(),
251
263
  ...timeKeys && {
252
264
  time: z.enum(timeKeys).optional().describe(`Which time field the period applies to; time dimensions name their own. Defaults to ${dataset.defaultTime}. ${describeList(Object.entries(dataset.time))}`)
253
265
  },
254
- compareToPrevious: z.boolean().optional().describe("Also return the previous period of the same length."),
266
+ compareToPrevious: z.boolean().optional().describe("Also return the previous period of the same length. Not for all-time periods."),
255
267
  sort: z.object({
256
268
  by: z.enum(sortKeys),
257
269
  direction: z.enum(["asc", "desc"]).optional()
@@ -268,6 +280,14 @@ function datasetQuerySchema(dataset) {
268
280
  message: `Sort by one of the asked measures or grouped dimensions: ${returned.join(", ")}`
269
281
  });
270
282
  }
283
+ const period = query.period ?? dataset.defaultPeriod;
284
+ if (query.compareToPrevious && "all" in period) {
285
+ context.addIssue({
286
+ code: "custom",
287
+ path: ["compareToPrevious"],
288
+ message: "An all-time period has no previous period to compare with"
289
+ });
290
+ }
271
291
  });
272
292
  }
273
293
  function buildQuerySchema(datasets) {
@@ -276,11 +296,15 @@ function buildQuerySchema(datasets) {
276
296
  throw new Error("querySchema needs at least one dataset");
277
297
  return z.discriminatedUnion("dataset", [first, ...rest]);
278
298
  }
279
- var periodOutput = z.object({
299
+ var rangeOutput = z.object({
280
300
  from: z.string(),
281
301
  to: z.string(),
282
302
  timezone: z.string()
283
303
  });
304
+ var periodOutput = z.union([
305
+ rangeOutput,
306
+ z.object({ all: z.literal(true), timezone: z.string() })
307
+ ]);
284
308
  var measureValue = z.number().nullable();
285
309
  var dimensionValue = z.object({
286
310
  key: z.union([z.string(), z.number()]).nullable(),
@@ -291,7 +315,7 @@ function resultSchemaOf(row, totals) {
291
315
  period: periodOutput,
292
316
  rows: z.array(row),
293
317
  totals,
294
- previous: z.object({ period: periodOutput, rows: z.array(row), totals }).optional(),
318
+ previous: z.object({ period: rangeOutput, rows: z.array(row), totals }).optional(),
295
319
  notes: z.array(z.string()),
296
320
  truncated: z.boolean()
297
321
  });
@@ -317,7 +341,7 @@ function normalizeQuery(dataset, parsed) {
317
341
  measures: [...new Set(parsed.measures)],
318
342
  groupBy: [...new Set(parsed.groupBy ?? [])],
319
343
  filters,
320
- period: parsed.period ?? DEFAULT_PERIOD,
344
+ period: parsed.period ?? dataset.defaultPeriod,
321
345
  time: parsed.time ?? dataset.defaultTime,
322
346
  compareToPrevious: parsed.compareToPrevious ?? false,
323
347
  sort: parsed.sort ? { by: parsed.sort.by, direction: parsed.sort.direction ?? "desc" } : null,
@@ -411,14 +435,22 @@ function dimensionNodes(dataset, key, timezone) {
411
435
  }
412
436
  }
413
437
  function baseWhere(dataset, tenant, period) {
414
- const where = tenant === allTenants2 ? [] : [
415
- {
438
+ const where = [];
439
+ if (tenant !== allTenants2) {
440
+ const ids = typeof tenant === "object" ? [...new Set(tenant)] : [tenant];
441
+ const [only] = ids;
442
+ where.push(ids.length === 1 && only !== undefined ? {
416
443
  kind: "compare",
417
444
  op: "=",
418
445
  left: dataset.tenantColumn,
419
- right: param(tenant)
420
- }
421
- ];
446
+ right: param(only)
447
+ } : {
448
+ kind: "in",
449
+ item: dataset.tenantColumn,
450
+ values: ids.map(param),
451
+ negated: false
452
+ });
453
+ }
422
454
  if (dataset.scope)
423
455
  where.push(dataset.scope);
424
456
  if (period) {
@@ -520,7 +552,7 @@ function planQuery(dataset, query, context, grouped) {
520
552
  });
521
553
  }
522
554
  const where = [
523
- ...baseWhere(dataset, context.tenant, {
555
+ ...baseWhere(dataset, context.tenant, context.period && {
524
556
  time: query.time,
525
557
  start: context.period.start,
526
558
  end: context.period.end
@@ -778,14 +810,14 @@ function createAnalytics2(options) {
778
810
  const at = now();
779
811
  let period;
780
812
  try {
781
- period = resolvePeriod(query.period, timezone, at);
813
+ period = "all" in query.period ? null : resolvePeriod(query.period, timezone, at);
782
814
  } catch (error) {
783
815
  if (error instanceof PeriodError) {
784
816
  throw new AnalyticsError2("invalid_period", error.message);
785
817
  }
786
818
  throw error;
787
819
  }
788
- const previous = query.compareToPrevious ? previousPeriod(period) : null;
820
+ const previous = query.compareToPrevious && period ? previousPeriod(period) : null;
789
821
  return { dataset, query, tenant, timezone, period, previous, now: at };
790
822
  };
791
823
  const runQuery = async (input, ctx) => {
@@ -809,24 +841,22 @@ function createAnalytics2(options) {
809
841
  const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect);
810
842
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {};
811
843
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect);
812
- const periodOut = { from: target.from, to: target.to, timezone };
813
- return { period: periodOut, rows, totals, truncated };
844
+ return { rows, totals, truncated };
814
845
  };
846
+ const range = ({ from, to }) => ({ from, to, timezone });
815
847
  const [current, before] = await Promise.all([
816
848
  runPeriod(period),
817
- previous ? runPeriod(previous) : null
849
+ previous && runPeriod(previous).then(({ rows, totals }) => ({
850
+ period: range(previous),
851
+ rows,
852
+ totals
853
+ }))
818
854
  ]);
819
855
  return {
820
- period: current.period,
856
+ period: period ? range(period) : { all: true, timezone },
821
857
  rows: current.rows,
822
858
  totals: current.totals,
823
- ...before && {
824
- previous: {
825
- period: before.period,
826
- rows: before.rows,
827
- totals: before.totals
828
- }
829
- },
859
+ ...before && { previous: before },
830
860
  notes: resultNotes(dataset, query),
831
861
  truncated: current.truncated
832
862
  };
@@ -888,7 +918,10 @@ function createAnalytics2(options) {
888
918
  };
889
919
  }
890
920
  function isTenant(value) {
891
- return value === allTenants2 || typeof value === "string" && value !== "" || typeof value === "number" && Number.isFinite(value);
921
+ return value === allTenants2 || isTenantId(value) || Array.isArray(value) && value.every(isTenantId);
922
+ }
923
+ function isTenantId(value) {
924
+ return typeof value === "string" && value !== "" || typeof value === "number" && Number.isFinite(value);
892
925
  }
893
926
  function periodStatements(dataset, query, context) {
894
927
  const grouped = query.groupBy.length > 0;
@@ -1177,6 +1210,7 @@ function defineDataset2(definition) {
1177
1210
  scope: definition.scope?.node ?? null,
1178
1211
  time,
1179
1212
  defaultTime: definition.defaultTime ?? firstTime[0],
1213
+ defaultPeriod: definition.defaultPeriod ?? DEFAULT_PERIOD,
1180
1214
  measures: definition.measures,
1181
1215
  dimensions: definition.dimensions
1182
1216
  };
package/dist/index.d.ts CHANGED
@@ -4,5 +4,5 @@ export { type AnyDataset, type AnyDimension, type AnyMeasure, type Dataset, type
4
4
  export { and, caseWhen, coalesce, type ColumnKey, eq, Expr, type ExprNode, exists, gt, gte, inList, isNotNull, isNull, literal, lt, lte, minutesAgo, ne, not, or, type SqlType, Table, type TableRef, table, toText } from './expr.js';
5
5
  export { analyticsToolGuidance } from './guidance.js';
6
6
  export type { PeriodInput, PeriodPreset } from './period.js';
7
- export { allTenants, type Tenant } from './plan.js';
8
- export { AnalyticsResultSchema, type DatasetFilter, type DatasetQuery, type QueryResult, type ResultOf } from './query.js';
7
+ export { allTenants, type Tenant, type TenantId } from './plan.js';
8
+ export { AnalyticsResultSchema, type DatasetFilter, type DatasetQuery, type QueryResult, type ResultOf, type ResultPeriod } from './query.js';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  measure2,
8
8
  dimension2,
9
9
  defineDataset2
10
- } from "./index-dj3369zz.js";
10
+ } from "./index-wrr67sxq.js";
11
11
  import {
12
12
  CompileError2,
13
13
  decodeNumber2,
@@ -39,7 +39,7 @@ import {
39
39
  // src/guidance.ts
40
40
  var 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.
41
41
  - Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.
42
- - Say which period and filters the numbers cover, using the dates in the result ("1-30 September").
42
+ - Say which period and filters the numbers cover, using the dates in the result ("1-30 September", or "all time").
43
43
  - Compare with the previous period (compareToPrevious) when the change is the point, such as "is it going up".
44
44
  - When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.
45
45
  - 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/dist/period.d.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * database compares plain timestamps and can use its time index.
5
5
  */
6
6
  export type PeriodPreset = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth' | 'thisYear';
7
- export type PeriodInput = {
7
+ /** A range of days: what `resolvePeriod` turns into instants. */
8
+ export type PeriodRange = {
8
9
  last: {
9
10
  days: number;
10
11
  };
@@ -14,6 +15,10 @@ export type PeriodInput = {
14
15
  } | {
15
16
  preset: PeriodPreset;
16
17
  };
18
+ /** A range of days, or `{ all: true }` for no time filter. */
19
+ export type PeriodInput = PeriodRange | {
20
+ all: true;
21
+ };
17
22
  export type ResolvedPeriod = {
18
23
  /** First local day, `YYYY-MM-DD`. */
19
24
  from: string;
@@ -27,7 +32,11 @@ export type ResolvedPeriod = {
27
32
  days: number;
28
33
  };
29
34
  export declare const MAX_PERIOD_DAYS = 731;
30
- export declare const DEFAULT_PERIOD: PeriodInput;
35
+ export declare const DEFAULT_PERIOD: {
36
+ readonly last: {
37
+ readonly days: 30;
38
+ };
39
+ };
31
40
  /** True for an IANA name the runtime knows. Names are also inlined in SQL, so be strict. */
32
41
  export declare function isValidTimezone(timezone: string): boolean;
33
42
  /** The local calendar date of `instant` in `timezone`, as `YYYY-MM-DD`. */
@@ -39,6 +48,6 @@ export declare class PeriodError extends Error {
39
48
  readonly name = "PeriodError";
40
49
  }
41
50
  /** Resolves a period input to local days and instants in `timezone`. */
42
- export declare function resolvePeriod(input: PeriodInput, timezone: string, now: Date): ResolvedPeriod;
51
+ export declare function resolvePeriod(input: PeriodRange, timezone: string, now: Date): ResolvedPeriod;
43
52
  /** The period of equal length that ends the day before `period` starts. */
44
53
  export declare function previousPeriod(period: ResolvedPeriod): ResolvedPeriod;
package/dist/plan.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Dialect, SelectStatement } from './compile.js';
2
2
  import type { AnyDataset } from './dataset.js';
3
- import { type PeriodInput, type ResolvedPeriod } from './period.js';
3
+ import type { PeriodInput, ResolvedPeriod } from './period.js';
4
4
  /** A parsed query with every default filled in and duplicates removed. */
5
5
  export type NormalizedQuery = {
6
6
  dataset: string;
@@ -50,13 +50,15 @@ export declare function normalizeQuery(dataset: AnyDataset, parsed: ParsedQuery)
50
50
  * symbol, so no query input (JSON, a model's tool call) can ever carry it.
51
51
  */
52
52
  export declare const allTenants: unique symbol;
53
- /** One tenant's id, or `allTenants`. */
54
- export type Tenant = string | number | typeof allTenants;
53
+ export type TenantId = string | number;
54
+ /** One tenant's id, a list of them (an empty list matches no rows), or `allTenants`. */
55
+ export type Tenant = TenantId | readonly TenantId[] | typeof allTenants;
55
56
  /** What the planner needs besides the query: values from the hooks and resolved labels. */
56
57
  export type PlanContext = {
57
58
  tenant: Tenant;
58
59
  timezone: string;
59
- period: ResolvedPeriod;
60
+ /** Null for an all-time period: no time filter. */
61
+ period: ResolvedPeriod | null;
60
62
  /** The moment the query runs. */
61
63
  now: Date;
62
64
  /** Keys for filters on open dimensions, after label resolution, by dimension. */
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-dj3369zz.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.2.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
@@ -6,6 +6,7 @@ import {
6
6
  type ParsedQuery,
7
7
  type PlanContext,
8
8
  type Tenant,
9
+ type TenantId,
9
10
  allTenants,
10
11
  normalizeQuery,
11
12
  planLookup,
@@ -115,9 +116,10 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
115
116
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
116
117
  sources: Record<string, AnalyticsSource>
117
118
  /**
118
- * The tenant every query is scoped to. Queries without one (null, undefined, '') are
119
- * refused. Return `allTenants` to drop the tenant filter, only for callers allowed to see
120
- * every tenant's rows.
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.
121
123
  */
122
124
  tenant: (ctx: Ctx) => Tenant | null | undefined
123
125
  /**
@@ -207,7 +209,8 @@ type Prepared = {
207
209
  query: NormalizedQuery
208
210
  tenant: Tenant
209
211
  timezone: string
210
- period: ResolvedPeriod
212
+ /** Null for all time. */
213
+ period: ResolvedPeriod | null
211
214
  previous: ResolvedPeriod | null
212
215
  now: Date
213
216
  }
@@ -326,16 +329,19 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
326
329
 
327
330
  const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
328
331
  const at = now()
329
- let period: ResolvedPeriod
332
+ let period: ResolvedPeriod | null
330
333
  try {
331
- period = resolvePeriod(query.period, timezone, at)
334
+ period =
335
+ 'all' in query.period ? null : resolvePeriod(query.period, timezone, at)
332
336
  } catch (error) {
333
337
  if (error instanceof PeriodError) {
334
338
  throw new AnalyticsError('invalid_period', error.message)
335
339
  }
336
340
  throw error
337
341
  }
338
- 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
339
345
 
340
346
  return { dataset, query, tenant, timezone, period, previous, now: at }
341
347
  }
@@ -357,7 +363,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
357
363
  dialect
358
364
  )
359
365
 
360
- const runPeriod = async (target: ResolvedPeriod) => {
366
+ const runPeriod = async (target: ResolvedPeriod | null) => {
361
367
  const statements = periodStatements(dataset, query, {
362
368
  tenant,
363
369
  timezone,
@@ -373,27 +379,26 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
373
379
  const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
374
380
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
375
381
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
376
- const periodOut = { from: target.from, to: target.to, timezone }
377
382
 
378
- return { period: periodOut, rows, totals, truncated }
383
+ return { rows, totals, truncated }
379
384
  }
380
385
 
386
+ const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
381
387
  const [current, before] = await Promise.all([
382
388
  runPeriod(period),
383
- previous ? runPeriod(previous) : null
389
+ previous &&
390
+ runPeriod(previous).then(({ rows, totals }) => ({
391
+ period: range(previous),
392
+ rows,
393
+ totals
394
+ }))
384
395
  ])
385
396
 
386
397
  return {
387
- period: current.period,
398
+ period: period ? range(period) : { all: true, timezone },
388
399
  rows: current.rows,
389
400
  totals: current.totals,
390
- ...(before && {
391
- previous: {
392
- period: before.period,
393
- rows: before.rows,
394
- totals: before.totals
395
- }
396
- }),
401
+ ...(before && { previous: before }),
397
402
  notes: resultNotes(dataset, query),
398
403
  truncated: current.truncated
399
404
  }
@@ -487,12 +492,20 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
487
492
  }
488
493
 
489
494
  /**
490
- * Fails closed: only a non-empty string, a finite number or `allTenants` scopes a query, even
491
- * when a JavaScript caller's hook returns something its type does not allow.
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.
492
498
  */
493
499
  function isTenant(value: unknown): value is Tenant {
494
500
  return (
495
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 (
496
509
  (typeof value === 'string' && value !== '') ||
497
510
  (typeof value === 'number' && Number.isFinite(value))
498
511
  )
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,11 +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 } from './plan.js'
68
+ export { allTenants, type Tenant, type TenantId } from './plan.js'
69
69
  export {
70
70
  AnalyticsResultSchema,
71
71
  type DatasetFilter,
72
72
  type DatasetQuery,
73
73
  type QueryResult,
74
- type ResultOf
74
+ type ResultOf,
75
+ type ResultPeriod
75
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
@@ -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,27 @@ 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
+ 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
+ ? {
223
225
  kind: 'compare',
224
226
  op: '=',
225
227
  left: dataset.tenantColumn,
226
- right: param(tenant)
228
+ right: param(only)
229
+ }
230
+ : {
231
+ kind: 'in',
232
+ item: dataset.tenantColumn,
233
+ values: ids.map(param),
234
+ negated: false
227
235
  }
228
- ]
236
+ )
237
+ }
229
238
  if (dataset.scope) where.push(dataset.scope)
230
239
 
231
240
  if (period) {
@@ -357,11 +366,15 @@ export function planQuery(
357
366
  }
358
367
 
359
368
  const where = [
360
- ...baseWhere(dataset, context.tenant, {
361
- time: query.time,
362
- start: context.period.start,
363
- end: context.period.end
364
- }),
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
+ ),
365
378
  ...query.filters.map((filter) => filterNode(dataset, filter, context))
366
379
  ]
367
380
 
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
@@ -11,6 +11,7 @@ import {
11
11
  measure
12
12
  } from './dataset.js'
13
13
  import type { PeriodInput } from './period.js'
14
+ import type { TenantId } from './plan.js'
14
15
  import { MAX_MEASURES } from './query.js'
15
16
 
16
17
  export type ContractCheck = {
@@ -78,14 +79,14 @@ export async function checkDatasetContract(
78
79
  const analytics = createAnalytics({
79
80
  datasets: [probe],
80
81
  sources: { main: options.source },
81
- tenant: (ctx: { tenant: string | number }) => ctx.tenant,
82
+ tenant: (ctx: { tenant: TenantId | readonly TenantId[] }) => ctx.tenant,
82
83
  timezone: () => timezone,
83
84
  ...(options.now && { now: options.now })
84
85
  })
85
86
 
86
87
  const run = async (
87
88
  query: Record<string, unknown>,
88
- tenant: string | number = options.tenant
89
+ tenant: TenantId | readonly TenantId[] = options.tenant
89
90
  ): Promise<ProbeResult> => {
90
91
  const input = { dataset: dataset.key, period, limit: 100, ...query }
91
92
  return (await analytics.query(input as never, {
@@ -204,33 +205,36 @@ export async function checkDatasetContract(
204
205
  }
205
206
  }
206
207
 
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
208
+ // An empty tenant list is a scope that matches nothing, never a missing filter.
209
+ for (const [name, tenant] of [
210
+ ['an empty tenant', emptyTenant],
211
+ ['an empty tenant list', []]
212
+ ] as const) {
213
+ await check(`tenant isolation: ${name}`, async () => {
214
+ const measures = Object.keys(probe.measures)
215
+ const leaked: [string, number | null][] = []
216
+ for (let start = 0; start < measures.length; start += MAX_MEASURES) {
217
+ const batch = measures.slice(start, start + MAX_MEASURES)
218
+ const result = await run({ measures: batch }, tenant)
219
+ leaked.push(
220
+ ...Object.entries(result.totals).filter(
221
+ ([, value]) => value !== 0 && value !== null
222
+ )
216
223
  )
217
- )
218
- }
219
- if (leaked.length > 0) {
220
- return `an empty tenant sees ${leaked.map(([key, value]) => `${key}=${value}`).join(', ')}`
221
- }
224
+ }
225
+ if (leaked.length > 0) {
226
+ return `${name} sees ${leaked.map(([key, value]) => `${key}=${value}`).join(', ')}`
227
+ }
222
228
 
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
- })
229
+ for (const [key, entry] of dimensions) {
230
+ if (!entry.groupable) continue
231
+ const grouped = await run({ measures: [COUNT], groupBy: [key] }, tenant)
232
+ if (grouped.rows.length > 0)
233
+ return `${name} sees ${grouped.rows.length} ${key} groups`
234
+ }
235
+ return undefined
236
+ })
237
+ }
234
238
 
235
239
  for (const time of Object.keys(dataset.time)) {
236
240
  await check(`day buckets of ${time} in ${timezone}`, async () => {