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 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
@@ -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
- /** The tenant every query is scoped to. Queries without one are refused. */
60
- tenant: (ctx: Ctx) => string | number | null | undefined;
60
+ /**
61
+ * The tenant every query is scoped to. 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 === null || tenant === undefined || 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-m0czpsyv.js";
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: string | number;
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: string | number, values: readonly string[] | null, now: Date): SelectStatement;
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
@@ -3,7 +3,7 @@ import {
3
3
  createAnalytics2,
4
4
  measure2,
5
5
  dimension2
6
- } from "./index-m0czpsyv.js";
6
+ } from "./index-dj3369zz.js";
7
7
 
8
8
  // src/testing.ts
9
9
  var COUNT = "__contractCount";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "measurr",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A small typed semantic layer: datasets of measures and dimensions compiled to one SQL statement per dialect.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
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
- /** The tenant every query is scoped to. Queries without one are refused. */
116
- tenant: (ctx: Ctx) => string | number | null | undefined
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 === null || tenant === undefined || 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: string | number,
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: string | number
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: string | number,
215
+ tenant: Tenant,
207
216
  period: { time: string; start: Date; end: Date } | null
208
217
  ): ExprNode[] {
209
- const where: ExprNode[] = [
210
- {
211
- kind: 'compare',
212
- op: '=',
213
- left: dataset.tenantColumn,
214
- right: param(tenant)
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: string | number,
423
+ tenant: Tenant,
412
424
  values: readonly string[] | null,
413
425
  now: Date
414
426
  ): SelectStatement {