measurr 0.1.0 → 0.2.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 +35 -2
- package/dist/analytics.d.ts +7 -2
- package/dist/{index-m0czpsyv.js → index-dj3369zz.js} +8 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/plan.d.ts +9 -2
- package/dist/testing.js +1 -1
- package/package.json +1 -1
- package/src/analytics.ts +35 -6
- package/src/index.ts +1 -0
- package/src/plan.ts +23 -11
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ 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. Platform admins can query every tenant only through an
|
|
14
|
+
explicit `allTenants` value that no query input can carry.
|
|
14
15
|
- The query schema is generated with Zod, so it doubles as an LLM tool schema
|
|
15
16
|
(`z.toJSONSchema`) and as the request validator. It can be built per caller, offering only
|
|
16
17
|
the datasets they may query.
|
|
@@ -150,6 +151,38 @@ ${analyticsToolGuidance}`
|
|
|
150
151
|
the input named no known dataset, so free text from a model never becomes a metric label.
|
|
151
152
|
What the hook throws or rejects with is swallowed; it cannot fail a query.
|
|
152
153
|
|
|
154
|
+
## Tenants and platform admins
|
|
155
|
+
|
|
156
|
+
`tenant(ctx)` decides the scope of every statement, including the lookups that resolve filter
|
|
157
|
+
labels. It returns a tenant id, or `allTenants` to drop the tenant filter for a caller allowed
|
|
158
|
+
to see every tenant. Anything else (null, undefined, `''`, any other value) is refused with
|
|
159
|
+
`AnalyticsError('missing_tenant')`, so a missing organization never widens a query.
|
|
160
|
+
|
|
161
|
+
The scope comes only from `ctx`, which your server builds from the session. `allTenants` is a
|
|
162
|
+
symbol, so a query (JSON, a model's tool call) cannot carry it. A discriminated context keeps
|
|
163
|
+
the rule in one typed place:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { allTenants } from 'measurr'
|
|
167
|
+
|
|
168
|
+
type Ctx =
|
|
169
|
+
// A super admin may narrow to one organization, or see all of them.
|
|
170
|
+
| { role: 'superAdmin'; organizationId?: OrganizationId }
|
|
171
|
+
// Everyone else is always scoped to the organization they are signed in to.
|
|
172
|
+
| { role: 'member'; organizationId: OrganizationId }
|
|
173
|
+
|
|
174
|
+
const analytics = createAnalytics({
|
|
175
|
+
// ...
|
|
176
|
+
tenant: (ctx: Ctx) =>
|
|
177
|
+
ctx.role === 'superAdmin' ? (ctx.organizationId ?? allTenants) : ctx.organizationId
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Resolve a super admin's optional organization id on the server (from the request, after
|
|
182
|
+
checking the session) before building `ctx`; never read it from the query input. Unscoped
|
|
183
|
+
lookups resolve labels across tenants, so `"Complaint"` matches every tenant's tag of that
|
|
184
|
+
name.
|
|
185
|
+
|
|
153
186
|
## The model
|
|
154
187
|
|
|
155
188
|
| Concept | What it is |
|
|
@@ -301,7 +334,7 @@ empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC
|
|
|
301
334
|
|
|
302
335
|
## Stable API
|
|
303
336
|
|
|
304
|
-
Kept stable for publishing: `createAnalytics` and its options, `analytics.query`,
|
|
337
|
+
Kept stable for publishing: `createAnalytics` and its options, `allTenants`, `analytics.query`,
|
|
305
338
|
`explain`, `querySchema`, `resultSchema`, `listDatasets`; `AnalyticsResultSchema`,
|
|
306
339
|
`analyticsToolGuidance`, `AnalyticsQueryEvent`; `defineDataset`, `measure.*`,
|
|
307
340
|
`dimension.*`, `table` / `Table`, the expression builders; the `Dialect`, `SqlRenderer` and
|
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,12 @@ 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. Queries without one (null, undefined, '') are
|
|
62
|
+
* refused. Return `allTenants` to drop the tenant filter, only for callers allowed to see
|
|
63
|
+
* every tenant's rows.
|
|
64
|
+
*/
|
|
65
|
+
tenant: (ctx: Ctx) => Tenant | null | undefined;
|
|
61
66
|
/**
|
|
62
67
|
* Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
|
|
63
68
|
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
@@ -309,9 +309,6 @@ function datasetResultSchema(dataset) {
|
|
|
309
309
|
}
|
|
310
310
|
var AnalyticsResultSchema2 = resultSchemaOf(z.record(z.string(), z.union([dimensionValue, measureValue])), z.record(z.string(), measureValue));
|
|
311
311
|
|
|
312
|
-
// src/analytics.ts
|
|
313
|
-
import { z as z2 } from "zod";
|
|
314
|
-
|
|
315
312
|
// src/plan.ts
|
|
316
313
|
function normalizeQuery(dataset, parsed) {
|
|
317
314
|
const filters = (parsed.filters ?? []).map((filter) => filter.op === "in" || filter.op === "notIn" ? { ...filter, values: [...new Set(filter.values)] } : filter);
|
|
@@ -327,6 +324,7 @@ function normalizeQuery(dataset, parsed) {
|
|
|
327
324
|
limit: parsed.limit ?? DEFAULT_LIMIT
|
|
328
325
|
};
|
|
329
326
|
}
|
|
327
|
+
var allTenants2 = Symbol("measurr.allTenants");
|
|
330
328
|
var param = (value) => ({ kind: "param", value });
|
|
331
329
|
var and = (items) => items.length === 1 && items[0] ? items[0] : { kind: "logical", op: "and", items };
|
|
332
330
|
function measureOf(dataset, key) {
|
|
@@ -413,7 +411,7 @@ function dimensionNodes(dataset, key, timezone) {
|
|
|
413
411
|
}
|
|
414
412
|
}
|
|
415
413
|
function baseWhere(dataset, tenant, period) {
|
|
416
|
-
const where = [
|
|
414
|
+
const where = tenant === allTenants2 ? [] : [
|
|
417
415
|
{
|
|
418
416
|
kind: "compare",
|
|
419
417
|
op: "=",
|
|
@@ -685,6 +683,7 @@ function resultNotes(dataset, query) {
|
|
|
685
683
|
}
|
|
686
684
|
|
|
687
685
|
// src/analytics.ts
|
|
686
|
+
import { z as z2 } from "zod";
|
|
688
687
|
class AnalyticsError2 extends Error {
|
|
689
688
|
code;
|
|
690
689
|
name = "AnalyticsError";
|
|
@@ -768,7 +767,7 @@ function createAnalytics2(options) {
|
|
|
768
767
|
throw new AnalyticsError2("forbidden", `Not allowed to query ${dataset.key}`);
|
|
769
768
|
}
|
|
770
769
|
const tenant = options.tenant(ctx);
|
|
771
|
-
if (tenant
|
|
770
|
+
if (!isTenant(tenant)) {
|
|
772
771
|
throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
|
|
773
772
|
}
|
|
774
773
|
const timezone = await options.timezone?.(ctx) || "UTC";
|
|
@@ -888,6 +887,9 @@ function createAnalytics2(options) {
|
|
|
888
887
|
listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
|
|
889
888
|
};
|
|
890
889
|
}
|
|
890
|
+
function isTenant(value) {
|
|
891
|
+
return value === allTenants2 || typeof value === "string" && value !== "" || typeof value === "number" && Number.isFinite(value);
|
|
892
|
+
}
|
|
891
893
|
function periodStatements(dataset, query, context) {
|
|
892
894
|
const grouped = query.groupBy.length > 0;
|
|
893
895
|
return {
|
|
@@ -1289,4 +1291,4 @@ function plainCaseValues(node) {
|
|
|
1289
1291
|
return [];
|
|
1290
1292
|
}
|
|
1291
1293
|
|
|
1292
|
-
export { MAX_MEASURES, AnalyticsResultSchema2, AnalyticsError2, isAnalyticsError2, createAnalytics2, measure2, dimension2, defineDataset2 };
|
|
1294
|
+
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 { allTenants, type Tenant } from './plan.js';
|
|
7
8
|
export { AnalyticsResultSchema, type DatasetFilter, type DatasetQuery, type QueryResult, type ResultOf } 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-dj3369zz.js";
|
|
10
11
|
import {
|
|
11
12
|
CompileError2,
|
|
12
13
|
decodeNumber2,
|
|
@@ -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/plan.d.ts
CHANGED
|
@@ -45,9 +45,16 @@ 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
|
+
/** One tenant's id, or `allTenants`. */
|
|
54
|
+
export type Tenant = string | number | typeof allTenants;
|
|
48
55
|
/** What the planner needs besides the query: values from the hooks and resolved labels. */
|
|
49
56
|
export type PlanContext = {
|
|
50
|
-
tenant:
|
|
57
|
+
tenant: Tenant;
|
|
51
58
|
timezone: string;
|
|
52
59
|
period: ResolvedPeriod;
|
|
53
60
|
/** The moment the query runs. */
|
|
@@ -70,7 +77,7 @@ export declare function planQuery(dataset: AnyDataset, query: NormalizedQuery, c
|
|
|
70
77
|
* Looks up an open dimension's keys and names. With `values`, only entries whose key or name
|
|
71
78
|
* (case-insensitive) matches; without, the first entries by name, to list valid values.
|
|
72
79
|
*/
|
|
73
|
-
export declare function planLookup(dataset: AnyDataset, dimensionKey: string, tenant:
|
|
80
|
+
export declare function planLookup(dataset: AnyDataset, dimensionKey: string, tenant: Tenant, values: readonly string[] | null, now: Date): SelectStatement;
|
|
74
81
|
export type ShapedValue = {
|
|
75
82
|
key: string | number | null;
|
|
76
83
|
label: string;
|
package/dist/testing.js
CHANGED
package/package.json
CHANGED
package/src/analytics.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
type NormalizedQuery,
|
|
6
6
|
type ParsedQuery,
|
|
7
7
|
type PlanContext,
|
|
8
|
+
type Tenant,
|
|
9
|
+
allTenants,
|
|
8
10
|
normalizeQuery,
|
|
9
11
|
planLookup,
|
|
10
12
|
planQuery,
|
|
@@ -112,8 +114,12 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
112
114
|
datasets: DS
|
|
113
115
|
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
114
116
|
sources: Record<string, AnalyticsSource>
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
+
/**
|
|
118
|
+
* The tenant every query is scoped to. Queries without one (null, undefined, '') are
|
|
119
|
+
* refused. Return `allTenants` to drop the tenant filter, only for callers allowed to see
|
|
120
|
+
* every tenant's rows.
|
|
121
|
+
*/
|
|
122
|
+
tenant: (ctx: Ctx) => Tenant | null | undefined
|
|
117
123
|
/**
|
|
118
124
|
* Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
|
|
119
125
|
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
@@ -195,6 +201,17 @@ export type AnalyticsResult<A, Q> =
|
|
|
195
201
|
|
|
196
202
|
type ShapedResult = QueryResult<Row, Row>
|
|
197
203
|
|
|
204
|
+
/** A parsed, authorized query with everything the hooks decide resolved. */
|
|
205
|
+
type Prepared = {
|
|
206
|
+
dataset: AnyDataset
|
|
207
|
+
query: NormalizedQuery
|
|
208
|
+
tenant: Tenant
|
|
209
|
+
timezone: string
|
|
210
|
+
period: ResolvedPeriod
|
|
211
|
+
previous: ResolvedPeriod | null
|
|
212
|
+
now: Date
|
|
213
|
+
}
|
|
214
|
+
|
|
198
215
|
export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
199
216
|
options: AnalyticsOptions<DS, Ctx>
|
|
200
217
|
): Analytics<DS, Ctx> {
|
|
@@ -273,7 +290,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
273
290
|
}
|
|
274
291
|
|
|
275
292
|
/** Parse, authorize, and resolve everything that comes from hooks. */
|
|
276
|
-
const prepare = async (input: unknown, ctx: Ctx) => {
|
|
293
|
+
const prepare = async (input: unknown, ctx: Ctx): Promise<Prepared> => {
|
|
277
294
|
const parsed = querySchemaFor(undefined).safeParse(input)
|
|
278
295
|
if (!parsed.success) {
|
|
279
296
|
throw new AnalyticsError(
|
|
@@ -291,8 +308,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
291
308
|
)
|
|
292
309
|
}
|
|
293
310
|
|
|
294
|
-
const tenant = options.tenant(ctx)
|
|
295
|
-
if (tenant
|
|
311
|
+
const tenant: unknown = options.tenant(ctx)
|
|
312
|
+
if (!isTenant(tenant)) {
|
|
296
313
|
throw new AnalyticsError(
|
|
297
314
|
'missing_tenant',
|
|
298
315
|
'Refusing to query without a tenant'
|
|
@@ -469,6 +486,18 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
469
486
|
}
|
|
470
487
|
}
|
|
471
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Fails closed: only a non-empty string, a finite number or `allTenants` scopes a query, even
|
|
491
|
+
* when a JavaScript caller's hook returns something its type does not allow.
|
|
492
|
+
*/
|
|
493
|
+
function isTenant(value: unknown): value is Tenant {
|
|
494
|
+
return (
|
|
495
|
+
value === allTenants ||
|
|
496
|
+
(typeof value === 'string' && value !== '') ||
|
|
497
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
472
501
|
/** The statement for a period's rows, and for its totals when the query groups. */
|
|
473
502
|
function periodStatements(
|
|
474
503
|
dataset: AnyDataset,
|
|
@@ -562,7 +591,7 @@ function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
|
|
|
562
591
|
async function resolveOpenValues(
|
|
563
592
|
dataset: AnyDataset,
|
|
564
593
|
query: NormalizedQuery,
|
|
565
|
-
tenant:
|
|
594
|
+
tenant: Tenant,
|
|
566
595
|
now: Date,
|
|
567
596
|
run: (statement: SelectStatement) => Promise<readonly Row[]>,
|
|
568
597
|
dialect: Dialect
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ 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
69
|
export {
|
|
69
70
|
AnalyticsResultSchema,
|
|
70
71
|
type DatasetFilter,
|
package/src/plan.ts
CHANGED
|
@@ -75,9 +75,18 @@ export function normalizeQuery(
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* What `tenant(ctx)` returns to query every tenant at once, for platform admins. It is a
|
|
80
|
+
* symbol, so no query input (JSON, a model's tool call) can ever carry it.
|
|
81
|
+
*/
|
|
82
|
+
export const allTenants: unique symbol = Symbol('measurr.allTenants')
|
|
83
|
+
|
|
84
|
+
/** One tenant's id, or `allTenants`. */
|
|
85
|
+
export type Tenant = string | number | typeof allTenants
|
|
86
|
+
|
|
78
87
|
/** What the planner needs besides the query: values from the hooks and resolved labels. */
|
|
79
88
|
export type PlanContext = {
|
|
80
|
-
tenant:
|
|
89
|
+
tenant: Tenant
|
|
81
90
|
timezone: string
|
|
82
91
|
period: ResolvedPeriod
|
|
83
92
|
/** The moment the query runs. */
|
|
@@ -203,17 +212,20 @@ function dimensionNodes(
|
|
|
203
212
|
/** Tenant, scope and period: the conditions every statement of a query carries. */
|
|
204
213
|
function baseWhere(
|
|
205
214
|
dataset: AnyDataset,
|
|
206
|
-
tenant:
|
|
215
|
+
tenant: Tenant,
|
|
207
216
|
period: { time: string; start: Date; end: Date } | null
|
|
208
217
|
): ExprNode[] {
|
|
209
|
-
const where: ExprNode[] =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
218
|
+
const where: ExprNode[] =
|
|
219
|
+
tenant === allTenants
|
|
220
|
+
? []
|
|
221
|
+
: [
|
|
222
|
+
{
|
|
223
|
+
kind: 'compare',
|
|
224
|
+
op: '=',
|
|
225
|
+
left: dataset.tenantColumn,
|
|
226
|
+
right: param(tenant)
|
|
227
|
+
}
|
|
228
|
+
]
|
|
217
229
|
if (dataset.scope) where.push(dataset.scope)
|
|
218
230
|
|
|
219
231
|
if (period) {
|
|
@@ -408,7 +420,7 @@ function orderBy(
|
|
|
408
420
|
export function planLookup(
|
|
409
421
|
dataset: AnyDataset,
|
|
410
422
|
dimensionKey: string,
|
|
411
|
-
tenant:
|
|
423
|
+
tenant: Tenant,
|
|
412
424
|
values: readonly string[] | null,
|
|
413
425
|
now: Date
|
|
414
426
|
): SelectStatement {
|