measurr 0.4.0 → 0.6.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 +192 -13
- package/dist/analytics.d.ts +36 -0
- package/dist/handler.d.ts +56 -0
- package/dist/handler.js +11 -0
- package/dist/index-115syy01.js +149 -0
- package/dist/index-pgccd363.js +303 -0
- package/dist/index-xf6qpmgy.js +46 -0
- package/dist/{index-4rmjas9h.js → index-z5xv3ffk.js} +76 -449
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -5
- package/dist/metrics.d.ts +152 -0
- package/dist/metrics.js +167 -0
- package/dist/period.d.ts +5 -0
- package/dist/shapes.d.ts +256 -0
- package/dist/shapes.js +217 -0
- package/dist/testing.js +4 -2
- package/package.json +20 -2
- package/src/analytics.ts +159 -23
- package/src/handler.ts +124 -0
- package/src/index.ts +1 -0
- package/src/metrics.ts +402 -0
- package/src/period.ts +32 -0
- package/src/shapes.ts +570 -0
package/README.md
CHANGED
|
@@ -176,9 +176,184 @@ ${analyticsToolGuidance}`
|
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
`onQuery(event, ctx)` hears every `query` once it succeeds or fails:
|
|
179
|
-
`{ dataset, durationMs, rows, outcome: 'success' | 'error', error? }`. `dataset` is null
|
|
180
|
-
the input named no known dataset, so free text from a model never becomes a metric label.
|
|
181
|
-
What the hook throws or rejects with is
|
|
179
|
+
`{ dataset, durationMs, rows, outcome: 'success' | 'error', cache?, error? }`. `dataset` is null
|
|
180
|
+
when the input named no known dataset, so free text from a model never becomes a metric label.
|
|
181
|
+
`cache` is `'hit'` or `'miss'` when a cache is configured. What the hook throws or rejects with is
|
|
182
|
+
swallowed; it cannot fail a query.
|
|
183
|
+
|
|
184
|
+
Each measure in `listDatasets` has a `valueType`: `count` (a whole number, never null),
|
|
185
|
+
`ratio` (0-1, from `share` and `ratio`) or `number` (sums, averages, medians), so a UI knows
|
|
186
|
+
to format a share as a percentage.
|
|
187
|
+
|
|
188
|
+
## Caching
|
|
189
|
+
|
|
190
|
+
`cache: { get, set }` keeps finished results in a store of your choice, such as Redis. A query
|
|
191
|
+
is parsed and authorized first, so a cached result never reaches a caller who may not run the
|
|
192
|
+
query. The key is `measurr:<dataset>:<sha256>`, hashed from the query with its defaults, the
|
|
193
|
+
tenant scope, the timezone and the resolved days (so `last 30 days` moves on at local midnight).
|
|
194
|
+
Results are plain JSON.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const TTL_SECONDS: Record<AnalyticsDatasetKey, number> = { orders: 300, units: 60 }
|
|
198
|
+
|
|
199
|
+
createAnalytics({
|
|
200
|
+
// ...
|
|
201
|
+
cache: {
|
|
202
|
+
// A miss bypasses the cache; the fresh result is still stored.
|
|
203
|
+
get: async (key, { ctx }) =>
|
|
204
|
+
ctx.refresh ? null : JSON.parse((await redis.get(`${BUILD_ID}:${key}`)) ?? 'null'),
|
|
205
|
+
set: (key, result, { dataset }) =>
|
|
206
|
+
redis.setex(`${BUILD_ID}:${key}`, TTL_SECONDS[dataset], JSON.stringify(result))
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A stored value that is not a result of its dataset counts as a miss, and a cache that throws or
|
|
212
|
+
rejects is a miss or a skipped write, never a failed query. A changed measure keeps its key, so
|
|
213
|
+
prefix keys with a build id (as above) if old numbers must not outlive a deploy. A dataset that
|
|
214
|
+
uses `minutesAgo` is as stale as the time to live.
|
|
215
|
+
|
|
216
|
+
## Chart shapes
|
|
217
|
+
|
|
218
|
+
`measurr/shapes` turns a result into what a chart or a KPI card draws. The functions are pure
|
|
219
|
+
and add no numbers the result does not hold; with `compareToPrevious` they carry the previous
|
|
220
|
+
period too.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { toDistribution, toKpi, toPivot, toTimeseries, toLeaderboard } from 'measurr/shapes'
|
|
224
|
+
|
|
225
|
+
toKpi(result, 'orders')
|
|
226
|
+
// { kind: 'kpi', value: 12, previous: 8, change: 0.5 }
|
|
227
|
+
|
|
228
|
+
toTimeseries(result, { bucket: 'day', unit: 'day', measure: 'orders', series: 'status' })
|
|
229
|
+
// { kind: 'timeseries', unit, series: [{ key, label, points: [{ bucket: '2026-10-01', value }], previous? }] }
|
|
230
|
+
|
|
231
|
+
toDistribution(result, { by: 'status', measure: 'orders' }) // segments with share of the total
|
|
232
|
+
toPivot(result, { rows: 'weekday', columns: 'hour', measure: 'orders' }) // values[row][column]
|
|
233
|
+
toLeaderboard(result, { by: 'region', measure: 'revenue' }) // ranked rows with every measure
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `toTimeseries` gives every bucket of the period a point; buckets without rows get `fill` (0 by
|
|
237
|
+
default, pass `null` for averages). An all-time result has only the buckets it found. The
|
|
238
|
+
previous period's points are on its own buckets; compare them by position.
|
|
239
|
+
- A grouped query returns at most `limit` rows (20 by default, 100 at most), and the shape keeps
|
|
240
|
+
`truncated`. Set `limit: 100` for a series; 30 days split into three series fit, 90 days into
|
|
241
|
+
eleven do not.
|
|
242
|
+
- `toDistribution`'s `total` is the measure over every row, so many-to-many shares can add up to
|
|
243
|
+
more than 1. `toPivot` sorts numeric keys (weekdays, hours).
|
|
244
|
+
|
|
245
|
+
### Presentation
|
|
246
|
+
|
|
247
|
+
`presentKpi`, `presentTimeseries`, `presentDistribution` and `presentLeaderboard` turn a result
|
|
248
|
+
into the wire outputs a dashboard draws (`KpiOutput`, `TimeSeriesOutput`, `DistributionOutput`,
|
|
249
|
+
`LeaderboardOutput`; `TableOutput` and `PivotOutput` for outputs you build yourself). A missing
|
|
250
|
+
value is 0, keys are strings, and the caller picks labels, units and chart types:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
presentKpi(result, 'paidShare', { label: 'Paid', percent: true })
|
|
254
|
+
// { kind: 'kpi', value: 25, unit: 'PERCENTAGE', label: 'Paid' }
|
|
255
|
+
|
|
256
|
+
presentTimeseries(result, {
|
|
257
|
+
bucket: 'day', measure: 'orders', series: 'status',
|
|
258
|
+
keys: { PAID: 'paid' }, // wire keys, by String(key); 'null' names the empty group
|
|
259
|
+
labels: { paid: 'Settled' }, // labels by wire key
|
|
260
|
+
chartType: 'line'
|
|
261
|
+
})
|
|
262
|
+
// points are { ts: Date, value }: ts is the bucket's local start date as UTC midnight
|
|
263
|
+
|
|
264
|
+
presentDistribution(result, { by: 'tag', measure: 'orders', chartType: 'donut', order: ['t2'] })
|
|
265
|
+
// total is what the segments add up to, percent is 0-100; keys order omits go last
|
|
266
|
+
|
|
267
|
+
presentLeaderboard(result, { by: 'region', measure: 'orders', meta: (values) => ({ revenue: values.revenue }) })
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`isKpi`, `isTimeSeries`, `isDistribution`, `isTable`, `isLeaderboard` and `isPivot` narrow a
|
|
271
|
+
`MetricOutput`. The module is pure, so a browser bundle can import the types and guards.
|
|
272
|
+
|
|
273
|
+
## Dashboard metrics
|
|
274
|
+
|
|
275
|
+
`measurr/metrics` names the numbers a dashboard shows: one query and the output it is drawn
|
|
276
|
+
in. The registry runs a batch for a caller and streams one chunk per metric.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { createMetricRegistry, metricDefiner } from 'measurr/metrics'
|
|
280
|
+
import { presentKpi, presentTimeseries } from 'measurr/shapes'
|
|
281
|
+
|
|
282
|
+
// Types only, so metric modules need not import the analytics instance.
|
|
283
|
+
export const defineMetric = metricDefiner<(typeof datasets)[number], Role>()
|
|
284
|
+
|
|
285
|
+
const orderTrend = defineMetric({
|
|
286
|
+
key: 'orders.trend',
|
|
287
|
+
kind: 'timeseries',
|
|
288
|
+
description: 'Orders over time',
|
|
289
|
+
allowedRoles: ['owner', 'member'],
|
|
290
|
+
supportsTimeRange: true, // false for a snapshot: all time, no previous period
|
|
291
|
+
filterSchema: z.object({ status: z.enum(['OPEN', 'PAID']).optional() }),
|
|
292
|
+
query: ({ bucket, filters }) => ({
|
|
293
|
+
dataset: 'orders',
|
|
294
|
+
measures: ['orders'],
|
|
295
|
+
groupBy: [bucket],
|
|
296
|
+
filters: filters.status ? [{ dimension: 'status', op: 'in', values: [filters.status] }] : [],
|
|
297
|
+
limit: 100
|
|
298
|
+
}),
|
|
299
|
+
shape: (result, { bucket }) =>
|
|
300
|
+
presentTimeseries(result, { bucket, measure: 'orders', chartType: 'bar' })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
export const metrics = createMetricRegistry({
|
|
304
|
+
analytics,
|
|
305
|
+
metrics: [orderTrend, orderCount] as const,
|
|
306
|
+
canRun: (metric, ctx) => ctx.isSuperAdmin || (metric.allowedRoles ?? []).includes(ctx.role),
|
|
307
|
+
onShapeError: (error, metric) => logger.error('metric.shape_failed', { key: metric.key, error })
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const input = metrics.requestSchema.extend({ refresh: z.boolean().default(false) }).parse(body)
|
|
311
|
+
for await (const chunk of metrics.run(input, ctx)) send(chunk)
|
|
312
|
+
// { key, requestKey, result: { current, previous?, supportsTimeRange }, done }
|
|
313
|
+
// or { key, requestKey, error, done }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
- `query` returns the query without `period` or `compareToPrevious`. The registry counts the
|
|
317
|
+
local days `from` and `to` (instants) fall on in `analytics.timezone(ctx)`, all time without
|
|
318
|
+
them, and always all time for a snapshot. `compareToPrevious` adds the previous period, whose
|
|
319
|
+
series points are put on the current buckets so a chart overlays them.
|
|
320
|
+
- `granularity` (hour to year, inferred from the range when omitted: day up to 31 days, week up
|
|
321
|
+
to 90, else month) picks `run.bucket`: `day`, `week` or `month`. Name time-bucket dimensions
|
|
322
|
+
after them so `groupBy: [bucket]` works.
|
|
323
|
+
- A metric that `canRun` refuses (`'Access denied'`), whose filters do not parse with its
|
|
324
|
+
`filterSchema` (`'Invalid filters: ...'`), whose query fails (the engine's message) or whose
|
|
325
|
+
`shape` throws (`'The metric could not be drawn'`) fails alone. `done` marks the last chunk.
|
|
326
|
+
- `metricDefiner<Datasets, Role, Extra>()` takes the app's own definition fields as `Extra`
|
|
327
|
+
(catalog metadata, UI hints for filters); the registry carries them to `canRun` and
|
|
328
|
+
`available` without reading them.
|
|
329
|
+
- Keys and outputs stay literal: a chunk narrows to its metric by `key`, and
|
|
330
|
+
`MetricOutputOf<typeof metrics, 'orders.trend'>`, `MetricFiltersOf`, `MetricKeysOfKind` and
|
|
331
|
+
`registry.get(key)` are typed per metric. `registry.available(ctx)` lists what `canRun`
|
|
332
|
+
allows, for a catalog.
|
|
333
|
+
|
|
334
|
+
## Serving queries
|
|
335
|
+
|
|
336
|
+
`measurr/handler` serves queries from any server framework:
|
|
337
|
+
|
|
338
|
+
- `queryBatch(analytics, { total: q1, byStatus: q2 }, ctx)` runs named queries in parallel and
|
|
339
|
+
resolves to `{ results, errors }`, typed per name. One failing query does not fail the
|
|
340
|
+
others; an error other than `AnalyticsError` is reported as `{ code: 'internal' }` without
|
|
341
|
+
its message.
|
|
342
|
+
- `streamBatch(analytics, queries, ctx)` yields `{ key, result }` or `{ key, error }` as each
|
|
343
|
+
query finishes, for a streaming endpoint.
|
|
344
|
+
- `toHttpError(error)` maps an `AnalyticsError` to `{ status, code, message, data? }`:
|
|
345
|
+
`forbidden` is 403 `FORBIDDEN`, `missing_tenant` 401 `UNAUTHORIZED`, the rest 400
|
|
346
|
+
`BAD_REQUEST` with the code and details. The codes are the names oRPC and tRPC use:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
try {
|
|
350
|
+
return await analytics.query(input, ctx)
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (!(error instanceof AnalyticsError)) throw error
|
|
353
|
+
const { code, message, data } = toHttpError(error)
|
|
354
|
+
throw new ORPCError(code, { message, data })
|
|
355
|
+
}
|
|
356
|
+
```
|
|
182
357
|
|
|
183
358
|
## Tenants and platform admins
|
|
184
359
|
|
|
@@ -420,16 +595,20 @@ await assertDatasetContract({ dataset: maintenance, source, tenant: allTenants }
|
|
|
420
595
|
|
|
421
596
|
## Stable API
|
|
422
597
|
|
|
423
|
-
Kept stable for publishing: `createAnalytics` and its options (with `authorize
|
|
424
|
-
`authorizeField`), `allTenants`, `analytics.query`, `explain`, `querySchema`,
|
|
425
|
-
`listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`,
|
|
426
|
-
`defineDataset` (with `defaultPeriod` and `tenantColumn: null`),
|
|
427
|
-
(with `access`), `table` / `Table`, the expression builders; the
|
|
428
|
-
`SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`,
|
|
429
|
-
`assertDatasetContract`;
|
|
430
|
-
`
|
|
431
|
-
|
|
432
|
-
|
|
598
|
+
Kept stable for publishing: `createAnalytics` and its options (with `authorize`,
|
|
599
|
+
`authorizeField` and `cache`), `allTenants`, `analytics.query`, `explain`, `querySchema`,
|
|
600
|
+
`resultSchema`, `listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`,
|
|
601
|
+
`AnalyticsQueryEvent`; `defineDataset` (with `defaultPeriod` and `tenantColumn: null`),
|
|
602
|
+
`measure.*` and `dimension.*` (with `access`), `table` / `Table`, the expression builders; the
|
|
603
|
+
`Dialect`, `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`,
|
|
604
|
+
`checkDatasetContract` / `assertDatasetContract`; `measurr/shapes` (`toKpi`, `toTimeseries`,
|
|
605
|
+
`toDistribution`, `toPivot`, `toLeaderboard`) and `measurr/handler` (`queryBatch`,
|
|
606
|
+
`streamBatch`, `toHttpError`); and the types `AnalyticsQuery`, `AnalyticsResult`,
|
|
607
|
+
`ResultPeriod`, `PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `AnalyticsCache`, `FieldKey`,
|
|
608
|
+
`Ratio`, `AnalyticsError` codes and their details (`isAnalyticsError`). `ExprNode` and
|
|
609
|
+
`SelectStatement` are exported for dialect authors and may grow new node kinds. The
|
|
610
|
+
presenters and wire outputs in `measurr/shapes`, `measurr/metrics` and `analytics.timezone`
|
|
611
|
+
are new and may still change before they join this list.
|
|
433
612
|
|
|
434
613
|
## Install
|
|
435
614
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type AnalyticsQueryEvent = {
|
|
|
50
50
|
/** Rows returned; 0 on error. */
|
|
51
51
|
rows: number;
|
|
52
52
|
outcome: 'success' | 'error';
|
|
53
|
+
/** Whether `cache` answered the query; absent without a cache or when it never got that far. */
|
|
54
|
+
cache?: 'hit' | 'miss';
|
|
53
55
|
/** What the query threw, on error. */
|
|
54
56
|
error?: unknown;
|
|
55
57
|
};
|
|
@@ -90,6 +92,33 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
90
92
|
* throws or rejects with is swallowed, so it can never fail a query.
|
|
91
93
|
*/
|
|
92
94
|
onQuery?: ((event: AnalyticsQueryEvent, ctx: Ctx) => unknown) | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Where finished results are kept and looked up. What it throws or rejects with is
|
|
97
|
+
* swallowed: a failing cache is a miss, never a failed query.
|
|
98
|
+
*/
|
|
99
|
+
cache?: AnalyticsCache<Ctx, DS[number]['key']> | undefined;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* A store for finished results, such as Redis. Keys name the dataset and hash everything that
|
|
103
|
+
* decides the result: the query with its defaults, the tenant scope, the timezone and the
|
|
104
|
+
* resolved days. A query is parsed and authorized before the cache is asked, so a cached result
|
|
105
|
+
* never reaches a caller who may not run the query. Results are plain JSON.
|
|
106
|
+
*/
|
|
107
|
+
export type AnalyticsCache<Ctx, Key extends string = string> = {
|
|
108
|
+
/**
|
|
109
|
+
* The stored result, or null/undefined for a miss. Return a miss to bypass the cache for a
|
|
110
|
+
* caller (a refresh button); its fresh result is still stored. A stored value that is not a
|
|
111
|
+
* result of this dataset counts as a miss, so a changed definition does not break queries.
|
|
112
|
+
*/
|
|
113
|
+
get(key: string, entry: {
|
|
114
|
+
dataset: Key;
|
|
115
|
+
ctx: Ctx;
|
|
116
|
+
}): unknown;
|
|
117
|
+
/** Stores a result; pick the time to live per `dataset`. */
|
|
118
|
+
set(key: string, result: QueryResult<Row, Row>, entry: {
|
|
119
|
+
dataset: Key;
|
|
120
|
+
ctx: Ctx;
|
|
121
|
+
}): unknown;
|
|
93
122
|
};
|
|
94
123
|
export type DatasetCatalogEntry<Key extends string = string> = {
|
|
95
124
|
key: Key;
|
|
@@ -103,6 +132,8 @@ export type DatasetCatalogEntry<Key extends string = string> = {
|
|
|
103
132
|
key: string;
|
|
104
133
|
label: string;
|
|
105
134
|
description: string | undefined;
|
|
135
|
+
/** `count`: a whole number, never null. `ratio`: 0-1 (`share`, `ratio`). `number`: the rest. */
|
|
136
|
+
valueType: 'count' | 'number' | 'ratio';
|
|
106
137
|
}[];
|
|
107
138
|
dimensions: {
|
|
108
139
|
key: string;
|
|
@@ -143,6 +174,11 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
143
174
|
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
|
|
144
175
|
/** The result schema for one dataset. */
|
|
145
176
|
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
|
|
177
|
+
/**
|
|
178
|
+
* The timezone `ctx`'s periods and buckets resolve in: the `timezone` hook's answer, UTC
|
|
179
|
+
* when it has none. Rejects with `invalid_timezone` for a name the runtime does not know.
|
|
180
|
+
*/
|
|
181
|
+
timezone(ctx: Ctx): Promise<string>;
|
|
146
182
|
/** The datasets `ctx` may query, with the measures and dimensions it may use. */
|
|
147
183
|
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
|
|
148
184
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving queries from any server framework: several queries in one request (all at once or
|
|
3
|
+
* as each finishes), and engine errors as HTTP errors. Framework-neutral; an oRPC or tRPC
|
|
4
|
+
* procedure wraps these in a few lines.
|
|
5
|
+
*/
|
|
6
|
+
import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails } from './analytics.js';
|
|
7
|
+
import type { AnyDataset } from './dataset.js';
|
|
8
|
+
import type { DatasetQuery, ResultOf } from './query.js';
|
|
9
|
+
/** Why one query of a batch failed. Errors other than `AnalyticsError` are not described. */
|
|
10
|
+
export type BatchError = {
|
|
11
|
+
code: AnalyticsErrorCode | 'internal';
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
export type BatchResult<DS extends readonly AnyDataset[], Q> = {
|
|
15
|
+
results: {
|
|
16
|
+
[K in keyof Q]?: ResultOf<DS[number], Q[K]>;
|
|
17
|
+
};
|
|
18
|
+
errors: {
|
|
19
|
+
[K in keyof Q]?: BatchError;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export type BatchChunk<DS extends readonly AnyDataset[], Q> = {
|
|
23
|
+
[K in keyof Q & string]: {
|
|
24
|
+
key: K;
|
|
25
|
+
result: ResultOf<DS[number], Q[K]>;
|
|
26
|
+
} | {
|
|
27
|
+
key: K;
|
|
28
|
+
error: BatchError;
|
|
29
|
+
};
|
|
30
|
+
}[keyof Q & string];
|
|
31
|
+
type Queries<DS extends readonly AnyDataset[]> = Record<string, DatasetQuery<DS[number]>>;
|
|
32
|
+
/**
|
|
33
|
+
* Runs named queries in parallel. One query failing does not fail the others: its error is
|
|
34
|
+
* under its name. Each query is authorized, cached and reported like a single `query`.
|
|
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
|
+
/** Runs named queries in parallel and yields each as it finishes, for a streaming endpoint. */
|
|
38
|
+
export declare function streamBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): AsyncGenerator<BatchChunk<DS, Q>, void, undefined>;
|
|
39
|
+
export type HttpError = {
|
|
40
|
+
status: 400 | 401 | 403;
|
|
41
|
+
/** The status name oRPC and tRPC use for it. */
|
|
42
|
+
code: 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN';
|
|
43
|
+
message: string;
|
|
44
|
+
/** For a bad request: the engine's code and details, so a model can fix its query. */
|
|
45
|
+
data?: {
|
|
46
|
+
code: AnalyticsErrorCode;
|
|
47
|
+
details: AnalyticsErrorDetails[AnalyticsErrorCode];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* An engine error as an HTTP error: `forbidden` is 403, `missing_tenant` 401, the rest are
|
|
52
|
+
* 400 with the code and details. Messages are safe to show; for `unknown_value` they list the
|
|
53
|
+
* valid values.
|
|
54
|
+
*/
|
|
55
|
+
export declare function toHttpError(error: AnalyticsError): HttpError;
|
|
56
|
+
export {};
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/period.ts
|
|
2
|
+
var MAX_PERIOD_DAYS = 731;
|
|
3
|
+
var DEFAULT_PERIOD = {
|
|
4
|
+
last: { days: 30 }
|
|
5
|
+
};
|
|
6
|
+
var DAY_MS = 86400000;
|
|
7
|
+
var formatterCache = new Map;
|
|
8
|
+
function formatter(timezone) {
|
|
9
|
+
let cached = formatterCache.get(timezone);
|
|
10
|
+
if (!cached) {
|
|
11
|
+
cached = new Intl.DateTimeFormat("en-US", {
|
|
12
|
+
timeZone: timezone,
|
|
13
|
+
hourCycle: "h23",
|
|
14
|
+
year: "numeric",
|
|
15
|
+
month: "2-digit",
|
|
16
|
+
day: "2-digit",
|
|
17
|
+
hour: "2-digit",
|
|
18
|
+
minute: "2-digit",
|
|
19
|
+
second: "2-digit"
|
|
20
|
+
});
|
|
21
|
+
formatterCache.set(timezone, cached);
|
|
22
|
+
}
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
25
|
+
function isValidTimezone(timezone) {
|
|
26
|
+
if (!/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(timezone))
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
formatter(timezone);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function localParts(instant, timezone) {
|
|
36
|
+
const parts = {};
|
|
37
|
+
for (const part of formatter(timezone).formatToParts(instant)) {
|
|
38
|
+
if (part.type !== "literal")
|
|
39
|
+
parts[part.type] = Number(part.value);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
year: parts.year ?? 0,
|
|
43
|
+
month: parts.month ?? 1,
|
|
44
|
+
day: parts.day ?? 1,
|
|
45
|
+
hour: parts.hour ?? 0,
|
|
46
|
+
minute: parts.minute ?? 0,
|
|
47
|
+
second: parts.second ?? 0
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function offsetAt(instant, timezone) {
|
|
51
|
+
const p = localParts(instant, timezone);
|
|
52
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
|
53
|
+
return asUtc - (instant.getTime() - instant.getUTCMilliseconds());
|
|
54
|
+
}
|
|
55
|
+
function localDate(instant, timezone) {
|
|
56
|
+
const p = localParts(instant, timezone);
|
|
57
|
+
return formatDate(Date.UTC(p.year, p.month - 1, p.day));
|
|
58
|
+
}
|
|
59
|
+
function startOfLocalDay(date, timezone) {
|
|
60
|
+
const wallClock = parseDate(date);
|
|
61
|
+
const firstGuess = wallClock - offsetAt(new Date(wallClock), timezone);
|
|
62
|
+
const corrected = wallClock - offsetAt(new Date(firstGuess), timezone);
|
|
63
|
+
return new Date(corrected);
|
|
64
|
+
}
|
|
65
|
+
function parseDate(date) {
|
|
66
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
67
|
+
return Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1);
|
|
68
|
+
}
|
|
69
|
+
function formatDate(utcMs) {
|
|
70
|
+
return new Date(utcMs).toISOString().slice(0, 10);
|
|
71
|
+
}
|
|
72
|
+
function addDays(date, days) {
|
|
73
|
+
return formatDate(parseDate(date) + days * DAY_MS);
|
|
74
|
+
}
|
|
75
|
+
function daysBetween(from, to) {
|
|
76
|
+
return Math.round((parseDate(to) - parseDate(from)) / DAY_MS);
|
|
77
|
+
}
|
|
78
|
+
function isoWeekday(date) {
|
|
79
|
+
const day = new Date(parseDate(date)).getUTCDay();
|
|
80
|
+
return day === 0 ? 7 : day;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class PeriodError extends Error {
|
|
84
|
+
name = "PeriodError";
|
|
85
|
+
}
|
|
86
|
+
function resolvePeriod(input, timezone, now) {
|
|
87
|
+
const today = localDate(now, timezone);
|
|
88
|
+
const [from, to] = periodDays(input, today);
|
|
89
|
+
if (daysBetween(from, to) < 0) {
|
|
90
|
+
throw new PeriodError(`The period starts (${from}) after it ends (${to}).`);
|
|
91
|
+
}
|
|
92
|
+
return periodFromDays(from, to, timezone);
|
|
93
|
+
}
|
|
94
|
+
function periodDays(input, today) {
|
|
95
|
+
if ("last" in input)
|
|
96
|
+
return [addDays(today, -(input.last.days - 1)), today];
|
|
97
|
+
if ("from" in input)
|
|
98
|
+
return [input.from, input.to];
|
|
99
|
+
const monthStart = `${today.slice(0, 8)}01`;
|
|
100
|
+
const weekStart = addDays(today, -(isoWeekday(today) - 1));
|
|
101
|
+
switch (input.preset) {
|
|
102
|
+
case "today":
|
|
103
|
+
return [today, today];
|
|
104
|
+
case "yesterday":
|
|
105
|
+
return [addDays(today, -1), addDays(today, -1)];
|
|
106
|
+
case "thisWeek":
|
|
107
|
+
return [weekStart, today];
|
|
108
|
+
case "lastWeek":
|
|
109
|
+
return [addDays(weekStart, -7), addDays(weekStart, -1)];
|
|
110
|
+
case "thisMonth":
|
|
111
|
+
return [monthStart, today];
|
|
112
|
+
case "lastMonth": {
|
|
113
|
+
const lastMonthEnd = addDays(monthStart, -1);
|
|
114
|
+
return [`${lastMonthEnd.slice(0, 8)}01`, lastMonthEnd];
|
|
115
|
+
}
|
|
116
|
+
case "thisYear":
|
|
117
|
+
return [`${today.slice(0, 4)}-01-01`, today];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function periodFromDays(from, to, timezone) {
|
|
121
|
+
const days = daysBetween(from, to) + 1;
|
|
122
|
+
if (days > MAX_PERIOD_DAYS) {
|
|
123
|
+
throw new PeriodError(`A period may cover at most ${MAX_PERIOD_DAYS} days (two years); ${from} to ${to} is ${days} days.`);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
from,
|
|
127
|
+
to,
|
|
128
|
+
timezone,
|
|
129
|
+
start: startOfLocalDay(from, timezone),
|
|
130
|
+
end: startOfLocalDay(addDays(to, 1), timezone),
|
|
131
|
+
days
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function previousPeriod(period) {
|
|
135
|
+
const to = addDays(period.from, -1);
|
|
136
|
+
const from = addDays(to, -(period.days - 1));
|
|
137
|
+
return periodFromDays(from, to, period.timezone);
|
|
138
|
+
}
|
|
139
|
+
function bucketStarts(from, to, unit) {
|
|
140
|
+
let start = unit === "day" ? from : unit === "week" ? addDays(from, -(isoWeekday(from) - 1)) : `${from.slice(0, 8)}01`;
|
|
141
|
+
const starts = [];
|
|
142
|
+
while (start <= to) {
|
|
143
|
+
starts.push(start);
|
|
144
|
+
start = unit === "day" ? addDays(start, 1) : unit === "week" ? addDays(start, 7) : formatDate(Date.UTC(Number(start.slice(0, 4)), Number(start.slice(5, 7)), 1));
|
|
145
|
+
}
|
|
146
|
+
return starts;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { MAX_PERIOD_DAYS, DEFAULT_PERIOD, isValidTimezone, localDate, PeriodError, resolvePeriod, previousPeriod, bucketStarts };
|