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/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
|
|
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
|
|
307
|
-
`dimension.*`, `table` / `Table`, the expression builders; the `Dialect`,
|
|
308
|
-
`AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
|
|
309
|
-
`assertDatasetContract`; and the types
|
|
310
|
-
`
|
|
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
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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 {};
|
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
|
});
|
|
@@ -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 ??
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
849
|
+
previous && runPeriod(previous).then(({ rows, totals }) => ({
|
|
850
|
+
period: range(previous),
|
|
851
|
+
rows,
|
|
852
|
+
totals
|
|
853
|
+
}))
|
|
819
854
|
]);
|
|
820
855
|
return {
|
|
821
|
-
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 {
|
|
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-
|
|
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
|
-
|
|
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;
|
|
@@ -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:
|
|
58
|
+
tenant: Tenant;
|
|
51
59
|
timezone: string;
|
|
52
|
-
period:
|
|
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:
|
|
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;
|