measurr 0.3.0 → 0.4.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.
@@ -437,6 +437,9 @@ function dimensionNodes(dataset, key, timezone) {
437
437
  function baseWhere(dataset, tenant, period) {
438
438
  const where = [];
439
439
  if (tenant !== allTenants2) {
440
+ if (!dataset.tenantColumn) {
441
+ throw new Error(`Dataset ${dataset.key} has no tenant column; only allTenants may query it`);
442
+ }
440
443
  const ids = typeof tenant === "object" ? [...new Set(tenant)] : [tenant];
441
444
  const [only] = ids;
442
445
  where.push(ids.length === 1 && only !== undefined ? {
@@ -734,6 +737,7 @@ function createAnalytics2(options) {
734
737
  const sourceNames = Object.keys(options.sources);
735
738
  const byKey = new Map;
736
739
  const sourceOf = new Map;
740
+ const restrictedOf = new Map;
737
741
  for (const dataset of datasets) {
738
742
  if (byKey.has(dataset.key))
739
743
  throw new Error(`Two datasets use the key ${dataset.key}`);
@@ -741,6 +745,11 @@ function createAnalytics2(options) {
741
745
  const source = resolveSource(dataset, options.sources, sourceNames);
742
746
  sourceOf.set(dataset.key, source);
743
747
  compileEverything(dataset, source.dialect);
748
+ const restricted = restrictedFields(dataset);
749
+ if (restricted.length > 0 && !options.authorizeField) {
750
+ throw new Error(`Dataset ${dataset.key} marks fields with access, but createAnalytics has no authorizeField`);
751
+ }
752
+ restrictedOf.set(dataset.key, restricted);
744
753
  }
745
754
  const now = options.now ?? (() => new Date);
746
755
  const querySchemas = new Map;
@@ -757,9 +766,17 @@ function createAnalytics2(options) {
757
766
  throw new Error(`No source for ${dataset.key}`);
758
767
  return source;
759
768
  };
760
- const querySchemaFor = (keys) => {
761
- const selected = keys ? keys.map(datasetOf) : datasets;
762
- const selectionKey = selected.map((dataset) => dataset.key).sort().join(",");
769
+ const querySchemaFor = (selected) => {
770
+ const keyOf = (dataset) => {
771
+ if (byKey.get(dataset.key) === dataset)
772
+ return dataset.key;
773
+ const fields = [
774
+ ...Object.keys(dataset.measures),
775
+ ...Object.keys(dataset.dimensions)
776
+ ];
777
+ return `${dataset.key}(${fields.join(" ")})`;
778
+ };
779
+ const selectionKey = selected.map(keyOf).sort().join(",");
763
780
  let schema = querySchemas.get(selectionKey);
764
781
  if (!schema) {
765
782
  schema = buildQuerySchema(selected);
@@ -775,38 +792,68 @@ function createAnalytics2(options) {
775
792
  }
776
793
  return schema;
777
794
  };
795
+ const deniedFields = async (dataset, ctx) => {
796
+ const restricted = restrictedOf.get(dataset.key) ?? [];
797
+ const authorizeField = options.authorizeField;
798
+ if (restricted.length === 0 || !authorizeField)
799
+ return restricted;
800
+ const mayUse = async (field) => {
801
+ const answers = await Promise.all(field.access.map((access) => authorizeField(dataset, field.key, access, ctx)));
802
+ return answers.every(Boolean);
803
+ };
804
+ const allowed = await Promise.all(restricted.map(mayUse));
805
+ return restricted.filter((_, index) => !allowed[index]);
806
+ };
778
807
  const allowedDatasets = async (ctx) => {
779
- const { authorize } = options;
780
- if (!authorize)
781
- return datasets;
782
- const allowed = await Promise.all(datasets.map((dataset) => authorize(dataset, ctx)));
783
- return datasets.filter((_, index) => allowed[index]);
808
+ const everyTenant = options.tenant(ctx) === allTenants2;
809
+ const views = await Promise.all(datasets.map(async (dataset) => {
810
+ if (!dataset.tenantColumn && !everyTenant)
811
+ return null;
812
+ if (options.authorize && !await options.authorize(dataset, ctx))
813
+ return null;
814
+ const view = withoutFields(dataset, await deniedFields(dataset, ctx));
815
+ return Object.keys(view.measures).length > 0 ? view : null;
816
+ }));
817
+ return views.filter((view) => view !== null);
784
818
  };
785
819
  const querySchemaForCtx = async (ctx) => {
786
820
  const allowed = await allowedDatasets(ctx);
787
821
  if (allowed.length === 0) {
788
822
  throw new AnalyticsError2("forbidden", "No dataset is allowed");
789
823
  }
790
- return querySchemaFor(allowed.map((dataset) => dataset.key));
824
+ return querySchemaFor(allowed);
791
825
  };
792
826
  const prepare = async (input, ctx) => {
793
- const parsed = querySchemaFor(undefined).safeParse(input);
827
+ const parsed = querySchemaFor(datasets).safeParse(input);
794
828
  if (!parsed.success) {
795
829
  throw new AnalyticsError2("invalid_query", `Invalid analytics query: ${z2.prettifyError(parsed.error)}`, parsed.error.issues);
796
830
  }
797
- const dataset = datasetOf(parsed.data.dataset);
831
+ const parsedQuery = parsed.data;
832
+ const dataset = datasetOf(parsedQuery.dataset);
798
833
  if (options.authorize && !await options.authorize(dataset, ctx)) {
799
834
  throw new AnalyticsError2("forbidden", `Not allowed to query ${dataset.key}`);
800
835
  }
836
+ const dimensionsUsed = [
837
+ ...parsedQuery.groupBy ?? [],
838
+ ...(parsedQuery.filters ?? []).map((filter) => filter.dimension)
839
+ ];
840
+ const refused = (await deniedFields(dataset, ctx)).filter((field) => field.kind === "measure" ? parsedQuery.measures.includes(field.key) : dimensionsUsed.includes(field.key));
841
+ if (refused.length > 0) {
842
+ const names = refused.map((field) => `${field.kind} ${field.key}`);
843
+ throw new AnalyticsError2("forbidden", `Not allowed to use ${names.join(", ")} of ${dataset.key}`);
844
+ }
801
845
  const tenant = options.tenant(ctx);
802
846
  if (!isTenant(tenant)) {
803
847
  throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
804
848
  }
849
+ if (!dataset.tenantColumn && tenant !== allTenants2) {
850
+ throw new AnalyticsError2("forbidden", `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`);
851
+ }
805
852
  const timezone = await options.timezone?.(ctx) || "UTC";
806
853
  if (!isValidTimezone(timezone)) {
807
854
  throw new AnalyticsError2("invalid_timezone", `Unknown timezone ${timezone}`);
808
855
  }
809
- const query = normalizeQuery(dataset, parsed.data);
856
+ const query = normalizeQuery(dataset, parsedQuery);
810
857
  const at = now();
811
858
  let period;
812
859
  try {
@@ -912,7 +959,7 @@ function createAnalytics2(options) {
912
959
  datasets: options.datasets,
913
960
  query,
914
961
  explain,
915
- querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets),
962
+ querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets?.map(datasetOf) ?? datasets),
916
963
  resultSchema: (key) => resultSchemaFor(key),
917
964
  listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
918
965
  };
@@ -943,7 +990,7 @@ function resolveSource(dataset, sources, sourceNames) {
943
990
  function compileEverything(dataset, dialect) {
944
991
  const now = new Date(0);
945
992
  const context = {
946
- tenant: "tenant",
993
+ tenant: dataset.tenantColumn ? "tenant" : allTenants2,
947
994
  timezone: "UTC",
948
995
  period: resolvePeriod({ last: { days: 1 } }, "UTC", now),
949
996
  now,
@@ -1009,6 +1056,38 @@ function decodeKey2(value, dialect) {
1009
1056
  return value;
1010
1057
  return dialect.decodeNumber(value) ?? String(value);
1011
1058
  }
1059
+ function restrictedFields(dataset) {
1060
+ const measures = dataset.measures;
1061
+ const accessOf = (key) => {
1062
+ const entry = measures[key];
1063
+ if (!entry)
1064
+ return [];
1065
+ const own = entry.access === undefined ? [] : [entry.access];
1066
+ return entry.kind === "ratio" ? [...own, ...accessOf(entry.numerator), ...accessOf(entry.denominator)] : own;
1067
+ };
1068
+ return [
1069
+ ...Object.keys(measures).map((key) => ({
1070
+ kind: "measure",
1071
+ key,
1072
+ access: [...new Set(accessOf(key))]
1073
+ })),
1074
+ ...Object.entries(dataset.dimensions).map(([key, entry]) => ({
1075
+ kind: "dimension",
1076
+ key,
1077
+ access: entry.access === undefined ? [] : [entry.access]
1078
+ }))
1079
+ ].filter((field) => field.access.length > 0);
1080
+ }
1081
+ function withoutFields(dataset, fields) {
1082
+ if (fields.length === 0)
1083
+ return dataset;
1084
+ const keep = (kind, entries) => Object.fromEntries(Object.entries(entries).filter(([key]) => !fields.some((field) => field.kind === kind && field.key === key)));
1085
+ return {
1086
+ ...dataset,
1087
+ measures: keep("measure", dataset.measures),
1088
+ dimensions: keep("dimension", dataset.dimensions)
1089
+ };
1090
+ }
1012
1091
  function catalogEntry(dataset) {
1013
1092
  return {
1014
1093
  key: dataset.key,
@@ -1046,6 +1125,7 @@ function aggregate(result, fn, arg, options) {
1046
1125
  result,
1047
1126
  label: options.label,
1048
1127
  description: options.description,
1128
+ access: options.access,
1049
1129
  node: {
1050
1130
  kind: "aggregate",
1051
1131
  fn,
@@ -1079,6 +1159,7 @@ var measure2 = {
1079
1159
  result: "ratio",
1080
1160
  label: options.label,
1081
1161
  description: options.description,
1162
+ access: options.access,
1082
1163
  numerator: options.numerator.node,
1083
1164
  denominator: options.denominator?.node ?? null
1084
1165
  };
@@ -1089,6 +1170,7 @@ var measure2 = {
1089
1170
  result: "ratio",
1090
1171
  label: options.label,
1091
1172
  description: options.description,
1173
+ access: options.access,
1092
1174
  numerator,
1093
1175
  denominator
1094
1176
  };
@@ -1098,6 +1180,7 @@ function base(options) {
1098
1180
  return {
1099
1181
  label: options.label,
1100
1182
  description: options.description,
1183
+ access: options.access,
1101
1184
  groupable: options.filterOnly !== true,
1102
1185
  filterable: options.groupOnly !== true
1103
1186
  };
@@ -1196,6 +1279,9 @@ function defineDataset2(definition) {
1196
1279
  if (!firstTime) {
1197
1280
  throw new Error(`Dataset ${definition.key}: declare at least one time field`);
1198
1281
  }
1282
+ if (definition.tenantColumn === undefined) {
1283
+ throw new Error(`Dataset ${definition.key}: declare tenantColumn, or null for a dataset without tenants`);
1284
+ }
1199
1285
  const time = Object.fromEntries(timeEntries.map(([key, field]) => [
1200
1286
  key,
1201
1287
  { column: field.column.node, label: field.label }
@@ -1206,7 +1292,7 @@ function defineDataset2(definition) {
1206
1292
  description: definition.description,
1207
1293
  from: definition.from.ref,
1208
1294
  source: definition.source,
1209
- tenantColumn: definition.tenantColumn.node,
1295
+ tenantColumn: definition.tenantColumn?.node ?? null,
1210
1296
  scope: definition.scope?.node ?? null,
1211
1297
  time,
1212
1298
  defaultTime: definition.defaultTime ?? firstTime[0],
@@ -1230,7 +1316,8 @@ function validateDataset(dataset) {
1230
1316
  }
1231
1317
  });
1232
1318
  };
1233
- check(dataset.tenantColumn, "tenantColumn");
1319
+ if (dataset.tenantColumn)
1320
+ check(dataset.tenantColumn, "tenantColumn");
1234
1321
  if (dataset.scope)
1235
1322
  check(dataset.scope, "scope");
1236
1323
  for (const [key, field] of Object.entries(dataset.time)) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type AnalyticsErrorOf, type AnalyticsOptions, type AnalyticsQuery, type AnalyticsQueryEvent, type AnalyticsResult, type AnalyticsSource, createAnalytics, type DatasetCatalogEntry, isAnalyticsError } from './analytics.js';
1
+ export { type AccessKey, type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type AnalyticsErrorOf, type AnalyticsOptions, type AnalyticsQuery, type AnalyticsQueryEvent, type AnalyticsResult, type AnalyticsSource, createAnalytics, type DatasetCatalogEntry, type FieldKey, isAnalyticsError } from './analytics.js';
2
2
  export { CompileError, type CompiledStatement, type Dialect, type JoinClause, type SelectItem, type SelectStatement, type SqlRenderer, decodeNumber, renderStatement } from './compile.js';
3
3
  export { type AnyDataset, type AnyDimension, type AnyMeasure, type Dataset, type DimensionValue, defineDataset, dimension, type MeasureValue, measure, type Ratio } from './dataset.js';
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';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  measure2,
8
8
  dimension2,
9
9
  defineDataset2
10
- } from "./index-wrr67sxq.js";
10
+ } from "./index-4rmjas9h.js";
11
11
  import {
12
12
  CompileError2,
13
13
  decodeNumber2,
package/dist/testing.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { type AnalyticsSource } from './analytics.js';
7
7
  import { type AnyDataset } from './dataset.js';
8
8
  import type { PeriodInput } from './period.js';
9
+ import { type TenantId, allTenants } from './plan.js';
9
10
  export type ContractCheck = {
10
11
  name: string;
11
12
  ok: boolean;
@@ -18,8 +19,11 @@ export type ContractReport = {
18
19
  export type ContractOptions = {
19
20
  dataset: AnyDataset;
20
21
  source: AnalyticsSource;
21
- /** A tenant with seeded rows in the period. */
22
- tenant: string | number;
22
+ /**
23
+ * A tenant with seeded rows in the period. `allTenants` for a dataset without a tenant
24
+ * column, which no single tenant may query.
25
+ */
26
+ tenant: TenantId | typeof allTenants;
23
27
  /** A tenant with no rows; defaults to a sentinel that no real tenant uses. */
24
28
  emptyTenant?: string | number;
25
29
  /** A timezone far from UTC makes bucketing mistakes visible. Defaults to Pacific/Auckland. */
@@ -30,8 +34,9 @@ export type ContractOptions = {
30
34
  };
31
35
  /**
32
36
  * Checks that every measure, grouping and filter compiles and runs, that groups add up to the
33
- * total, that labels resolve, that another tenant's rows never count, and that day buckets
34
- * agree with periods in the timezone.
37
+ * total, that labels resolve, that another tenant's rows never count (or, for a dataset
38
+ * without a tenant column, that a scoped caller is refused), and that day buckets agree with
39
+ * periods in the timezone. Fields marked with `access` are checked like any other.
35
40
  */
36
41
  export declare function checkDatasetContract(options: ContractOptions): Promise<ContractReport>;
37
42
  /** Runs `checkDatasetContract` and throws with every failed check listed. */
package/dist/testing.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  MAX_MEASURES,
3
+ allTenants2,
4
+ isAnalyticsError2,
3
5
  createAnalytics2,
4
6
  measure2,
5
7
  dimension2
6
- } from "./index-wrr67sxq.js";
8
+ } from "./index-4rmjas9h.js";
7
9
 
8
10
  // src/testing.ts
9
11
  var COUNT = "__contractCount";
@@ -31,6 +33,7 @@ async function checkDatasetContract(options) {
31
33
  datasets: [probe],
32
34
  sources: { main: options.source },
33
35
  tenant: (ctx) => ctx.tenant,
36
+ authorizeField: () => true,
34
37
  timezone: () => timezone,
35
38
  ...options.now && { now: options.now }
36
39
  });
@@ -55,10 +58,11 @@ async function checkDatasetContract(options) {
55
58
  };
56
59
  const total = (await run({ measures: [COUNT] })).totals[COUNT] ?? 0;
57
60
  if (total === 0) {
61
+ const scope = options.tenant === allTenants2 ? "The dataset" : `Tenant ${options.tenant}`;
58
62
  checks.push({
59
63
  name: "seeded rows",
60
64
  ok: false,
61
- message: `Tenant ${options.tenant} has no rows in the period; the checks below prove little`
65
+ message: `${scope} has no rows in the period; the checks below prove little`
62
66
  });
63
67
  }
64
68
  for (const key of Object.keys(dataset.measures)) {
@@ -133,6 +137,10 @@ async function checkDatasetContract(options) {
133
137
  ["an empty tenant list", []]
134
138
  ]) {
135
139
  await check(`tenant isolation: ${name}`, async () => {
140
+ if (!dataset.tenantColumn) {
141
+ const refused = await run({ measures: [COUNT] }, tenant).then(() => false, (error) => isAnalyticsError2(error, "forbidden"));
142
+ return refused ? undefined : `${name} may query a dataset without a tenant column`;
143
+ }
136
144
  const measures = Object.keys(probe.measures);
137
145
  const leaked = [];
138
146
  for (let start = 0;start < measures.length; start += MAX_MEASURES) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "measurr",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",