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/README.md CHANGED
@@ -10,7 +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.
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.
14
16
  - The query schema is generated with Zod, so it doubles as an LLM tool schema
15
17
  (`z.toJSONSchema`) and as the request validator. It can be built per caller, offering only
16
18
  the datasets they may query.
@@ -150,15 +152,59 @@ ${analyticsToolGuidance}`
150
152
  the input named no known dataset, so free text from a model never becomes a metric label.
151
153
  What the hook throws or rejects with is swallowed; it cannot fail a query.
152
154
 
155
+ ## Tenants and platform admins
156
+
157
+ `tenant(ctx)` decides the scope of every statement, including the lookups that resolve filter
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
+ ```
173
+
174
+ The scope comes only from `ctx`, which your server builds from the session. `allTenants` is a
175
+ symbol, so a query (JSON, a model's tool call) cannot carry it. A discriminated context keeps
176
+ the rule in one typed place:
177
+
178
+ ```ts
179
+ import { allTenants } from 'measurr'
180
+
181
+ type Ctx =
182
+ // A super admin may narrow to one organization, or see all of them.
183
+ | { role: 'superAdmin'; organizationId?: OrganizationId }
184
+ // Everyone else is always scoped to the organization they are signed in to.
185
+ | { role: 'member'; organizationId: OrganizationId }
186
+
187
+ const analytics = createAnalytics({
188
+ // ...
189
+ tenant: (ctx: Ctx) =>
190
+ ctx.role === 'superAdmin' ? (ctx.organizationId ?? allTenants) : ctx.organizationId
191
+ })
192
+ ```
193
+
194
+ Resolve a super admin's optional organization id on the server (from the request, after
195
+ checking the session) before building `ctx`; never read it from the query input. Unscoped
196
+ lookups resolve labels across tenants, so `"Complaint"` matches every tenant's tag of that
197
+ name.
198
+
153
199
  ## The model
154
200
 
155
201
  | Concept | What it is |
156
202
  | --- | --- |
157
- | 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. |
158
204
  | Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). |
159
205
  | Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). |
160
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). |
161
- | Result | `{ period, rows, totals, previous?, notes, truncated }`. |
207
+ | Result | `{ period, rows, totals, previous?, notes, truncated }`; `period` is `{ from, to, timezone }` or `{ all: true, timezone }`. |
162
208
 
163
209
  **Operators per dimension kind.** Closed and open dimensions take `in`, `notIn`, `isEmpty`
164
210
  and `isNotEmpty`; numeric and time dimensions take `between`, `isEmpty` and `isNotEmpty`. The
