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 +42 -14
- package/dist/analytics.d.ts +4 -3
- package/dist/dataset.d.ts +11 -3
- package/dist/guidance.d.ts +1 -1
- package/dist/{index-dj3369zz.js → index-wrr67sxq.js} +62 -28
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/period.d.ts +12 -3
- package/dist/plan.d.ts +6 -4
- package/dist/query.d.ts +40 -30
- package/dist/testing.js +26 -21
- package/package.json +1 -1
- package/src/analytics.ts +34 -21
- package/src/dataset.ts +18 -5
- package/src/guidance.ts +1 -1
- package/src/index.ts +3 -2
- package/src/period.ts +10 -4
- package/src/plan.ts +34 -21
- package/src/query.ts +75 -26
- package/src/testing.ts +31 -27
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.
|
|
14
|
-
explicit `allTenants` value that no
|
|
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
|
|
158
|
-
|
|
159
|
-
`AnalyticsError('missing_tenant')
|
|
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
|
|
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
|
|
340
|
-
`dimension.*`, `table` / `Table`, the expression builders; the `Dialect`,
|
|
341
|
-
`AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
|
|
342
|
-
`assertDatasetContract`; and the types
|
|
343
|
-
`
|
|
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
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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 {};
|
package/dist/guidance.d.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 ??
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
849
|
+
previous && runPeriod(previous).then(({ rows, totals }) => ({
|
|
850
|
+
period: range(previous),
|
|
851
|
+
rows,
|
|
852
|
+
totals
|
|
853
|
+
}))
|
|
818
854
|
]);
|
|
819
855
|
return {
|
|
820
|
-
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 ||
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
leaked
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
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`
|
|
491
|
-
* when a JavaScript caller's hook returns something its type does not
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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 ??
|
|
64
|
+
period: parsed.period ?? dataset.defaultPeriod,
|
|
69
65
|
time: parsed.time ?? dataset.defaultTime,
|
|
70
66
|
compareToPrevious: parsed.compareToPrevious ?? false,
|
|
71
67
|
sort: parsed.sort
|
|
@@ -81,14 +77,17 @@ export function normalizeQuery(
|
|
|
81
77
|
*/
|
|
82
78
|
export const allTenants: unique symbol = Symbol('measurr.allTenants')
|
|
83
79
|
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
export type TenantId = string | number
|
|
81
|
+
|
|
82
|
+
/** One tenant's id, a list of them (an empty list matches no rows), or `allTenants`. */
|
|
83
|
+
export type Tenant = TenantId | readonly TenantId[] | typeof allTenants
|
|
86
84
|
|
|
87
85
|
/** What the planner needs besides the query: values from the hooks and resolved labels. */
|
|
88
86
|
export type PlanContext = {
|
|
89
87
|
tenant: Tenant
|
|
90
88
|
timezone: string
|
|
91
|
-
period:
|
|
89
|
+
/** Null for an all-time period: no time filter. */
|
|
90
|
+
period: ResolvedPeriod | null
|
|
92
91
|
/** The moment the query runs. */
|
|
93
92
|
now: Date
|
|
94
93
|
/** Keys for filters on open dimensions, after label resolution, by dimension. */
|
|
@@ -215,17 +214,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
|
-
|
|
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(
|
|
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(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
113
|
-
|
|
112
|
+
/** The days a result covers: local days `from` to `to` (inclusive), or all time. */
|
|
113
|
+
export type ResultPeriod =
|
|
114
|
+
| { from: string; to: string; timezone: string }
|
|
115
|
+
| { all: true; timezone: string }
|
|
116
|
+
|
|
117
|
+
/** The period query `Q` names; null or none means the dataset's `Default`. */
|
|
118
|
+
type QueryPeriod<Q, Default> = Q extends unknown
|
|
119
|
+
? 'period' extends keyof Q
|
|
120
|
+
? OrDefault<Q['period' & keyof Q], Default>
|
|
121
|
+
: Default
|
|
122
|
+
: never
|
|
123
|
+
type OrDefault<P, Default> = P extends null | undefined ? Default : P
|
|
124
|
+
type PeriodShape<P> = P extends { all: true }
|
|
125
|
+
? Extract<ResultPeriod, { all: true }>
|
|
126
|
+
: Exclude<ResultPeriod, { all: true }>
|
|
127
|
+
|
|
128
|
+
export type QueryResult<
|
|
129
|
+
Row,
|
|
130
|
+
Totals,
|
|
131
|
+
Period extends ResultPeriod = ResultPeriod
|
|
132
|
+
> = {
|
|
133
|
+
period: Period
|
|
114
134
|
rows: Row[]
|
|
115
135
|
totals: Totals
|
|
136
|
+
/** Only for a range: an all-time period has no previous period. */
|
|
116
137
|
previous?: {
|
|
117
138
|
period: { from: string; to: string; timezone: string }
|
|
118
139
|
rows: Row[]
|
|
@@ -124,9 +145,13 @@ export type QueryResult<Row, Totals> = {
|
|
|
124
145
|
|
|
125
146
|
/** The typed result of query `Q` against the datasets `DS`. */
|
|
126
147
|
export type ResultOf<DS, Q> =
|
|
127
|
-
DS extends Dataset<infer K, infer M, infer Dm, string>
|
|
148
|
+
DS extends Dataset<infer K, infer M, infer Dm, string, infer P>
|
|
128
149
|
? Q extends { dataset: K; measures: readonly (infer Mk extends string)[] }
|
|
129
|
-
? QueryResult<
|
|
150
|
+
? QueryResult<
|
|
151
|
+
ResultRow<M, Dm, Mk, GroupKeys<Q>>,
|
|
152
|
+
ResultTotals<M, Mk>,
|
|
153
|
+
PeriodShape<QueryPeriod<Q, P>>
|
|
154
|
+
>
|
|
130
155
|
: never
|
|
131
156
|
: never
|
|
132
157
|
|
|
@@ -135,24 +160,29 @@ export type ResultOf<DS, Q> =
|
|
|
135
160
|
|
|
136
161
|
const isoDate = z.iso.date().describe('A date, YYYY-MM-DD.')
|
|
137
162
|
|
|
138
|
-
|
|
139
|
-
.
|
|
140
|
-
z.object({
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
const periodSchema = z.union([
|
|
164
|
+
z.object({
|
|
165
|
+
last: z.object({
|
|
166
|
+
days: z
|
|
167
|
+
.number()
|
|
168
|
+
.int()
|
|
169
|
+
.min(1)
|
|
170
|
+
.max(MAX_PERIOD_DAYS)
|
|
171
|
+
.describe('Number of days, ending today.')
|
|
172
|
+
})
|
|
173
|
+
}),
|
|
174
|
+
z.object({ from: isoDate, to: isoDate.describe('Last day, inclusive.') }),
|
|
175
|
+
z.object({ preset: z.enum(PERIOD_PRESETS) }),
|
|
176
|
+
z.object({ all: z.literal(true) }).describe('Every row, with no time filter.')
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
/** How the schema names a dataset's default period, so a model knows what omitting it means. */
|
|
180
|
+
function describePeriod(period: PeriodInput): string {
|
|
181
|
+
if ('all' in period) return 'all time (no time filter)'
|
|
182
|
+
if ('last' in period) return `the last ${period.last.days} days`
|
|
183
|
+
if ('from' in period) return `${period.from} to ${period.to}`
|
|
184
|
+
return period.preset
|
|
185
|
+
}
|
|
156
186
|
|
|
157
187
|
function describeList(
|
|
158
188
|
entries: [string, { label: string; description?: string | undefined }][]
|
|
@@ -285,7 +315,11 @@ export function datasetQuerySchema(dataset: AnyDataset) {
|
|
|
285
315
|
.describe('Every filter must match.')
|
|
286
316
|
}),
|
|
287
317
|
// Nullable as well: LLM tool callers send null for a field they leave out.
|
|
288
|
-
period: periodSchema
|
|
318
|
+
period: periodSchema
|
|
319
|
+
.describe(
|
|
320
|
+
`Which days to count, in the organization timezone. Defaults to ${describePeriod(dataset.defaultPeriod)}. A range covers at most two years.`
|
|
321
|
+
)
|
|
322
|
+
.nullish(),
|
|
289
323
|
...(timeKeys && {
|
|
290
324
|
time: z
|
|
291
325
|
.enum(timeKeys)
|
|
@@ -297,7 +331,9 @@ export function datasetQuerySchema(dataset: AnyDataset) {
|
|
|
297
331
|
compareToPrevious: z
|
|
298
332
|
.boolean()
|
|
299
333
|
.optional()
|
|
300
|
-
.describe(
|
|
334
|
+
.describe(
|
|
335
|
+
'Also return the previous period of the same length. Not for all-time periods.'
|
|
336
|
+
),
|
|
301
337
|
sort: z
|
|
302
338
|
.object({
|
|
303
339
|
by: z.enum(sortKeys),
|
|
@@ -328,6 +364,15 @@ export function datasetQuerySchema(dataset: AnyDataset) {
|
|
|
328
364
|
message: `Sort by one of the asked measures or grouped dimensions: ${returned.join(', ')}`
|
|
329
365
|
})
|
|
330
366
|
}
|
|
367
|
+
|
|
368
|
+
const period = query.period ?? dataset.defaultPeriod
|
|
369
|
+
if (query.compareToPrevious && 'all' in period) {
|
|
370
|
+
context.addIssue({
|
|
371
|
+
code: 'custom',
|
|
372
|
+
path: ['compareToPrevious'],
|
|
373
|
+
message: 'An all-time period has no previous period to compare with'
|
|
374
|
+
})
|
|
375
|
+
}
|
|
331
376
|
})
|
|
332
377
|
}
|
|
333
378
|
|
|
@@ -339,11 +384,15 @@ export function buildQuerySchema(datasets: readonly AnyDataset[]) {
|
|
|
339
384
|
return z.discriminatedUnion('dataset', [first, ...rest])
|
|
340
385
|
}
|
|
341
386
|
|
|
342
|
-
const
|
|
387
|
+
const rangeOutput = z.object({
|
|
343
388
|
from: z.string(),
|
|
344
389
|
to: z.string(),
|
|
345
390
|
timezone: z.string()
|
|
346
391
|
})
|
|
392
|
+
const periodOutput = z.union([
|
|
393
|
+
rangeOutput,
|
|
394
|
+
z.object({ all: z.literal(true), timezone: z.string() })
|
|
395
|
+
])
|
|
347
396
|
const measureValue = z.number().nullable()
|
|
348
397
|
const dimensionValue = z.object({
|
|
349
398
|
key: z.union([z.string(), z.number()]).nullable(),
|
|
@@ -360,7 +409,7 @@ function resultSchemaOf<Row extends z.ZodType, Totals extends z.ZodType>(
|
|
|
360
409
|
rows: z.array(row),
|
|
361
410
|
totals,
|
|
362
411
|
previous: z
|
|
363
|
-
.object({ period:
|
|
412
|
+
.object({ period: rangeOutput, rows: z.array(row), totals })
|
|
364
413
|
.optional(),
|
|
365
414
|
notes: z.array(z.string()),
|
|
366
415
|
truncated: z.boolean()
|
package/src/testing.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
224
|
+
}
|
|
225
|
+
if (leaked.length > 0) {
|
|
226
|
+
return `${name} sees ${leaked.map(([key, value]) => `${key}=${value}`).join(', ')}`
|
|
227
|
+
}
|
|
222
228
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 () => {
|