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 +72 -11
- package/dist/analytics.d.ts +8 -2
- package/dist/dataset.d.ts +11 -3
- package/dist/guidance.d.ts +1 -1
- package/dist/{index-m0czpsyv.js → index-wrr67sxq.js} +68 -32
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -2
- package/dist/period.d.ts +12 -3
- package/dist/plan.d.ts +13 -4
- package/dist/query.d.ts +40 -30
- package/dist/testing.js +26 -21
- package/package.json +1 -1
- package/src/analytics.ts +63 -21
- package/src/dataset.ts +18 -5
- package/src/guidance.ts +1 -1
- package/src/index.ts +3 -1
- package/src/period.ts +10 -4
- package/src/plan.ts +48 -23
- package/src/query.ts +75 -26
- package/src/testing.ts +31 -27
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
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
type NormalizedQuery,
|
|
6
6
|
type ParsedQuery,
|
|
7
7
|
type PlanContext,
|
|
8
|
+
type Tenant,
|
|
9
|
+
type TenantId,
|
|
10
|
+
allTenants,
|
|
8
11
|
normalizeQuery,
|
|
9
12
|
planLookup,
|
|
10
13
|
planQuery,
|
|
@@ -112,8 +115,13 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
112
115
|
datasets: DS
|
|
113
116
|
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
114
117
|
sources: Record<string, AnalyticsSource>
|
|
115
|
-
/**
|
|
116
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The tenant every query is scoped to, or a list of them (an empty list matches no rows).
|
|
120
|
+
* Queries without one (null, undefined, '', a list holding one of those) are refused.
|
|
121
|
+
* Return `allTenants` to drop the tenant filter, only for callers allowed to see every
|
|
122
|
+
* tenant's rows.
|
|
123
|
+
*/
|
|
124
|
+
tenant: (ctx: Ctx) => Tenant | null | undefined
|
|
117
125
|
/**
|
|
118
126
|
* Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
|
|
119
127
|
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
@@ -195,6 +203,18 @@ export type AnalyticsResult<A, Q> =
|
|
|
195
203
|
|
|
196
204
|
type ShapedResult = QueryResult<Row, Row>
|
|
197
205
|
|
|
206
|
+
/** A parsed, authorized query with everything the hooks decide resolved. */
|
|
207
|
+
type Prepared = {
|
|
208
|
+
dataset: AnyDataset
|
|
209
|
+
query: NormalizedQuery
|
|
210
|
+
tenant: Tenant
|
|
211
|
+
timezone: string
|
|
212
|
+
/** Null for all time. */
|
|
213
|
+
period: ResolvedPeriod | null
|
|
214
|
+
previous: ResolvedPeriod | null
|
|
215
|
+
now: Date
|
|
216
|
+
}
|
|
217
|
+
|
|
198
218
|
export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
199
219
|
options: AnalyticsOptions<DS, Ctx>
|
|
200
220
|
): Analytics<DS, Ctx> {
|
|
@@ -273,7 +293,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
273
293
|
}
|
|
274
294
|
|
|
275
295
|
/** Parse, authorize, and resolve everything that comes from hooks. */
|
|
276
|
-
const prepare = async (input: unknown, ctx: Ctx) => {
|
|
296
|
+
const prepare = async (input: unknown, ctx: Ctx): Promise<Prepared> => {
|
|
277
297
|
const parsed = querySchemaFor(undefined).safeParse(input)
|
|
278
298
|
if (!parsed.success) {
|
|
279
299
|
throw new AnalyticsError(
|
|
@@ -291,8 +311,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
291
311
|
)
|
|
292
312
|
}
|
|
293
313
|
|
|
294
|
-
const tenant = options.tenant(ctx)
|
|
295
|
-
if (tenant
|
|
314
|
+
const tenant: unknown = options.tenant(ctx)
|
|
315
|
+
if (!isTenant(tenant)) {
|
|
296
316
|
throw new AnalyticsError(
|
|
297
317
|
'missing_tenant',
|
|
298
318
|
'Refusing to query without a tenant'
|
|
@@ -309,16 +329,19 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
309
329
|
|
|
310
330
|
const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
|
|
311
331
|
const at = now()
|
|
312
|
-
let period: ResolvedPeriod
|
|
332
|
+
let period: ResolvedPeriod | null
|
|
313
333
|
try {
|
|
314
|
-
period =
|
|
334
|
+
period =
|
|
335
|
+
'all' in query.period ? null : resolvePeriod(query.period, timezone, at)
|
|
315
336
|
} catch (error) {
|
|
316
337
|
if (error instanceof PeriodError) {
|
|
317
338
|
throw new AnalyticsError('invalid_period', error.message)
|
|
318
339
|
}
|
|
319
340
|
throw error
|
|
320
341
|
}
|
|
321
|
-
|
|
342
|
+
// The schema refuses compareToPrevious with an all-time period.
|
|
343
|
+
const previous =
|
|
344
|
+
query.compareToPrevious && period ? previousPeriod(period) : null
|
|
322
345
|
|
|
323
346
|
return { dataset, query, tenant, timezone, period, previous, now: at }
|
|
324
347
|
}
|
|
@@ -340,7 +363,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
340
363
|
dialect
|
|
341
364
|
)
|
|
342
365
|
|
|
343
|
-
const runPeriod = async (target: ResolvedPeriod) => {
|
|
366
|
+
const runPeriod = async (target: ResolvedPeriod | null) => {
|
|
344
367
|
const statements = periodStatements(dataset, query, {
|
|
345
368
|
tenant,
|
|
346
369
|
timezone,
|
|
@@ -356,27 +379,26 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
356
379
|
const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
|
|
357
380
|
const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
|
|
358
381
|
const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
|
|
359
|
-
const periodOut = { from: target.from, to: target.to, timezone }
|
|
360
382
|
|
|
361
|
-
return {
|
|
383
|
+
return { rows, totals, truncated }
|
|
362
384
|
}
|
|
363
385
|
|
|
386
|
+
const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
|
|
364
387
|
const [current, before] = await Promise.all([
|
|
365
388
|
runPeriod(period),
|
|
366
|
-
previous
|
|
389
|
+
previous &&
|
|
390
|
+
runPeriod(previous).then(({ rows, totals }) => ({
|
|
391
|
+
period: range(previous),
|
|
392
|
+
rows,
|
|
393
|
+
totals
|
|
394
|
+
}))
|
|
367
395
|
])
|
|
368
396
|
|
|
369
397
|
return {
|
|
370
|
-
period:
|
|
398
|
+
period: period ? range(period) : { all: true, timezone },
|
|
371
399
|
rows: current.rows,
|
|
372
400
|
totals: current.totals,
|
|
373
|
-
...(before && {
|
|
374
|
-
previous: {
|
|
375
|
-
period: before.period,
|
|
376
|
-
rows: before.rows,
|
|
377
|
-
totals: before.totals
|
|
378
|
-
}
|
|
379
|
-
}),
|
|
401
|
+
...(before && { previous: before }),
|
|
380
402
|
notes: resultNotes(dataset, query),
|
|
381
403
|
truncated: current.truncated
|
|
382
404
|
}
|
|
@@ -469,6 +491,26 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
469
491
|
}
|
|
470
492
|
}
|
|
471
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Fails closed: only a non-empty string, a finite number, a list of those or `allTenants`
|
|
496
|
+
* scopes a query, even when a JavaScript caller's hook returns something its type does not
|
|
497
|
+
* allow.
|
|
498
|
+
*/
|
|
499
|
+
function isTenant(value: unknown): value is Tenant {
|
|
500
|
+
return (
|
|
501
|
+
value === allTenants ||
|
|
502
|
+
isTenantId(value) ||
|
|
503
|
+
(Array.isArray(value) && value.every(isTenantId))
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isTenantId(value: unknown): value is TenantId {
|
|
508
|
+
return (
|
|
509
|
+
(typeof value === 'string' && value !== '') ||
|
|
510
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
|
|
472
514
|
/** The statement for a period's rows, and for its totals when the query groups. */
|
|
473
515
|
function periodStatements(
|
|
474
516
|
dataset: AnyDataset,
|
|
@@ -562,7 +604,7 @@ function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
|
|
|
562
604
|
async function resolveOpenValues(
|
|
563
605
|
dataset: AnyDataset,
|
|
564
606
|
query: NormalizedQuery,
|
|
565
|
-
tenant:
|
|
607
|
+
tenant: Tenant,
|
|
566
608
|
now: Date,
|
|
567
609
|
run: (statement: SelectStatement) => Promise<readonly Row[]>,
|
|
568
610
|
dialect: Dialect
|
package/src/dataset.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
directChildren,
|
|
10
10
|
rootOf
|
|
11
11
|
} from './expr.js'
|
|
12
|
+
import { DEFAULT_PERIOD, type PeriodInput } from './period.js'
|
|
12
13
|
|
|
13
14
|
declare const ratioBrand: unique symbol
|
|
14
15
|
|
|
@@ -532,7 +533,8 @@ export type Dataset<
|
|
|
532
533
|
K extends string = string,
|
|
533
534
|
M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>,
|
|
534
535
|
Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>,
|
|
535
|
-
T extends string = string
|
|
536
|
+
T extends string = string,
|
|
537
|
+
P extends PeriodInput = PeriodInput
|
|
536
538
|
> = {
|
|
537
539
|
readonly key: K
|
|
538
540
|
readonly label: string
|
|
@@ -543,6 +545,8 @@ export type Dataset<
|
|
|
543
545
|
readonly scope: ExprNode | null
|
|
544
546
|
readonly time: Readonly<Record<T, TimeField>>
|
|
545
547
|
readonly defaultTime: T
|
|
548
|
+
/** Kept as a type, so results know whether a query without a period covers all time. */
|
|
549
|
+
readonly defaultPeriod: P
|
|
546
550
|
readonly measures: M
|
|
547
551
|
readonly dimensions: Dm
|
|
548
552
|
}
|
|
@@ -571,7 +575,8 @@ export type DatasetDefinition<
|
|
|
571
575
|
K extends string,
|
|
572
576
|
M extends Record<string, AnyMeasure>,
|
|
573
577
|
Dm extends Record<string, AnyDimension>,
|
|
574
|
-
T extends string
|
|
578
|
+
T extends string,
|
|
579
|
+
P extends PeriodInput
|
|
575
580
|
> = {
|
|
576
581
|
key: K
|
|
577
582
|
label: string
|
|
@@ -587,6 +592,11 @@ export type DatasetDefinition<
|
|
|
587
592
|
time: Record<T, { column: Expr<'timestamp'>; label: string }>
|
|
588
593
|
/** The time field periods apply to when a query names none; the first one by default. */
|
|
589
594
|
defaultTime?: NoInfer<T> | undefined
|
|
595
|
+
/**
|
|
596
|
+
* The period of a query that names none; the last 30 days by default. A dataset of current
|
|
597
|
+
* state (units, organizations) sets `{ all: true }`.
|
|
598
|
+
*/
|
|
599
|
+
defaultPeriod?: P | undefined
|
|
590
600
|
measures: M & CheckMeasures<M>
|
|
591
601
|
dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>
|
|
592
602
|
}
|
|
@@ -599,8 +609,9 @@ export function defineDataset<
|
|
|
599
609
|
const K extends string,
|
|
600
610
|
const M extends Record<string, AnyMeasure>,
|
|
601
611
|
const Dm extends Record<string, AnyDimension>,
|
|
602
|
-
const T extends string
|
|
603
|
-
|
|
612
|
+
const T extends string,
|
|
613
|
+
const P extends PeriodInput = typeof DEFAULT_PERIOD
|
|
614
|
+
>(definition: DatasetDefinition<K, M, Dm, T, P>): Dataset<K, M, Dm, T, P> {
|
|
604
615
|
const timeEntries = Object.entries<{
|
|
605
616
|
column: Expr<'timestamp'>
|
|
606
617
|
label: string
|
|
@@ -619,7 +630,7 @@ export function defineDataset<
|
|
|
619
630
|
])
|
|
620
631
|
) as Record<T, TimeField>
|
|
621
632
|
|
|
622
|
-
const dataset: Dataset<K, M, Dm, T> = {
|
|
633
|
+
const dataset: Dataset<K, M, Dm, T, P> = {
|
|
623
634
|
key: definition.key,
|
|
624
635
|
label: definition.label,
|
|
625
636
|
description: definition.description,
|
|
@@ -629,6 +640,8 @@ export function defineDataset<
|
|
|
629
640
|
scope: definition.scope?.node ?? null,
|
|
630
641
|
time,
|
|
631
642
|
defaultTime: definition.defaultTime ?? (firstTime[0] as T),
|
|
643
|
+
// P is DEFAULT_PERIOD's type when the definition names no period.
|
|
644
|
+
defaultPeriod: definition.defaultPeriod ?? (DEFAULT_PERIOD as P),
|
|
632
645
|
measures: definition.measures,
|
|
633
646
|
dimensions: definition.dimensions
|
|
634
647
|
}
|
package/src/guidance.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const analyticsToolGuidance = `- Answer every "how many", "how much", "per week" or "split by" question with the analytics tool. Never count by hand from lists or pages.
|
|
6
6
|
- Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.
|
|
7
|
-
- Say which period and filters the numbers cover, using the dates in the result ("1-30 September").
|
|
7
|
+
- Say which period and filters the numbers cover, using the dates in the result ("1-30 September", or "all time").
|
|
8
8
|
- Compare with the previous period (compareToPrevious) when the change is the point, such as "is it going up".
|
|
9
9
|
- When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.
|
|
10
10
|
- Ask one short question only when the period is genuinely ambiguous ("this season") and someone can answer; otherwise take the natural reading and state it.`
|
package/src/index.ts
CHANGED
|
@@ -65,10 +65,12 @@ export {
|
|
|
65
65
|
} from './expr.js'
|
|
66
66
|
export { analyticsToolGuidance } from './guidance.js'
|
|
67
67
|
export type { PeriodInput, PeriodPreset } from './period.js'
|
|
68
|
+
export { allTenants, type Tenant, type TenantId } from './plan.js'
|
|
68
69
|
export {
|
|
69
70
|
AnalyticsResultSchema,
|
|
70
71
|
type DatasetFilter,
|
|
71
72
|
type DatasetQuery,
|
|
72
73
|
type QueryResult,
|
|
73
|
-
type ResultOf
|
|
74
|
+
type ResultOf,
|
|
75
|
+
type ResultPeriod
|
|
74
76
|
} from './query.js'
|
package/src/period.ts
CHANGED
|
@@ -13,11 +13,15 @@ export type PeriodPreset =
|
|
|
13
13
|
| 'lastMonth'
|
|
14
14
|
| 'thisYear'
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
@@ -75,11 +71,23 @@ export function normalizeQuery(
|
|
|
75
71
|
}
|
|
76
72
|
}
|
|
77
73
|
|
|
74
|
+
/**
|
|
75
|
+
* What `tenant(ctx)` returns to query every tenant at once, for platform admins. It is a
|
|
76
|
+
* symbol, so no query input (JSON, a model's tool call) can ever carry it.
|
|
77
|
+
*/
|
|
78
|
+
export const allTenants: unique symbol = Symbol('measurr.allTenants')
|
|
79
|
+
|
|
80
|
+
export type TenantId = string | number
|
|
81
|
+
|
|
82
|
+
/** One tenant's id, a list of them (an empty list matches no rows), or `allTenants`. */
|
|
83
|
+
export type Tenant = TenantId | readonly TenantId[] | typeof allTenants
|
|
84
|
+
|
|
78
85
|
/** What the planner needs besides the query: values from the hooks and resolved labels. */
|
|
79
86
|
export type PlanContext = {
|
|
80
|
-
tenant:
|
|
87
|
+
tenant: Tenant
|
|
81
88
|
timezone: string
|
|
82
|
-
period:
|
|
89
|
+
/** Null for an all-time period: no time filter. */
|
|
90
|
+
period: ResolvedPeriod | null
|
|
83
91
|
/** The moment the query runs. */
|
|
84
92
|
now: Date
|
|
85
93
|
/** Keys for filters on open dimensions, after label resolution, by dimension. */
|
|
@@ -203,17 +211,30 @@ function dimensionNodes(
|
|
|
203
211
|
/** Tenant, scope and period: the conditions every statement of a query carries. */
|
|
204
212
|
function baseWhere(
|
|
205
213
|
dataset: AnyDataset,
|
|
206
|
-
tenant:
|
|
214
|
+
tenant: Tenant,
|
|
207
215
|
period: { time: string; start: Date; end: Date } | null
|
|
208
216
|
): ExprNode[] {
|
|
209
|
-
const where: ExprNode[] = [
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
const where: ExprNode[] = []
|
|
218
|
+
if (tenant !== allTenants) {
|
|
219
|
+
const ids = typeof tenant === 'object' ? [...new Set(tenant)] : [tenant]
|
|
220
|
+
const [only] = ids
|
|
221
|
+
// One id compiles exactly like the scalar; an empty list renders as a false condition.
|
|
222
|
+
where.push(
|
|
223
|
+
ids.length === 1 && only !== undefined
|
|
224
|
+
? {
|
|
225
|
+
kind: 'compare',
|
|
226
|
+
op: '=',
|
|
227
|
+
left: dataset.tenantColumn,
|
|
228
|
+
right: param(only)
|
|
229
|
+
}
|
|
230
|
+
: {
|
|
231
|
+
kind: 'in',
|
|
232
|
+
item: dataset.tenantColumn,
|
|
233
|
+
values: ids.map(param),
|
|
234
|
+
negated: false
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
}
|
|
217
238
|
if (dataset.scope) where.push(dataset.scope)
|
|
218
239
|
|
|
219
240
|
if (period) {
|
|
@@ -345,11 +366,15 @@ export function planQuery(
|
|
|
345
366
|
}
|
|
346
367
|
|
|
347
368
|
const where = [
|
|
348
|
-
...baseWhere(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
369
|
+
...baseWhere(
|
|
370
|
+
dataset,
|
|
371
|
+
context.tenant,
|
|
372
|
+
context.period && {
|
|
373
|
+
time: query.time,
|
|
374
|
+
start: context.period.start,
|
|
375
|
+
end: context.period.end
|
|
376
|
+
}
|
|
377
|
+
),
|
|
353
378
|
...query.filters.map((filter) => filterNode(dataset, filter, context))
|
|
354
379
|
]
|
|
355
380
|
|
|
@@ -408,7 +433,7 @@ function orderBy(
|
|
|
408
433
|
export function planLookup(
|
|
409
434
|
dataset: AnyDataset,
|
|
410
435
|
dimensionKey: string,
|
|
411
|
-
tenant:
|
|
436
|
+
tenant: Tenant,
|
|
412
437
|
values: readonly string[] | null,
|
|
413
438
|
now: Date
|
|
414
439
|
): SelectStatement {
|