@@ -189,6 +235,21 @@ try {
189
235
  The previous period has the same length and ends the day before. Time buckets use the same
190
236
  timezone. `time` picks the time field the period applies to; a time dimension names its own.
191
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
+
192
253
  **Totals are a separate, ungrouped statement**, so a many-to-many grouping (an order with
193
254
  two tags appears in two rows) never inflates them. Filters on many-to-many dimensions compile
194
255
  to `EXISTS`, so they never multiply rows either.
@@ -282,8 +343,8 @@ await assertDatasetContract({ dataset: orders, source, tenant: seededTenantId })
282
343
  It checks that every measure runs and returns a number or null, every grouping runs and its
283
344
  groups add up to the total (except many-to-many), `isEmpty` plus `isNotEmpty` equals the total,
284
345
  open dimensions filter by label to their group's count, closed dimensions filter by value, an
285
- empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC timezone
286
- (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.
287
348
 
288
349
  ## Extending
289
350
 
@@ -301,13 +362,13 @@ empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC
301
362
 
302
363
  ## Stable API
303
364
 
304
- Kept stable for publishing: `createAnalytics` and its options, `analytics.query`,
365
+ Kept stable for publishing: `createAnalytics` and its options, `allTenants`, `analytics.query`,
305
366
  `explain`, `querySchema`, `resultSchema`, `listDatasets`; `AnalyticsResultSchema`,
306
- `analyticsToolGuidance`, `AnalyticsQueryEvent`; `defineDataset`, `measure.*`,
307
- `dimension.*`, `table` / `Table`, the expression builders; the `Dialect`, `SqlRenderer` and
308
- `AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
309
- `assertDatasetContract`; and the types
310
- `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
311
372
  (`isAnalyticsError`). `ExprNode` and `SelectStatement` are
312
373
  exported for dialect authors and may grow new node kinds.
313
374
 
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { CompiledStatement, Dialect } from './compile.js';
3
3
  import type { AnyDataset, AnyDimension } from './dataset.js';
4
+ import { type Tenant } from './plan.js';
4
5
  import { type DatasetQuery, type QueryResult, type ResultOf } from './query.js';
5
6
  export type Row = Record<string, unknown>;
6
7
  /** A database: a dialect to compile with and a function that runs the compiled SQL. */
@@ -56,8 +57,13 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
56
57
  datasets: DS;
57
58
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
58
59
  sources: Record<string, AnalyticsSource>;
59
- /** The tenant every query is scoped to. Queries without one are refused. */
60
- tenant: (ctx: Ctx) => string | number | null | undefined;
60
+ /**
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.
65
+ */
66
+ tenant: (ctx: Ctx) => Tenant | null | undefined;
61
67
  /**
62
68
  * Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
63
69
  * `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
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
  });
@@ -309,9 +333,6 @@ function datasetResultSchema(dataset) {
309
333
  }
310
334
  var AnalyticsResultSchema2 = resultSchemaOf(z.record(z.string(), z.union([dimensionValue, measureValue])), z.record(z.string(), measureValue));
311
335
 
312
- // src/analytics.ts
313
- import { z as z2 } from "zod";
314
-
315
336
  // src/plan.ts
316
337
  function normalizeQuery(dataset, parsed) {
317
338
  const filters = (parsed.filters ?? []).map((filter) => filter.op === "in" || filter.op === "notIn" ? { ...filter, values: [...new Set(filter.values)] } : filter);
@@ -320,13 +341,14 @@ function normalizeQuery(dataset, parsed) {
320
341
  measures: [...new Set(parsed.measures)],
321
342
  groupBy: [...new Set(parsed.groupBy ?? [])],
322
343
  filters,
323
- period: parsed.period ?? DEFAULT_PERIOD,
344
+ period: parsed.period ?? dataset.defaultPeriod,
324
345
  time: parsed.time ?? dataset.defaultTime,
325
346
  compareToPrevious: parsed.compareToPrevious ?? false,
326
347
  sort: parsed.sort ? { by: parsed.sort.by, direction: parsed.sort.direction ?? "desc" } : null,
327
348
  limit: parsed.limit ?? DEFAULT_LIMIT
328
349
  };
329
350
  }
351
+ var allTenants2 = Symbol("measurr.allTenants");
330
352
  var param = (value) => ({ kind: "param", value });
331
353
  var and = (items) => items.length === 1 && items[0] ? items[0] : { kind: "logical", op: "and", items };
332
354
  function measureOf(dataset, key) {
@@ -413,14 +435,22 @@ function dimensionNodes(dataset, key, timezone) {
413
435
  }
414
436
  }
415
437
  function baseWhere(dataset, tenant, period) {
416
- const where = [
417
- {
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 ? {
418
443
  kind: "compare",
419
444
  op: "=",
420
445
  left: dataset.tenantColumn,
421
- right: param(tenant)
422
- }
423
- ];
446
+ right: param(only)
447
+ } : {
448
+ kind: "in",
449
+ item: dataset.tenantColumn,
450
+ values: ids.map(param),
451
+ negated: false
452
+ });
453
+ }
424
454
  if (dataset.scope)
425
455
  where.push(dataset.scope);
426
456
  if (period) {
@@ -522,7 +552,7 @@ function planQuery(dataset, query, context, grouped) {
522
552
  });
523
553
  }
524
554
  const where = [
525
- ...baseWhere(dataset, context.tenant, {
555
+ ...baseWhere(dataset, context.tenant, context.period && {
526
556
  time: query.time,
527
557
  start: context.period.start,
528
558
  end: context.period.end
@@ -685,6 +715,7 @@ function resultNotes(dataset, query) {
685
715
  }
686
716
 
687
717
  // src/analytics.ts
718
+ import { z as z2 } from "zod";
688
719
  class AnalyticsError2 extends Error {
689
720
  code;
690
721
  name = "AnalyticsError";
@@ -768,7 +799,7 @@ function createAnalytics2(options) {
768
799
  throw new AnalyticsError2("forbidden", `Not allowed to query ${dataset.key}`);
769
800
  }
770
801
  const tenant = options.tenant(ctx);
771
- if (tenant === null || tenant === undefined || tenant === "") {
802
+ if (!isTenant(tenant)) {
772
803
  throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
773
804
  }
774
805
  const timezone = await options.timezone?.(ctx) || "UTC";
@@ -779,14 +810,14 @@ function createAnalytics2(options) {
779
810
  const at = now();
780
811
  let period;
781
812
  try {
782
- period = resolvePeriod(query.period, timezone, at);
813
+ period = "all" in query.period ? null : resolvePeriod(query.period, timezone, at);
783
814
  } catch (error) {
784
815
  if (error instanceof PeriodError) {
785
816
  throw new AnalyticsError2("invalid_period", error.message);
786
817
  }
787
818
  throw error;
788
819
  }
789
- const previous = query.compareToPrevious ? previousPeriod(period) : null;
820
+ const previous = query.compareToPrevious && period ? previousPeriod(period) : null;
790
821
  return { dataset, query, tenant, timezone, period, previous, now: at };
791
822
  };
792
823
  const runQuery = async (input, ctx) => {
@@ -810,24 +841,22 @@ function createAnalytics2(options) {
810
841
  const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect);
811
842
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {};
812
843
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect);
813
- const periodOut = { from: target.from, to: target.to, timezone };
814
- return { period: periodOut, rows, totals, truncated };
844
+ return { rows, totals, truncated };
815
845
  };
846
+ const range = ({ from, to }) => ({ from, to, timezone });
816
847
  const [current, before] = await Promise.all([
817
848
  runPeriod(period),
818
- previous ? runPeriod(previous) : null
849
+ previous && runPeriod(previous).then(({ rows, totals }) => ({
850
+ period: range(previous),
851
+ rows,
852
+ totals
853
+ }))
819
854
  ]);
820
855
  return {
821
- period: current.period,
856
+ period: period ? range(period) : { all: true, timezone },
822
857
  rows: current.rows,
823
858
  totals: current.totals,
824
- ...before && {
825
- previous: {
826
- period: before.period,
827
- rows: before.rows,
828
- totals: before.totals
829
- }
830
- },
859
+ ...before && { previous: before },
831
860
  notes: resultNotes(dataset, query),
832
861
  truncated: current.truncated
833
862
  };
@@ -888,6 +917,12 @@ function createAnalytics2(options) {
888
917
  listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
889
918
  };
890
919
  }
920
+ function isTenant(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);
925
+ }
891
926
  function periodStatements(dataset, query, context) {
892
927
  const grouped = query.groupBy.length > 0;
893
928
  return {
@@ -1175,6 +1210,7 @@ function defineDataset2(definition) {
1175
1210
  scope: definition.scope?.node ?? null,
1176
1211
  time,
1177
1212
  defaultTime: definition.defaultTime ?? firstTime[0],
1213
+ defaultPeriod: definition.defaultPeriod ?? DEFAULT_PERIOD,
1178
1214
  measures: definition.measures,
1179
1215
  dimensions: definition.dimensions
1180
1216
  };
@@ -1289,4 +1325,4 @@ function plainCaseValues(node) {
1289
1325
  return [];
1290
1326
  }
1291
1327
 
1292
- export { MAX_MEASURES, AnalyticsResultSchema2, AnalyticsError2, isAnalyticsError2, createAnalytics2, measure2, dimension2, defineDataset2 };
1328
+ export { MAX_MEASURES, AnalyticsResultSchema2, allTenants2, AnalyticsError2, isAnalyticsError2, createAnalytics2, measure2, dimension2, defineDataset2 };
package/dist/index.d.ts CHANGED
@@ -4,4 +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 { 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
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  AnalyticsResultSchema2,
3
+ allTenants2,
3
4
  AnalyticsError2,
4
5
  isAnalyticsError2,
5
6
  createAnalytics2,
6
7
  measure2,
7
8
  dimension2,
8
9
  defineDataset2
9
- } from "./index-m0czpsyv.js";
10
+ } from "./index-wrr67sxq.js";
10
11
  import {
11
12
  CompileError2,
12
13
  decodeNumber2,
@@ -38,7 +39,7 @@ import {
38
39
  // src/guidance.ts
39
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.
40
41
  - Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.
41
- - 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").
42
43
  - Compare with the previous period (compareToPrevious) when the change is the point, such as "is it going up".
43
44
  - When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.
44
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.`;
@@ -48,6 +49,7 @@ export {
48
49
  CompileError2 as CompileError,
49
50
  Expr2 as Expr,
50
51
  Table2 as Table,
52
+ allTenants2 as allTenants,
51
53
  analyticsToolGuidance,
52
54
  and2 as and,
53
55
  caseWhen2 as caseWhen,
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;
@@ -45,11 +45,20 @@ export type ParsedQuery = {
45
45
  limit?: number | undefined;
46
46
  };
47
47
  export declare function normalizeQuery(dataset: AnyDataset, parsed: ParsedQuery): NormalizedQuery;
48
+ /**
49
+ * What `tenant(ctx)` returns to query every tenant at once, for platform admins. It is a
50
+ * symbol, so no query input (JSON, a model's tool call) can ever carry it.
51
+ */
52
+ export declare const allTenants: unique symbol;
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;
48
56
  /** What the planner needs besides the query: values from the hooks and resolved labels. */
49
57
  export type PlanContext = {
50
- tenant: string | number;
58
+ tenant: Tenant;
51
59
  timezone: string;
52
- period: ResolvedPeriod;
60
+ /** Null for an all-time period: no time filter. */
61
+ period: ResolvedPeriod | null;
53
62
  /** The moment the query runs. */
54
63
  now: Date;
55
64
  /** Keys for filters on open dimensions, after label resolution, by dimension. */
@@ -70,7 +79,7 @@ export declare function planQuery(dataset: AnyDataset, query: NormalizedQuery, c
70
79
  * Looks up an open dimension's keys and names. With `values`, only entries whose key or name
71
80
  * (case-insensitive) matches; without, the first entries by name, to list valid values.
72
81
  */
73
- export declare function planLookup(dataset: AnyDataset, dimensionKey: string, tenant: string | number, values: readonly string[] | null, now: Date): SelectStatement;
82
+ export declare function planLookup(dataset: AnyDataset, dimensionKey: string, tenant: Tenant, values: readonly string[] | null, now: Date): SelectStatement;
74
83
  export type ShapedValue = {
75
84
  key: string | number | null;
76
85
  label: string;