measurr 0.7.0 → 0.8.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 +244 -53
- package/dist/analytics.d.ts +51 -14
- package/dist/dataset.d.ts +18 -0
- package/dist/handler.d.ts +9 -4
- package/dist/handler.js +54 -8
- package/dist/{index-g0ah4ehn.js → index-8nhqje4s.js} +260 -66
- package/dist/{index-rgqsrd72.js → index-vjak6aa0.js} +16 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/metrics.d.ts +152 -35
- package/dist/metrics.js +316 -83
- package/dist/plan.d.ts +3 -0
- package/dist/query.d.ts +169 -10
- package/dist/shapes.d.ts +81 -11
- package/dist/shapes.js +121 -13
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +42 -3
- package/package.json +3 -3
- package/src/analytics.ts +354 -73
- package/src/dataset.ts +49 -2
- package/src/handler.ts +38 -18
- package/src/index.ts +9 -2
- package/src/metrics.ts +646 -144
- package/src/plan.ts +64 -23
- package/src/query.ts +133 -15
- package/src/shapes.ts +293 -29
- package/src/testing.ts +64 -1
- package/dist/index-psw9s799.js +0 -46
package/dist/handler.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* as each finishes), and engine errors as HTTP errors. Framework-neutral; an oRPC or tRPC
|
|
4
4
|
* procedure wraps these in a few lines.
|
|
5
5
|
*/
|
|
6
|
-
import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails } from './analytics.js';
|
|
6
|
+
import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type QueryOptions } from './analytics.js';
|
|
7
7
|
import type { AnyDataset } from './dataset.js';
|
|
8
8
|
import type { DatasetQuery, ResultOf } from './query.js';
|
|
9
9
|
/** Why one query of a batch failed. Errors other than `AnalyticsError` are not described. */
|
|
@@ -33,9 +33,14 @@ type Queries<DS extends readonly AnyDataset[]> = Record<string, DatasetQuery<DS[
|
|
|
33
33
|
* Runs named queries in parallel. One query failing does not fail the others: its error is
|
|
34
34
|
* under its name. Each query is authorized, cached and reported like a single `query`.
|
|
35
35
|
*/
|
|
36
|
-
export declare function queryBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): Promise<BatchResult<DS, Q>>;
|
|
37
|
-
/**
|
|
38
|
-
export
|
|
36
|
+
export declare function queryBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx, options?: BatchOptions): Promise<BatchResult<DS, Q>>;
|
|
37
|
+
/** Aborting `signal` stops the batch's statements and rejects with its reason. */
|
|
38
|
+
export type BatchOptions = Pick<QueryOptions, 'signal'>;
|
|
39
|
+
/**
|
|
40
|
+
* Runs named queries in parallel and yields each as it finishes, for a streaming endpoint. A
|
|
41
|
+
* consumer that stops reading (a closed stream) stops the statements still running.
|
|
42
|
+
*/
|
|
43
|
+
export declare function streamBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx, options?: BatchOptions): AsyncGenerator<BatchChunk<DS, Q>, void, undefined>;
|
|
39
44
|
export type HttpError = {
|
|
40
45
|
status: 400 | 401 | 403;
|
|
41
46
|
/** The status name oRPC and tRPC use for it. */
|
package/dist/handler.js
CHANGED
|
@@ -1,11 +1,57 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
AnalyticsError2
|
|
3
|
+
} from "./index-8nhqje4s.js";
|
|
4
|
+
|
|
5
|
+
// src/handler.ts
|
|
6
|
+
async function queryBatch(analytics, queries, ctx, options) {
|
|
7
|
+
const results = {};
|
|
8
|
+
const errors = {};
|
|
9
|
+
for await (const chunk of streamBatch(analytics, queries, ctx, options)) {
|
|
10
|
+
if ("error" in chunk)
|
|
11
|
+
errors[chunk.key] = chunk.error;
|
|
12
|
+
else
|
|
13
|
+
results[chunk.key] = chunk.result;
|
|
14
|
+
}
|
|
15
|
+
return { results, errors };
|
|
16
|
+
}
|
|
17
|
+
async function* streamBatch(analytics, queries, ctx, options = {}) {
|
|
18
|
+
options.signal?.throwIfAborted();
|
|
19
|
+
const controller = new AbortController;
|
|
20
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
21
|
+
try {
|
|
22
|
+
const pending = new Map(Object.entries(queries).map(([key, query]) => {
|
|
23
|
+
const settled = analytics.query(query, ctx, { signal }).then((result) => ({ key, result }), (error) => ({ key, error: batchError(error) }));
|
|
24
|
+
return [key, settled];
|
|
25
|
+
}));
|
|
26
|
+
while (pending.size > 0) {
|
|
27
|
+
const chunk = await Promise.race(pending.values());
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
pending.delete(chunk.key);
|
|
30
|
+
yield chunk;
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
controller.abort();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function batchError(error) {
|
|
37
|
+
return error instanceof AnalyticsError2 ? { code: error.code, message: error.message } : { code: "internal", message: "The query failed" };
|
|
38
|
+
}
|
|
39
|
+
function toHttpError(error) {
|
|
40
|
+
if (error.code === "forbidden") {
|
|
41
|
+
return { status: 403, code: "FORBIDDEN", message: error.message };
|
|
42
|
+
}
|
|
43
|
+
if (error.code === "missing_tenant") {
|
|
44
|
+
return { status: 401, code: "UNAUTHORIZED", message: error.message };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
status: 400,
|
|
48
|
+
code: "BAD_REQUEST",
|
|
49
|
+
message: error.message,
|
|
50
|
+
data: { code: error.code, details: error.details }
|
|
51
|
+
};
|
|
52
|
+
}
|
|
7
53
|
export {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
54
|
+
queryBatch,
|
|
55
|
+
streamBatch,
|
|
56
|
+
toHttpError
|
|
11
57
|
};
|
|
@@ -98,7 +98,8 @@ function filterSchema(key, dim) {
|
|
|
98
98
|
]).describe(`${description} (bucket start dates, inclusive)`);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
function datasetQuerySchema(dataset) {
|
|
101
|
+
function datasetQuerySchema(dataset, options = {}) {
|
|
102
|
+
const maxLimit = options.maxLimit ?? MAX_LIMIT;
|
|
102
103
|
const measures = Object.entries(dataset.measures);
|
|
103
104
|
const dimensions = Object.entries(dataset.dimensions);
|
|
104
105
|
const groupable = dimensions.filter(([, dim]) => dim.groupable);
|
|
@@ -131,7 +132,8 @@ function datasetQuerySchema(dataset) {
|
|
|
131
132
|
by: z.enum(sortKeys),
|
|
132
133
|
direction: z.enum(["asc", "desc"]).optional()
|
|
133
134
|
}).optional().describe("One of the asked measures or grouped dimensions. Defaults to time order when grouped by time, otherwise the first measure, descending."),
|
|
134
|
-
limit: z.number().int().min(1).max(
|
|
135
|
+
limit: z.number().int().min(1).max(maxLimit).optional().describe(`Maximum number of groups. Defaults to ${DEFAULT_LIMIT}.`),
|
|
136
|
+
...options.parse && { totals: z.boolean().optional() }
|
|
135
137
|
}).superRefine((query, context) => {
|
|
136
138
|
const { groupBy } = query;
|
|
137
139
|
const by = query.sort?.by;
|
|
@@ -161,8 +163,8 @@ function datasetQuerySchema(dataset) {
|
|
|
161
163
|
}
|
|
162
164
|
});
|
|
163
165
|
}
|
|
164
|
-
function buildQuerySchema(datasets) {
|
|
165
|
-
const [first, ...rest] = datasets.map(datasetQuerySchema);
|
|
166
|
+
function buildQuerySchema(datasets, options = {}) {
|
|
167
|
+
const [first, ...rest] = datasets.map((dataset) => datasetQuerySchema(dataset, options));
|
|
166
168
|
if (!first)
|
|
167
169
|
throw new Error("querySchema needs at least one dataset");
|
|
168
170
|
return z.discriminatedUnion("dataset", [first, ...rest]);
|
|
@@ -181,14 +183,41 @@ var dimensionValue = z.object({
|
|
|
181
183
|
key: z.union([z.string(), z.number()]).nullable(),
|
|
182
184
|
label: z.string()
|
|
183
185
|
});
|
|
186
|
+
var fieldsOutput = z.object({
|
|
187
|
+
groupBy: z.array(z.object({
|
|
188
|
+
key: z.string(),
|
|
189
|
+
label: z.string(),
|
|
190
|
+
kind: z.enum([
|
|
191
|
+
"enum",
|
|
192
|
+
"computed",
|
|
193
|
+
"relation",
|
|
194
|
+
"manyToMany",
|
|
195
|
+
"number",
|
|
196
|
+
"timePart",
|
|
197
|
+
"timeBucket"
|
|
198
|
+
]),
|
|
199
|
+
unit: z.enum(["day", "week", "month"]).optional(),
|
|
200
|
+
values: z.array(z.object({ key: z.string(), label: z.string() })).optional()
|
|
201
|
+
})),
|
|
202
|
+
measures: z.array(z.object({
|
|
203
|
+
key: z.string(),
|
|
204
|
+
label: z.string(),
|
|
205
|
+
valueType: z.enum(["count", "number", "ratio"])
|
|
206
|
+
}))
|
|
207
|
+
});
|
|
184
208
|
function resultSchemaOf(row, totals) {
|
|
185
209
|
return z.object({
|
|
186
210
|
period: periodOutput,
|
|
187
211
|
rows: z.array(row),
|
|
188
|
-
totals,
|
|
189
|
-
previous: z.object({
|
|
212
|
+
totals: totals.optional(),
|
|
213
|
+
previous: z.object({
|
|
214
|
+
period: rangeOutput,
|
|
215
|
+
rows: z.array(row),
|
|
216
|
+
totals: totals.optional()
|
|
217
|
+
}).optional(),
|
|
190
218
|
notes: z.array(z.string()),
|
|
191
|
-
truncated: z.boolean()
|
|
219
|
+
truncated: z.boolean(),
|
|
220
|
+
fields: fieldsOutput.optional()
|
|
192
221
|
});
|
|
193
222
|
}
|
|
194
223
|
function datasetResultSchema(dataset) {
|
|
@@ -216,7 +245,8 @@ function normalizeQuery(dataset, parsed) {
|
|
|
216
245
|
time: parsed.time ?? dataset.defaultTime,
|
|
217
246
|
compareToPrevious: parsed.compareToPrevious ?? false,
|
|
218
247
|
sort: parsed.sort ? { by: parsed.sort.by, direction: parsed.sort.direction ?? "desc" } : null,
|
|
219
|
-
limit: parsed.limit ?? DEFAULT_LIMIT
|
|
248
|
+
limit: parsed.limit ?? DEFAULT_LIMIT,
|
|
249
|
+
totals: parsed.totals ?? true
|
|
220
250
|
};
|
|
221
251
|
}
|
|
222
252
|
var allTenants2 = Symbol("measurr.allTenants");
|
|
@@ -320,25 +350,20 @@ function dimensionNodes(dataset, key, timezone) {
|
|
|
320
350
|
}
|
|
321
351
|
}
|
|
322
352
|
}
|
|
353
|
+
function tenantWhere(column, tenant) {
|
|
354
|
+
const ids = typeof tenant === "object" ? [...new Set(tenant)] : [tenant];
|
|
355
|
+
const [only] = ids;
|
|
356
|
+
return [
|
|
357
|
+
ids.length === 1 && only !== undefined ? { kind: "compare", op: "=", left: column, right: param(only) } : { kind: "in", item: column, values: ids.map(param), negated: false }
|
|
358
|
+
];
|
|
359
|
+
}
|
|
323
360
|
function baseWhere(dataset, tenant, period) {
|
|
324
361
|
const where = [];
|
|
325
362
|
if (tenant !== allTenants2) {
|
|
326
363
|
if (!dataset.tenantColumn) {
|
|
327
364
|
throw new Error(`Dataset ${dataset.key} has no tenant column; only allTenants may query it`);
|
|
328
365
|
}
|
|
329
|
-
|
|
330
|
-
const [only] = ids;
|
|
331
|
-
where.push(ids.length === 1 && only !== undefined ? {
|
|
332
|
-
kind: "compare",
|
|
333
|
-
op: "=",
|
|
334
|
-
left: dataset.tenantColumn,
|
|
335
|
-
right: param(only)
|
|
336
|
-
} : {
|
|
337
|
-
kind: "in",
|
|
338
|
-
item: dataset.tenantColumn,
|
|
339
|
-
values: ids.map(param),
|
|
340
|
-
negated: false
|
|
341
|
-
});
|
|
366
|
+
where.push(...tenantWhere(dataset.tenantColumn, tenant));
|
|
342
367
|
}
|
|
343
368
|
if (dataset.scope)
|
|
344
369
|
where.push(dataset.scope);
|
|
@@ -403,7 +428,7 @@ function filterNode(dataset, filter, context) {
|
|
|
403
428
|
values: values.map(param),
|
|
404
429
|
negated: filter.op === "notIn"
|
|
405
430
|
};
|
|
406
|
-
if (filter.op === "in")
|
|
431
|
+
if (filter.op === "in" || neverNull(key))
|
|
407
432
|
return list;
|
|
408
433
|
return {
|
|
409
434
|
kind: "logical",
|
|
@@ -413,6 +438,13 @@ function filterNode(dataset, filter, context) {
|
|
|
413
438
|
}
|
|
414
439
|
}
|
|
415
440
|
}
|
|
441
|
+
function neverNull(node) {
|
|
442
|
+
if (node.kind === "param")
|
|
443
|
+
return node.value !== null;
|
|
444
|
+
if (node.kind !== "case" || !node.otherwise)
|
|
445
|
+
return false;
|
|
446
|
+
return neverNull(node.otherwise) && node.branches.every((branch) => neverNull(branch.result));
|
|
447
|
+
}
|
|
416
448
|
var columnAlias = {
|
|
417
449
|
measure: (key) => `m_${key}`,
|
|
418
450
|
groupKey: (key) => `g_${key}`,
|
|
@@ -488,9 +520,11 @@ function orderBy(dataset, query, select) {
|
|
|
488
520
|
return order;
|
|
489
521
|
}
|
|
490
522
|
function planLookup(dataset, dimensionKey, tenant, values, now) {
|
|
523
|
+
const dimension = dimensionOf(dataset, dimensionKey);
|
|
524
|
+
const lookup = dimension.kind === "relation" || dimension.kind === "manyToMany" ? dimension.lookup : undefined;
|
|
491
525
|
const nodes = dimensionNodes(dataset, dimensionKey, "UTC");
|
|
492
526
|
const label = nodes.label ?? nodes.key;
|
|
493
|
-
const where = baseWhere(dataset, tenant, null);
|
|
527
|
+
const where = lookup ? lookupWhere(dataset, lookup.tenantColumn, tenant) : baseWhere(dataset, tenant, null);
|
|
494
528
|
where.push({ kind: "isNull", item: nodes.key, negated: true });
|
|
495
529
|
if (values) {
|
|
496
530
|
where.push({
|
|
@@ -513,8 +547,8 @@ function planLookup(dataset, dimensionKey, tenant, values, now) {
|
|
|
513
547
|
});
|
|
514
548
|
}
|
|
515
549
|
return {
|
|
516
|
-
from: dataset.from,
|
|
517
|
-
virtualJoins: nodes.virtualJoin ? [nodes.virtualJoin] : [],
|
|
550
|
+
from: lookup ? lookup.table : dataset.from,
|
|
551
|
+
virtualJoins: !lookup && nodes.virtualJoin ? [nodes.virtualJoin] : [],
|
|
518
552
|
where,
|
|
519
553
|
select: [
|
|
520
554
|
{ alias: "key", node: nodes.key },
|
|
@@ -526,6 +560,15 @@ function planLookup(dataset, dimensionKey, tenant, values, now) {
|
|
|
526
560
|
now
|
|
527
561
|
};
|
|
528
562
|
}
|
|
563
|
+
function lookupWhere(dataset, tenantColumn, tenant) {
|
|
564
|
+
if (tenant === allTenants2 || !tenantColumn) {
|
|
565
|
+
if (!dataset.tenantColumn && tenant !== allTenants2) {
|
|
566
|
+
throw new Error(`Dataset ${dataset.key} has no tenant column; only allTenants may query it`);
|
|
567
|
+
}
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
return tenantWhere(tenantColumn, tenant);
|
|
571
|
+
}
|
|
529
572
|
var WEEKDAYS = [
|
|
530
573
|
"Monday",
|
|
531
574
|
"Tuesday",
|
|
@@ -637,13 +680,17 @@ function createAnalytics2(options) {
|
|
|
637
680
|
const byKey = new Map;
|
|
638
681
|
const sourceOf = new Map;
|
|
639
682
|
const restrictedOf = new Map;
|
|
683
|
+
const fingerprints = new Map;
|
|
640
684
|
for (const dataset of datasets) {
|
|
641
685
|
if (byKey.has(dataset.key))
|
|
642
686
|
throw new Error(`Two datasets use the key ${dataset.key}`);
|
|
643
687
|
byKey.set(dataset.key, dataset);
|
|
644
688
|
const source = resolveSource(dataset, options.sources, sourceNames);
|
|
645
689
|
sourceOf.set(dataset.key, source);
|
|
646
|
-
compileEverything(dataset, source.dialect);
|
|
690
|
+
const compiled = compileEverything(dataset, source.dialect);
|
|
691
|
+
if (options.cache) {
|
|
692
|
+
fingerprints.set(dataset.key, sha256(compiled + stableJson(catalogEntry(dataset))));
|
|
693
|
+
}
|
|
647
694
|
const restricted = restrictedFields(dataset);
|
|
648
695
|
if (restricted.length > 0 && !options.authorizeField) {
|
|
649
696
|
throw new Error(`Dataset ${dataset.key} marks fields with access, but createAnalytics has no authorizeField`);
|
|
@@ -672,6 +719,26 @@ function createAnalytics2(options) {
|
|
|
672
719
|
const now = options.now ?? (() => new Date);
|
|
673
720
|
const querySchemas = new Map;
|
|
674
721
|
const resultSchemas = new Map;
|
|
722
|
+
const parseSchema = buildQuerySchema(datasets, {
|
|
723
|
+
parse: true,
|
|
724
|
+
maxLimit: options.maxLimit
|
|
725
|
+
});
|
|
726
|
+
const limiters = new Map;
|
|
727
|
+
if (options.concurrency !== undefined) {
|
|
728
|
+
for (const source of new Set(Object.values(options.sources)))
|
|
729
|
+
limiters.set(source, semaphore(options.concurrency));
|
|
730
|
+
}
|
|
731
|
+
const inFlight = new Map;
|
|
732
|
+
const execute = async (source, statement, signal) => {
|
|
733
|
+
signal?.throwIfAborted();
|
|
734
|
+
const release = await limiters.get(source)?.(signal);
|
|
735
|
+
try {
|
|
736
|
+
signal?.throwIfAborted();
|
|
737
|
+
return await (signal ? source.execute(statement, { signal }) : source.execute(statement));
|
|
738
|
+
} finally {
|
|
739
|
+
release?.();
|
|
740
|
+
}
|
|
741
|
+
};
|
|
675
742
|
const datasetOf = (key) => {
|
|
676
743
|
const dataset = byKey.get(key);
|
|
677
744
|
if (!dataset)
|
|
@@ -741,6 +808,32 @@ function createAnalytics2(options) {
|
|
|
741
808
|
}
|
|
742
809
|
return querySchemaFor(allowed);
|
|
743
810
|
};
|
|
811
|
+
const scopeOf = (ctx) => {
|
|
812
|
+
const tenant = options.tenant(ctx);
|
|
813
|
+
if (!isTenant(tenant)) {
|
|
814
|
+
throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
|
|
815
|
+
}
|
|
816
|
+
return tenant;
|
|
817
|
+
};
|
|
818
|
+
const resolveIn = (input, timezone, at) => {
|
|
819
|
+
if ("all" in input)
|
|
820
|
+
return null;
|
|
821
|
+
try {
|
|
822
|
+
return resolvePeriod(input, timezone, at);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (error instanceof PeriodError) {
|
|
825
|
+
throw new AnalyticsError2("invalid_period", error.message);
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
const periodOf = async (input, ctx) => {
|
|
831
|
+
const parsed = periodSchema.safeParse(input);
|
|
832
|
+
if (!parsed.success) {
|
|
833
|
+
throw new AnalyticsError2("invalid_period", `Invalid period: ${z2.prettifyError(parsed.error)}`);
|
|
834
|
+
}
|
|
835
|
+
return resolveIn(parsed.data, await timezoneOf(ctx), now());
|
|
836
|
+
};
|
|
744
837
|
const timezoneOf = async (ctx) => {
|
|
745
838
|
const timezone = await options.timezone?.(ctx) || "UTC";
|
|
746
839
|
if (!isValidTimezone(timezone)) {
|
|
@@ -749,7 +842,7 @@ function createAnalytics2(options) {
|
|
|
749
842
|
return timezone;
|
|
750
843
|
};
|
|
751
844
|
const prepare = async (input, ctx) => {
|
|
752
|
-
const parsed =
|
|
845
|
+
const parsed = parseSchema.safeParse(input);
|
|
753
846
|
if (!parsed.success) {
|
|
754
847
|
throw new AnalyticsError2("invalid_query", `Invalid analytics query: ${z2.prettifyError(parsed.error)}`, parsed.error.issues);
|
|
755
848
|
}
|
|
@@ -767,33 +860,22 @@ function createAnalytics2(options) {
|
|
|
767
860
|
const names = refused.map((field) => `${field.kind} ${field.key}`);
|
|
768
861
|
throw new AnalyticsError2("forbidden", `Not allowed to use ${names.join(", ")} of ${dataset.key}`);
|
|
769
862
|
}
|
|
770
|
-
const tenant =
|
|
771
|
-
if (!isTenant(tenant)) {
|
|
772
|
-
throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
|
|
773
|
-
}
|
|
863
|
+
const tenant = scopeOf(ctx);
|
|
774
864
|
if (!dataset.tenantColumn && tenant !== allTenants2) {
|
|
775
865
|
throw new AnalyticsError2("forbidden", `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`);
|
|
776
866
|
}
|
|
777
867
|
const timezone = await timezoneOf(ctx);
|
|
778
868
|
const query = normalizeQuery(dataset, parsedQuery);
|
|
779
869
|
const at = now();
|
|
780
|
-
|
|
781
|
-
try {
|
|
782
|
-
period = "all" in query.period ? null : resolvePeriod(query.period, timezone, at);
|
|
783
|
-
} catch (error) {
|
|
784
|
-
if (error instanceof PeriodError) {
|
|
785
|
-
throw new AnalyticsError2("invalid_period", error.message);
|
|
786
|
-
}
|
|
787
|
-
throw error;
|
|
788
|
-
}
|
|
870
|
+
const period = resolveIn(query.period, timezone, at);
|
|
789
871
|
const previous = query.compareToPrevious && period ? previousPeriod(period) : null;
|
|
790
872
|
return { dataset, query, tenant, timezone, period, previous, now: at };
|
|
791
873
|
};
|
|
792
|
-
const runQuery = async (prepared, ctx) => {
|
|
874
|
+
const runQuery = async (prepared, ctx, signal) => {
|
|
793
875
|
const { dataset, query, tenant, timezone, period, previous, now } = prepared;
|
|
794
876
|
const source = sourceFor(dataset);
|
|
795
877
|
const { dialect } = source;
|
|
796
|
-
const run = (statement) =>
|
|
878
|
+
const run = (statement) => execute(source, dialect.compile(statement), signal);
|
|
797
879
|
const resolvedValues = await resolveOpenValues(dataset, query, tenant, now, run, dialect);
|
|
798
880
|
const range = ({ from, to }) => ({ from, to, timezone });
|
|
799
881
|
const runPeriod = async (target) => {
|
|
@@ -809,7 +891,8 @@ function createAnalytics2(options) {
|
|
|
809
891
|
statements.totals ? run(statements.totals) : null
|
|
810
892
|
]);
|
|
811
893
|
const { rows, kept, truncated } = shapeRows(dataset, query, rawRows, dialect);
|
|
812
|
-
const
|
|
894
|
+
const grouped = query.groupBy.length > 0;
|
|
895
|
+
const totalsRow = (rawTotals ?? (grouped ? [] : rawRows))[0] ?? {};
|
|
813
896
|
const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect);
|
|
814
897
|
for (const key of query.measures) {
|
|
815
898
|
const entry = dataset.measures[key];
|
|
@@ -846,20 +929,20 @@ function createAnalytics2(options) {
|
|
|
846
929
|
});
|
|
847
930
|
totals[key] = value(output.totals);
|
|
848
931
|
}
|
|
849
|
-
return { rows, totals, truncated };
|
|
932
|
+
return { rows, ...query.totals && { totals }, truncated };
|
|
850
933
|
};
|
|
851
934
|
const [current, before] = await Promise.all([
|
|
852
935
|
runPeriod(period),
|
|
853
936
|
previous && runPeriod(previous).then(({ rows, totals }) => ({
|
|
854
937
|
period: range(previous),
|
|
855
938
|
rows,
|
|
856
|
-
totals
|
|
939
|
+
...totals && { totals }
|
|
857
940
|
}))
|
|
858
941
|
]);
|
|
859
942
|
return {
|
|
860
943
|
period: period ? range(period) : { all: true, timezone },
|
|
861
944
|
rows: current.rows,
|
|
862
|
-
totals: current.totals,
|
|
945
|
+
...current.totals && { totals: current.totals },
|
|
863
946
|
...before && { previous: before },
|
|
864
947
|
notes: resultNotes(dataset, query),
|
|
865
948
|
truncated: current.truncated
|
|
@@ -892,34 +975,48 @@ function createAnalytics2(options) {
|
|
|
892
975
|
Promise.resolve(options.cache?.set(key, result, entry)).catch(() => {});
|
|
893
976
|
} catch {}
|
|
894
977
|
};
|
|
895
|
-
const query = async (input, ctx) => {
|
|
978
|
+
const query = async (input, ctx, queryOptions = {}) => {
|
|
979
|
+
const { signal, metric } = queryOptions;
|
|
896
980
|
const startedAt = performance.now();
|
|
897
981
|
const dataset = datasetKeyOf(input);
|
|
898
982
|
const durationMs = () => Math.round(performance.now() - startedAt);
|
|
899
983
|
let cache;
|
|
900
984
|
try {
|
|
985
|
+
signal?.throwIfAborted();
|
|
901
986
|
const prepared = await prepare(input, ctx);
|
|
902
|
-
const entry = {
|
|
903
|
-
|
|
987
|
+
const entry = {
|
|
988
|
+
dataset: prepared.dataset.key,
|
|
989
|
+
ctx,
|
|
990
|
+
...metric !== undefined && { metric }
|
|
991
|
+
};
|
|
992
|
+
const fingerprint = fingerprints.get(prepared.dataset.key);
|
|
993
|
+
const key = options.cache && fingerprint ? await cacheKey(prepared, await fingerprint) : null;
|
|
904
994
|
let result = key ? await cachedResult(key, entry) : null;
|
|
905
995
|
if (key)
|
|
906
996
|
cache = result ? "hit" : "miss";
|
|
907
997
|
if (!result) {
|
|
908
|
-
result = await
|
|
909
|
-
|
|
910
|
-
store(key,
|
|
998
|
+
result = key ? await share(inFlight, key, async (shared) => {
|
|
999
|
+
const fresh = await runQuery(prepared, ctx, shared);
|
|
1000
|
+
store(key, fresh, entry);
|
|
1001
|
+
return fresh;
|
|
1002
|
+
}, signal) : await runQuery(prepared, ctx, signal);
|
|
911
1003
|
}
|
|
912
1004
|
report({
|
|
913
1005
|
dataset,
|
|
1006
|
+
...metric !== undefined && { metric },
|
|
914
1007
|
durationMs: durationMs(),
|
|
915
1008
|
rows: result.rows.length,
|
|
916
1009
|
outcome: "success",
|
|
917
1010
|
...cache && { cache }
|
|
918
1011
|
}, ctx);
|
|
919
|
-
return
|
|
1012
|
+
return {
|
|
1013
|
+
...result,
|
|
1014
|
+
fields: resultFields(prepared.dataset, prepared.query)
|
|
1015
|
+
};
|
|
920
1016
|
} catch (error) {
|
|
921
1017
|
report({
|
|
922
1018
|
dataset,
|
|
1019
|
+
...metric !== undefined && { metric },
|
|
923
1020
|
durationMs: durationMs(),
|
|
924
1021
|
rows: 0,
|
|
925
1022
|
outcome: "error",
|
|
@@ -954,23 +1051,94 @@ function createAnalytics2(options) {
|
|
|
954
1051
|
querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets?.map(datasetOf) ?? datasets),
|
|
955
1052
|
resultSchema: (key) => resultSchemaFor(key),
|
|
956
1053
|
timezone: timezoneOf,
|
|
1054
|
+
period: periodOf,
|
|
1055
|
+
scope: scopeOf,
|
|
957
1056
|
listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
|
|
958
1057
|
};
|
|
959
1058
|
}
|
|
960
|
-
async function cacheKey(prepared) {
|
|
1059
|
+
async function cacheKey(prepared, fingerprint) {
|
|
961
1060
|
const { dataset, query, tenant, timezone, period, previous } = prepared;
|
|
962
1061
|
const days = (target) => target && [target.from, target.to];
|
|
963
1062
|
const scope = tenant === allTenants2 ? { all: true } : Array.isArray(tenant) ? [...new Set(tenant.map((id) => JSON.stringify(id)))].sort() : tenant;
|
|
964
1063
|
const text = stableJson({
|
|
1064
|
+
definition: fingerprint,
|
|
965
1065
|
query,
|
|
966
1066
|
tenant: scope,
|
|
967
1067
|
timezone,
|
|
968
1068
|
period: days(period),
|
|
969
1069
|
previous: days(previous)
|
|
970
1070
|
});
|
|
1071
|
+
return `measurr:${dataset.key}:${await sha256(text)}`;
|
|
1072
|
+
}
|
|
1073
|
+
async function sha256(text) {
|
|
971
1074
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
972
|
-
|
|
973
|
-
|
|
1075
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1076
|
+
}
|
|
1077
|
+
function semaphore(max) {
|
|
1078
|
+
if (!Number.isInteger(max) || max < 1)
|
|
1079
|
+
throw new Error(`concurrency must be a whole number of at least 1, not ${max}`);
|
|
1080
|
+
let active = 0;
|
|
1081
|
+
const queue = [];
|
|
1082
|
+
const release = () => {
|
|
1083
|
+
const next = queue.shift();
|
|
1084
|
+
if (next)
|
|
1085
|
+
next();
|
|
1086
|
+
else
|
|
1087
|
+
active--;
|
|
1088
|
+
};
|
|
1089
|
+
return async (signal) => {
|
|
1090
|
+
if (active < max) {
|
|
1091
|
+
active++;
|
|
1092
|
+
return release;
|
|
1093
|
+
}
|
|
1094
|
+
await new Promise((resolve, reject) => {
|
|
1095
|
+
const onAbort = () => {
|
|
1096
|
+
queue.splice(queue.indexOf(wake), 1);
|
|
1097
|
+
reject(signal?.reason);
|
|
1098
|
+
};
|
|
1099
|
+
const wake = () => {
|
|
1100
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1101
|
+
resolve();
|
|
1102
|
+
};
|
|
1103
|
+
queue.push(wake);
|
|
1104
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1105
|
+
});
|
|
1106
|
+
return release;
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
function share(inFlight, key, start, signal) {
|
|
1110
|
+
let entry = inFlight.get(key);
|
|
1111
|
+
if (!entry) {
|
|
1112
|
+
const controller = new AbortController;
|
|
1113
|
+
const created = {
|
|
1114
|
+
controller,
|
|
1115
|
+
waiting: 0,
|
|
1116
|
+
promise: start(controller.signal).finally(() => {
|
|
1117
|
+
if (inFlight.get(key) === created)
|
|
1118
|
+
inFlight.delete(key);
|
|
1119
|
+
})
|
|
1120
|
+
};
|
|
1121
|
+
inFlight.set(key, created);
|
|
1122
|
+
entry = created;
|
|
1123
|
+
}
|
|
1124
|
+
const shared = entry;
|
|
1125
|
+
shared.waiting++;
|
|
1126
|
+
if (!signal)
|
|
1127
|
+
return shared.promise;
|
|
1128
|
+
return new Promise((resolve, reject) => {
|
|
1129
|
+
const onAbort = () => {
|
|
1130
|
+
if (--shared.waiting === 0) {
|
|
1131
|
+
if (inFlight.get(key) === shared)
|
|
1132
|
+
inFlight.delete(key);
|
|
1133
|
+
shared.controller.abort(signal.reason);
|
|
1134
|
+
}
|
|
1135
|
+
reject(signal.reason);
|
|
1136
|
+
};
|
|
1137
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1138
|
+
shared.promise.then(resolve, reject).finally(() => {
|
|
1139
|
+
signal.removeEventListener("abort", onAbort);
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
974
1142
|
}
|
|
975
1143
|
function stableJson(value) {
|
|
976
1144
|
return JSON.stringify(value, (_key, inner) => inner && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : 1)) : inner);
|
|
@@ -983,9 +1151,10 @@ function isTenantId(value) {
|
|
|
983
1151
|
}
|
|
984
1152
|
function periodStatements(dataset, query, context) {
|
|
985
1153
|
const grouped = query.groupBy.length > 0;
|
|
1154
|
+
const resolved = query.measures.some((key) => dataset.measures[key]?.kind === "resolved");
|
|
986
1155
|
return {
|
|
987
1156
|
rows: planQuery(dataset, query, context, true),
|
|
988
|
-
totals: grouped ? planQuery(dataset, query, context, false) : null
|
|
1157
|
+
totals: grouped && (query.totals || resolved) ? planQuery(dataset, query, context, false) : null
|
|
989
1158
|
};
|
|
990
1159
|
}
|
|
991
1160
|
function resolveSource(dataset, sources, sourceNames) {
|
|
@@ -1009,11 +1178,15 @@ function compileEverything(dataset, dialect) {
|
|
|
1009
1178
|
};
|
|
1010
1179
|
const measures = Object.keys(dataset.measures);
|
|
1011
1180
|
const base = normalizeQuery(dataset, { dataset: dataset.key, measures });
|
|
1181
|
+
const texts = [];
|
|
1182
|
+
const compile = (statement) => {
|
|
1183
|
+
texts.push(dialect.compile(statement).text);
|
|
1184
|
+
};
|
|
1012
1185
|
try {
|
|
1013
|
-
|
|
1186
|
+
compile(planQuery(dataset, base, context, true));
|
|
1014
1187
|
for (const [key, dimension] of Object.entries(dataset.dimensions)) {
|
|
1015
1188
|
if (dimension.groupable) {
|
|
1016
|
-
|
|
1189
|
+
compile(planQuery(dataset, { ...base, groupBy: [key] }, context, true));
|
|
1017
1190
|
}
|
|
1018
1191
|
if (dimension.filterable) {
|
|
1019
1192
|
const filter = dimension.category === "numeric" ? { dimension: key, op: "between", from: 0, to: 1 } : dimension.category === "time" ? {
|
|
@@ -1022,13 +1195,15 @@ function compileEverything(dataset, dialect) {
|
|
|
1022
1195
|
from: "2000-01-01",
|
|
1023
1196
|
to: "2000-01-02"
|
|
1024
1197
|
} : { dimension: key, op: "in", values: ["value"] };
|
|
1025
|
-
|
|
1198
|
+
compile(planQuery(dataset, { ...base, filters: [filter] }, context, true));
|
|
1026
1199
|
}
|
|
1027
1200
|
}
|
|
1028
1201
|
} catch (error) {
|
|
1029
1202
|
const message = error instanceof Error ? error.message : String(error);
|
|
1030
1203
|
throw new Error(`Dataset ${dataset.key} does not compile for ${dialect.name}: ${message}`);
|
|
1031
1204
|
}
|
|
1205
|
+
return texts.join(`
|
|
1206
|
+
`);
|
|
1032
1207
|
}
|
|
1033
1208
|
async function resolveOpenValues(dataset, query, tenant, now, run, dialect) {
|
|
1034
1209
|
const resolved = new Map;
|
|
@@ -1123,7 +1298,7 @@ function catalogEntry(dataset) {
|
|
|
1123
1298
|
key,
|
|
1124
1299
|
label: entry.label,
|
|
1125
1300
|
description: entry.description,
|
|
1126
|
-
valueType: entry.
|
|
1301
|
+
valueType: entry.result
|
|
1127
1302
|
})),
|
|
1128
1303
|
dimensions: Object.entries(dataset.dimensions).map(([key, entry]) => ({
|
|
1129
1304
|
key,
|
|
@@ -1133,12 +1308,31 @@ function catalogEntry(dataset) {
|
|
|
1133
1308
|
category: entry.category,
|
|
1134
1309
|
groupable: entry.groupable,
|
|
1135
1310
|
filterable: entry.filterable,
|
|
1136
|
-
values:
|
|
1137
|
-
key: value,
|
|
1138
|
-
label
|
|
1139
|
-
})) : undefined
|
|
1311
|
+
values: closedValues(entry)
|
|
1140
1312
|
}))
|
|
1141
1313
|
};
|
|
1142
1314
|
}
|
|
1315
|
+
function closedValues(dimension) {
|
|
1316
|
+
return dimension.kind === "enum" || dimension.kind === "computed" ? Object.entries(dimension.values).map(([key, label]) => ({ key, label })) : undefined;
|
|
1317
|
+
}
|
|
1318
|
+
function resultFields(dataset, query) {
|
|
1319
|
+
return {
|
|
1320
|
+
groupBy: query.groupBy.map((key) => {
|
|
1321
|
+
const dimension = dataset.dimensions[key];
|
|
1322
|
+
const values = closedValues(dimension);
|
|
1323
|
+
return {
|
|
1324
|
+
key,
|
|
1325
|
+
label: dimension.label,
|
|
1326
|
+
kind: dimension.kind,
|
|
1327
|
+
...dimension.kind === "timeBucket" && { unit: dimension.unit },
|
|
1328
|
+
...values && { values }
|
|
1329
|
+
};
|
|
1330
|
+
}),
|
|
1331
|
+
measures: query.measures.map((key) => {
|
|
1332
|
+
const entry = dataset.measures[key];
|
|
1333
|
+
return { key, label: entry.label, valueType: entry.result };
|
|
1334
|
+
})
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1143
1337
|
|
|
1144
|
-
export { MAX_MEASURES, AnalyticsResultSchema2, allTenants2, AnalyticsError2, isAnalyticsError2, createAnalytics2 };
|
|
1338
|
+
export { MAX_MEASURES, periodSchema, periodOutput, AnalyticsResultSchema2, allTenants2, AnalyticsError2, isAnalyticsError2, createAnalytics2 };
|