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.
- package/README.md +82 -23
- package/dist/analytics.d.ts +16 -4
- package/dist/dataset.d.ts +50 -30
- package/dist/{index-wrr67sxq.js → index-4rmjas9h.js} +103 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +9 -4
- package/dist/testing.js +10 -2
- package/package.json +1 -1
- package/src/analytics.ts +196 -25
- package/src/dataset.ts +135 -60
- package/src/index.ts +2 -0
- package/src/plan.ts +6 -0
- package/src/testing.ts +31 -9
|
@@ -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 = (
|
|
761
|
-
const
|
|
762
|
-
|
|
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
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
|
824
|
+
return querySchemaFor(allowed);
|
|
791
825
|
};
|
|
792
826
|
const prepare = async (input, ctx) => {
|
|
793
|
-
const parsed = querySchemaFor(
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
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
|
-
/**
|
|
22
|
-
|
|
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,
|
|
34
|
-
*
|
|
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-
|
|
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:
|
|
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