@ak--47/dungeon-master 1.7.0 → 1.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/.claude/skills/analyze-soup/SKILL.md +21 -11
- package/.claude/skills/create-dungeon/SKILL.md +49 -10
- package/.claude/skills/create-project/SKILL.md +22 -3
- package/.claude/skills/create-project/context.mjs +89 -0
- package/.claude/skills/create-project/provision.mjs +1 -60
- package/.claude/skills/headless-build/SKILL.md +18 -1
- package/.claude/skills/powertools/SKILL.md +20 -1
- package/.claude/skills/release-check/SKILL.md +99 -0
- package/.claude/skills/verify-dungeon/SKILL.md +71 -16
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
- package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
- package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
- package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
- package/.claude/skills/write-hooks/SKILL.md +33 -3
- package/CHANGELOG.md +142 -0
- package/HOOKS.md +105 -5
- package/README.md +228 -0
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/dungeons/technical/warehouse.js +187 -0
- package/index.js +116 -2
- package/lib/core/config-validator.js +21 -0
- package/lib/core/dungeon-loader.js +1 -1
- package/lib/core/storage.js +51 -3
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/orchestrators/mixpanel-sender.js +27 -2
- package/lib/orchestrators/user-loop.js +1 -0
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/utils.js +37 -12
- package/lib/verify/index.js +1 -0
- package/lib/verify/story-runner.js +71 -8
- package/lib/verify/warehouse.js +683 -0
- package/package.json +4 -2
- package/scripts/verify-stories.mjs +150 -44
- package/types.d.ts +303 -4
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/** @typedef {import('../../types').Dungeon} Dungeon */
|
|
2
|
+
/** @typedef {import('../../types').WarehouseMetricConfig} WarehouseMetricConfig */
|
|
3
|
+
/** @typedef {import('../../types').ResolvedWarehouseMetricConfig} ResolvedWarehouseMetricConfig */
|
|
4
|
+
|
|
5
|
+
export const VALID_WAREHOUSE_TYPES = Object.freeze(['additive', 'point-in-time']);
|
|
6
|
+
export const VALID_WAREHOUSE_GRAINS = Object.freeze(['day', 'week', 'month']);
|
|
7
|
+
export const VALID_WAREHOUSE_MEASURES = Object.freeze(['count', 'sum', 'avg', 'dau', 'users']);
|
|
8
|
+
export const VALID_WAREHOUSE_FORMATS = Object.freeze(['csv', 'json']);
|
|
9
|
+
|
|
10
|
+
const METRIC_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
11
|
+
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
|
+
const HISTORY_WARN_BY_GRAIN = Object.freeze({ day: 1095, week: 156, month: 36 });
|
|
13
|
+
const DAY_SECONDS = 86400;
|
|
14
|
+
|
|
15
|
+
export function bucketStart(unixSec, grain) {
|
|
16
|
+
if (!Number.isFinite(unixSec)) throw new Error('bucketStart requires a finite unixSec');
|
|
17
|
+
|
|
18
|
+
switch (grain) {
|
|
19
|
+
case 'day': {
|
|
20
|
+
return Math.floor(unixSec / DAY_SECONDS) * DAY_SECONDS;
|
|
21
|
+
}
|
|
22
|
+
case 'week': {
|
|
23
|
+
const dayIndex = Math.floor(unixSec / DAY_SECONDS);
|
|
24
|
+
const weekStartIndex = dayIndex - ((dayIndex + 3) % 7);
|
|
25
|
+
return weekStartIndex * DAY_SECONDS;
|
|
26
|
+
}
|
|
27
|
+
case 'month': {
|
|
28
|
+
const date = new Date(unixSec * 1000);
|
|
29
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1) / 1000;
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Unsupported warehouse grain "${grain}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function nextBucket(startSec, grain) {
|
|
37
|
+
if (!Number.isFinite(startSec)) throw new Error('nextBucket requires a finite startSec');
|
|
38
|
+
|
|
39
|
+
switch (grain) {
|
|
40
|
+
case 'day':
|
|
41
|
+
return startSec + DAY_SECONDS;
|
|
42
|
+
case 'week':
|
|
43
|
+
return startSec + (7 * DAY_SECONDS);
|
|
44
|
+
case 'month': {
|
|
45
|
+
const date = new Date(startSec * 1000);
|
|
46
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) / 1000;
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
throw new Error(`Unsupported warehouse grain "${grain}"`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildBuckets(beginSec, endSec, grain, history = 0) {
|
|
54
|
+
if (!Number.isFinite(beginSec) || !Number.isFinite(endSec)) {
|
|
55
|
+
throw new Error('buildBuckets requires finite beginSec and endSec');
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(history) || history < 0) {
|
|
58
|
+
throw new Error('buildBuckets history must be an integer >= 0');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const windowStart = bucketStart(beginSec, grain);
|
|
62
|
+
const windowEnd = bucketStart(endSec, grain);
|
|
63
|
+
if (windowEnd < windowStart) return [];
|
|
64
|
+
|
|
65
|
+
const buckets = [];
|
|
66
|
+
for (let cursor = windowStart; cursor <= windowEnd; cursor = nextBucket(cursor, grain)) {
|
|
67
|
+
buckets.push(cursor);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let prepend = windowStart;
|
|
71
|
+
for (let index = 0; index < history; index += 1) {
|
|
72
|
+
prepend = previousBucket(prepend, grain);
|
|
73
|
+
buckets.unshift(prepend);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return buckets;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class WarehouseAccumulator {
|
|
80
|
+
constructor(specs, { FIXED_BEGIN, FIXED_NOW }) {
|
|
81
|
+
if (!Array.isArray(specs)) throw new Error('WarehouseAccumulator specs must be an array');
|
|
82
|
+
if (!Number.isFinite(FIXED_BEGIN) || !Number.isFinite(FIXED_NOW)) {
|
|
83
|
+
throw new Error('WarehouseAccumulator requires finite FIXED_BEGIN and FIXED_NOW');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.FIXED_BEGIN = FIXED_BEGIN;
|
|
87
|
+
this.FIXED_NOW = FIXED_NOW;
|
|
88
|
+
this.warnings = [];
|
|
89
|
+
this.warnedMetrics = new Set();
|
|
90
|
+
this.warnedGroupByKeys = new Set();
|
|
91
|
+
this.metrics = new Map();
|
|
92
|
+
this.eventIndex = new Map();
|
|
93
|
+
|
|
94
|
+
for (const spec of specs) {
|
|
95
|
+
const state = {
|
|
96
|
+
spec,
|
|
97
|
+
series: new Map(),
|
|
98
|
+
seriesValues: new Map(),
|
|
99
|
+
};
|
|
100
|
+
this.metrics.set(spec.name, state);
|
|
101
|
+
|
|
102
|
+
for (const eventName of spec.source.event || []) {
|
|
103
|
+
this.addEventIndex(eventName, spec.name, 'plus');
|
|
104
|
+
}
|
|
105
|
+
for (const eventName of spec.source.minus || []) {
|
|
106
|
+
this.addEventIndex(eventName, spec.name, 'minus');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
addEventIndex(eventName, metricName, leg) {
|
|
112
|
+
const refs = this.eventIndex.get(eventName) || [];
|
|
113
|
+
refs.push({ metricName, leg });
|
|
114
|
+
this.eventIndex.set(eventName, refs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ingest(events) {
|
|
118
|
+
for (const event of events || []) {
|
|
119
|
+
const refs = this.eventIndex.get(event?.event);
|
|
120
|
+
if (!refs || refs.length === 0) continue;
|
|
121
|
+
|
|
122
|
+
const unixSec = Date.parse(event.time) / 1000;
|
|
123
|
+
if (!Number.isFinite(unixSec)) continue;
|
|
124
|
+
if (unixSec < this.FIXED_BEGIN || unixSec > this.FIXED_NOW) continue;
|
|
125
|
+
|
|
126
|
+
for (const ref of refs) {
|
|
127
|
+
const metric = this.metrics.get(ref.metricName);
|
|
128
|
+
if (!metric) continue;
|
|
129
|
+
const { spec } = metric;
|
|
130
|
+
if (spec.source.where && !spec.source.where(event)) continue;
|
|
131
|
+
this.observeGroupByValues(spec, event);
|
|
132
|
+
|
|
133
|
+
const { key: seriesKey, values: seriesValues } = buildSeriesRef(spec, event);
|
|
134
|
+
observeSeriesValues(metric, seriesKey, seriesValues);
|
|
135
|
+
const bucketSec = bucketStart(unixSec, spec.grain);
|
|
136
|
+
const cell = this.getOrCreateCell(metric, seriesKey, bucketSec);
|
|
137
|
+
updateCell({
|
|
138
|
+
cell,
|
|
139
|
+
event,
|
|
140
|
+
spec,
|
|
141
|
+
leg: ref.leg,
|
|
142
|
+
unixSec,
|
|
143
|
+
warn: (message) => this.warnOnce(spec.name, message),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getCell(metricName, seriesKey, bucketStartSec) {
|
|
150
|
+
const metric = this.metrics.get(metricName);
|
|
151
|
+
if (!metric) throw new Error(`Unknown warehouse metric "${metricName}"`);
|
|
152
|
+
|
|
153
|
+
const key = seriesKey ?? '';
|
|
154
|
+
const bucketMap = metric.series.get(key);
|
|
155
|
+
if (bucketMap?.has(bucketStartSec)) return bucketMap.get(bucketStartSec);
|
|
156
|
+
return createEmptyCell(metric.spec.source.measure);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getOrCreateCell(metric, seriesKey, bucketStartSec) {
|
|
160
|
+
const key = seriesKey ?? '';
|
|
161
|
+
let bucketMap = metric.series.get(key);
|
|
162
|
+
if (!bucketMap) {
|
|
163
|
+
bucketMap = new Map();
|
|
164
|
+
metric.series.set(key, bucketMap);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let cell = bucketMap.get(bucketStartSec);
|
|
168
|
+
if (!cell) {
|
|
169
|
+
cell = createEmptyCell(metric.spec.source.measure);
|
|
170
|
+
bucketMap.set(bucketStartSec, cell);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return cell;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
observeGroupByValues(spec, event) {
|
|
177
|
+
for (const key of spec.source.groupBy || []) {
|
|
178
|
+
const stateKey = `${spec.name}:${key}`;
|
|
179
|
+
let values = this.observedGroupByValues?.get(stateKey);
|
|
180
|
+
if (!values) {
|
|
181
|
+
if (!this.observedGroupByValues) this.observedGroupByValues = new Map();
|
|
182
|
+
values = new Set();
|
|
183
|
+
this.observedGroupByValues.set(stateKey, values);
|
|
184
|
+
}
|
|
185
|
+
values.add(String(event?.[key] ?? ''));
|
|
186
|
+
if (values.size > 50 && !this.warnedGroupByKeys.has(stateKey)) {
|
|
187
|
+
this.warnedGroupByKeys.add(stateKey);
|
|
188
|
+
this.warnings.push(`warehouse metric "${spec.name}" source.groupBy "${key}" has ${values.size} observed distinct values (> 50)`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
warnOnce(metricName, message) {
|
|
194
|
+
if (this.warnedMetrics.has(metricName)) return;
|
|
195
|
+
this.warnedMetrics.add(metricName);
|
|
196
|
+
this.warnings.push(message);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validates and normalizes the top-level `warehouseMetrics` config surface.
|
|
202
|
+
* Throws on malformed entries; warnings cover lossy normalization only.
|
|
203
|
+
*
|
|
204
|
+
* @param {Dungeon & { warehouseMetrics?: WarehouseMetricConfig[] | null, format?: string }} config
|
|
205
|
+
* @returns {{ warehouseMetrics: ResolvedWarehouseMetricConfig[], warnings: string[] }}
|
|
206
|
+
*/
|
|
207
|
+
export function validateWarehouseMetrics(config) {
|
|
208
|
+
const specs = config?.warehouseMetrics;
|
|
209
|
+
if (specs === undefined || specs === null) return { warehouseMetrics: [], warnings: [] };
|
|
210
|
+
if (!Array.isArray(specs)) throw new Error('warehouseMetrics must be an array');
|
|
211
|
+
|
|
212
|
+
const warnings = [];
|
|
213
|
+
const seenNames = new Set();
|
|
214
|
+
const eventMap = new Map((config?.events || []).map((event) => [event.event, event]));
|
|
215
|
+
const superProps = config?.superProps && typeof config.superProps === 'object' ? config.superProps : {};
|
|
216
|
+
|
|
217
|
+
const warehouseMetrics = specs.map((spec, index) => {
|
|
218
|
+
const label = `warehouseMetrics[${index}]`;
|
|
219
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
220
|
+
throw new Error(`${label} must be an object`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const name = spec.name;
|
|
224
|
+
if (typeof name !== 'string' || !METRIC_NAME_RE.test(name)) {
|
|
225
|
+
throw new Error(`${label}.name must match ${METRIC_NAME_RE}`);
|
|
226
|
+
}
|
|
227
|
+
if (seenNames.has(name)) {
|
|
228
|
+
throw new Error(`${label}.name "${name}" must be unique across warehouseMetrics`);
|
|
229
|
+
}
|
|
230
|
+
seenNames.add(name);
|
|
231
|
+
|
|
232
|
+
const type = spec.type ?? 'additive';
|
|
233
|
+
if (!VALID_WAREHOUSE_TYPES.includes(type)) {
|
|
234
|
+
throw new Error(`${label}.type must be one of ${VALID_WAREHOUSE_TYPES.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const grain = spec.grain ?? 'day';
|
|
238
|
+
if (!VALID_WAREHOUSE_GRAINS.includes(grain)) {
|
|
239
|
+
throw new Error(`${label}.grain must be one of ${VALID_WAREHOUSE_GRAINS.join(', ')}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const sparse = spec.sparse ?? false;
|
|
243
|
+
if (typeof sparse !== 'boolean') {
|
|
244
|
+
throw new Error(`${label}.sparse must be a boolean`);
|
|
245
|
+
}
|
|
246
|
+
if (sparse && type !== 'point-in-time') {
|
|
247
|
+
throw new Error(`${label}.sparse is only valid with type "point-in-time"`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const source = spec.source;
|
|
251
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
252
|
+
throw new Error(`${label}.source must be an object`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const plusEvents = normalizeStringArray(source.event, `${label}.source.event`);
|
|
256
|
+
if (plusEvents.length === 0) {
|
|
257
|
+
throw new Error(`${label}.source.event must list at least one event`);
|
|
258
|
+
}
|
|
259
|
+
const minusEvents = normalizeOptionalStringArray(source.minus, `${label}.source.minus`);
|
|
260
|
+
const sourceEvents = [...plusEvents, ...minusEvents];
|
|
261
|
+
assertKnownEvents(sourceEvents, eventMap, label);
|
|
262
|
+
|
|
263
|
+
const measure = source.measure ?? 'count';
|
|
264
|
+
if (!VALID_WAREHOUSE_MEASURES.includes(measure)) {
|
|
265
|
+
throw new Error(`${label}.source.measure must be one of ${VALID_WAREHOUSE_MEASURES.join(', ')}`);
|
|
266
|
+
}
|
|
267
|
+
if (type === 'point-in-time' && (measure === 'avg' || measure === 'dau')) {
|
|
268
|
+
throw new Error(`${label}.source.measure "${measure}" is not valid for point-in-time metrics`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const property = source.property ?? null;
|
|
272
|
+
if ((measure === 'sum' || measure === 'avg') && typeof property !== 'string') {
|
|
273
|
+
throw new Error(`${label}.source.property is required when measure is "${measure}"`);
|
|
274
|
+
}
|
|
275
|
+
if (property !== null && typeof property !== 'string') {
|
|
276
|
+
throw new Error(`${label}.source.property must be a string`);
|
|
277
|
+
}
|
|
278
|
+
if (property && !isDeclaredForAllSources(property, sourceEvents, eventMap, superProps)) {
|
|
279
|
+
throw new Error(`${label}.source.property "${property}" must be declared on every source event or in superProps`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const groupBy = normalizeOptionalStringArray(source.groupBy, `${label}.source.groupBy`);
|
|
283
|
+
if (groupBy.length > 2) {
|
|
284
|
+
throw new Error(`${label}.source.groupBy may contain at most 2 keys`);
|
|
285
|
+
}
|
|
286
|
+
for (const key of groupBy) {
|
|
287
|
+
assertIdentifier(key, `${label}.source.groupBy`);
|
|
288
|
+
if (!isDeclaredForAllSources(key, sourceEvents, eventMap, superProps)) {
|
|
289
|
+
throw new Error(`${label}.source.groupBy "${key}" must be declared on every source event or in superProps`);
|
|
290
|
+
}
|
|
291
|
+
const distinctCount = countObservedDistinctValues(key, sourceEvents, eventMap, superProps);
|
|
292
|
+
if (distinctCount > 50) {
|
|
293
|
+
warnings.push(`${label}.source.groupBy "${key}" has ${distinctCount} observed distinct values (> 50)`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const history = spec.history ?? 0;
|
|
298
|
+
if (!Number.isInteger(history) || history < 0) {
|
|
299
|
+
throw new Error(`${label}.history must be an integer >= 0`);
|
|
300
|
+
}
|
|
301
|
+
if (history > HISTORY_WARN_BY_GRAIN[grain]) {
|
|
302
|
+
warnings.push(`${label}.history exceeds the recommended ~3 year limit for ${grain} grain`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let baseline = spec.baseline ?? 0;
|
|
306
|
+
if (baseline !== undefined && baseline !== null && (!Number.isFinite(baseline) || baseline < 0)) {
|
|
307
|
+
throw new Error(`${label}.baseline must be a number >= 0`);
|
|
308
|
+
}
|
|
309
|
+
if (type !== 'point-in-time' && baseline !== 0) {
|
|
310
|
+
warnings.push(`${label}.baseline is ignored for additive metrics`);
|
|
311
|
+
baseline = 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const scale = spec.scale ?? 1;
|
|
315
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
316
|
+
throw new Error(`${label}.scale must be a number > 0`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let noise = spec.noise ?? 0;
|
|
320
|
+
if (!Number.isFinite(noise)) {
|
|
321
|
+
throw new Error(`${label}.noise must be a finite number`);
|
|
322
|
+
}
|
|
323
|
+
if (noise < 0 || noise > 0.5) {
|
|
324
|
+
const clamped = Math.min(0.5, Math.max(0, noise));
|
|
325
|
+
warnings.push(`${label}.noise was clamped to ${clamped}`);
|
|
326
|
+
noise = clamped;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const timeColumn = spec.timeColumn ?? 'date';
|
|
330
|
+
const valueColumn = spec.valueColumn ?? 'value';
|
|
331
|
+
assertIdentifier(timeColumn, `${label}.timeColumn`);
|
|
332
|
+
assertIdentifier(valueColumn, `${label}.valueColumn`);
|
|
333
|
+
|
|
334
|
+
const columns = spec.columns ?? {};
|
|
335
|
+
if (!columns || typeof columns !== 'object' || Array.isArray(columns)) {
|
|
336
|
+
throw new Error(`${label}.columns must be an object`);
|
|
337
|
+
}
|
|
338
|
+
for (const key of Object.keys(columns)) {
|
|
339
|
+
assertIdentifier(key, `${label}.columns.${key}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
assertDistinctIdentifiers([timeColumn, valueColumn, ...groupBy, ...Object.keys(columns)], label);
|
|
343
|
+
|
|
344
|
+
const format = /** @type {'csv' | 'json'} */ (spec.format ?? config?.format ?? 'csv');
|
|
345
|
+
if (!VALID_WAREHOUSE_FORMATS.includes(format)) {
|
|
346
|
+
throw new Error(`${label}.format must be one of ${VALID_WAREHOUSE_FORMATS.join(', ')}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const where = source.where ?? null;
|
|
350
|
+
if (where !== null && typeof where !== 'function') {
|
|
351
|
+
throw new Error(`${label}.source.where must be a function`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
name,
|
|
356
|
+
type,
|
|
357
|
+
grain,
|
|
358
|
+
sparse,
|
|
359
|
+
source: {
|
|
360
|
+
event: plusEvents,
|
|
361
|
+
minus: minusEvents,
|
|
362
|
+
measure,
|
|
363
|
+
property,
|
|
364
|
+
where,
|
|
365
|
+
groupBy,
|
|
366
|
+
},
|
|
367
|
+
timeColumn,
|
|
368
|
+
valueColumn,
|
|
369
|
+
baseline: type === 'point-in-time' ? baseline : 0,
|
|
370
|
+
scale,
|
|
371
|
+
noise,
|
|
372
|
+
history,
|
|
373
|
+
columns: { ...columns },
|
|
374
|
+
format,
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return { warehouseMetrics, warnings };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function normalizeStringArray(value, label) {
|
|
382
|
+
if (typeof value === 'string') return [value];
|
|
383
|
+
if (Array.isArray(value)) {
|
|
384
|
+
for (const item of value) {
|
|
385
|
+
if (typeof item !== 'string' || !item.trim()) {
|
|
386
|
+
throw new Error(`${label} must contain only non-empty strings`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return [...value];
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`${label} must be a string or array of strings`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function normalizeOptionalStringArray(value, label) {
|
|
395
|
+
if (value === undefined || value === null) return [];
|
|
396
|
+
return normalizeStringArray(value, label);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function assertKnownEvents(eventNames, eventMap, label) {
|
|
400
|
+
for (const eventName of eventNames) {
|
|
401
|
+
if (!eventMap.has(eventName)) {
|
|
402
|
+
throw new Error(`${label} references unknown source event "${eventName}"`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isDeclaredForAllSources(key, eventNames, eventMap, superProps) {
|
|
408
|
+
if (Object.prototype.hasOwnProperty.call(superProps, key)) return true;
|
|
409
|
+
return eventNames.every((eventName) => {
|
|
410
|
+
const event = eventMap.get(eventName);
|
|
411
|
+
const properties = event?.properties;
|
|
412
|
+
return !!properties && Object.prototype.hasOwnProperty.call(properties, key);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function countObservedDistinctValues(key, eventNames, eventMap, superProps) {
|
|
417
|
+
const values = new Set();
|
|
418
|
+
if (Object.prototype.hasOwnProperty.call(superProps, key) && Array.isArray(superProps[key])) {
|
|
419
|
+
for (const value of superProps[key]) values.add(value);
|
|
420
|
+
}
|
|
421
|
+
for (const eventName of eventNames) {
|
|
422
|
+
const prop = eventMap.get(eventName)?.properties?.[key];
|
|
423
|
+
if (Array.isArray(prop)) {
|
|
424
|
+
for (const value of prop) values.add(value);
|
|
425
|
+
} else if (prop !== undefined) {
|
|
426
|
+
values.add(prop);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return values.size;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function assertIdentifier(value, label) {
|
|
433
|
+
if (typeof value !== 'string' || !IDENTIFIER_RE.test(value)) {
|
|
434
|
+
throw new Error(`${label} must be a valid identifier`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function assertDistinctIdentifiers(values, label) {
|
|
439
|
+
const seen = new Set();
|
|
440
|
+
for (const value of values) {
|
|
441
|
+
if (seen.has(value)) {
|
|
442
|
+
throw new Error(`${label} output column names must be distinct; found collision on "${value}"`);
|
|
443
|
+
}
|
|
444
|
+
seen.add(value);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function previousBucket(startSec, grain) {
|
|
449
|
+
switch (grain) {
|
|
450
|
+
case 'day':
|
|
451
|
+
return startSec - DAY_SECONDS;
|
|
452
|
+
case 'week':
|
|
453
|
+
return startSec - (7 * DAY_SECONDS);
|
|
454
|
+
case 'month': {
|
|
455
|
+
const date = new Date(startSec * 1000);
|
|
456
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - 1, 1) / 1000;
|
|
457
|
+
}
|
|
458
|
+
default:
|
|
459
|
+
throw new Error(`Unsupported warehouse grain "${grain}"`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function buildSeriesKey(spec, record) {
|
|
464
|
+
if (!spec.source.groupBy || spec.source.groupBy.length === 0) return '';
|
|
465
|
+
return spec.source.groupBy.map((key) => String(record[key] ?? '')).join('|');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function createEmptyCell(measure) {
|
|
469
|
+
return {
|
|
470
|
+
count: 0,
|
|
471
|
+
sum: 0,
|
|
472
|
+
users: measure === 'users' ? new Set() : null,
|
|
473
|
+
userDays: measure === 'dau' ? new Set() : null,
|
|
474
|
+
mCount: 0,
|
|
475
|
+
mSum: 0,
|
|
476
|
+
mUsers: measure === 'users' ? new Set() : null,
|
|
477
|
+
mUserDays: measure === 'dau' ? new Set() : null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function updateCell({ cell, event, spec, leg, unixSec, warn }) {
|
|
482
|
+
const fields = leg === 'minus'
|
|
483
|
+
? { count: 'mCount', sum: 'mSum', users: 'mUsers', userDays: 'mUserDays' }
|
|
484
|
+
: { count: 'count', sum: 'sum', users: 'users', userDays: 'userDays' };
|
|
485
|
+
cell[fields.count] += 1;
|
|
486
|
+
|
|
487
|
+
const measure = spec.source.measure;
|
|
488
|
+
if (measure === 'sum' || measure === 'avg') {
|
|
489
|
+
const numeric = Number(event[spec.source.property]);
|
|
490
|
+
if (Number.isFinite(numeric)) {
|
|
491
|
+
cell[fields.sum] += numeric;
|
|
492
|
+
} else {
|
|
493
|
+
warn(`warehouse metric "${spec.name}" encountered a non-numeric value for source.property "${spec.source.property}"; treating it as 0`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (measure === 'users') {
|
|
498
|
+
cell[fields.users].add(String(event.user_id ?? ''));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (measure === 'dau') {
|
|
502
|
+
cell[fields.userDays].add(`${String(event.user_id ?? '')}|${toUtcDay(unixSec)}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function toUtcDay(unixSec) {
|
|
507
|
+
return new Date(bucketStart(unixSec, 'day') * 1000).toISOString().slice(0, 10);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function materializeWarehouseMetrics({ specs, accumulator, chance, FIXED_BEGIN, FIXED_NOW, configName, config }) {
|
|
511
|
+
if (!Array.isArray(specs)) throw new Error('materializeWarehouseMetrics specs must be an array');
|
|
512
|
+
if (!accumulator || typeof accumulator.getCell !== 'function') {
|
|
513
|
+
throw new Error('materializeWarehouseMetrics requires a WarehouseAccumulator');
|
|
514
|
+
}
|
|
515
|
+
if (!Number.isFinite(FIXED_BEGIN) || !Number.isFinite(FIXED_NOW)) {
|
|
516
|
+
throw new Error('materializeWarehouseMetrics requires finite FIXED_BEGIN and FIXED_NOW');
|
|
517
|
+
}
|
|
518
|
+
if (!chance || typeof chance.normal !== 'function') {
|
|
519
|
+
throw new Error('materializeWarehouseMetrics requires a seeded chance instance');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const resolvedConfig = config ?? { name: configName };
|
|
523
|
+
|
|
524
|
+
return specs.map((spec) => materializeOneMetric({
|
|
525
|
+
spec,
|
|
526
|
+
accumulator,
|
|
527
|
+
chance,
|
|
528
|
+
FIXED_BEGIN,
|
|
529
|
+
FIXED_NOW,
|
|
530
|
+
configName,
|
|
531
|
+
config: resolvedConfig,
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function buildManifest(specs, materialized, configName) {
|
|
536
|
+
return {
|
|
537
|
+
configName,
|
|
538
|
+
tables: specs.map((spec, index) => {
|
|
539
|
+
const entry = materialized[index] || { rows: [] };
|
|
540
|
+
const orderedColumns = [
|
|
541
|
+
spec.timeColumn,
|
|
542
|
+
...(spec.source.groupBy || []),
|
|
543
|
+
spec.valueColumn,
|
|
544
|
+
...Object.keys(spec.columns || {}),
|
|
545
|
+
];
|
|
546
|
+
const firstRow = entry.rows[0] || {};
|
|
547
|
+
return {
|
|
548
|
+
table: spec.name,
|
|
549
|
+
file: `${configName}-WAREHOUSE-${spec.name}`,
|
|
550
|
+
format: spec.format,
|
|
551
|
+
grain: spec.grain,
|
|
552
|
+
type: spec.type,
|
|
553
|
+
timeColumn: spec.timeColumn,
|
|
554
|
+
valueColumn: spec.valueColumn,
|
|
555
|
+
dimensionColumns: [...(spec.source.groupBy || [])],
|
|
556
|
+
columns: orderedColumns.map((columnName) => ({
|
|
557
|
+
name: columnName,
|
|
558
|
+
bqType: inferBqType(spec, columnName, firstRow[columnName]),
|
|
559
|
+
})),
|
|
560
|
+
recommendedAggregation: spec.type === 'point-in-time' ? 'last value' : 'sum',
|
|
561
|
+
sql: `SELECT * FROM \`{{DATASET}}.${spec.name}\` ORDER BY ${spec.timeColumn}`,
|
|
562
|
+
refreshHint: 'hourly',
|
|
563
|
+
};
|
|
564
|
+
}),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function materializeOneMetric({ spec, accumulator, chance, FIXED_BEGIN, FIXED_NOW, configName, config }) {
|
|
569
|
+
const metricState = accumulator.metrics.get(spec.name);
|
|
570
|
+
const seriesKeys = resolveSeriesKeys(metricState, spec);
|
|
571
|
+
const historyBuckets = buildBuckets(FIXED_BEGIN, FIXED_NOW, spec.grain, spec.history);
|
|
572
|
+
const windowBuckets = buildBuckets(FIXED_BEGIN, FIXED_NOW, spec.grain, 0);
|
|
573
|
+
const rows = [];
|
|
574
|
+
const metas = [];
|
|
575
|
+
|
|
576
|
+
for (const seriesKey of seriesKeys) {
|
|
577
|
+
const windowValues = computeWindowValues({ spec, accumulator, seriesKey, windowBuckets });
|
|
578
|
+
const backfillValues = computeBackfillValues(spec, windowValues);
|
|
579
|
+
let lastEmittedValue;
|
|
580
|
+
|
|
581
|
+
for (let bucketIndex = 0; bucketIndex < historyBuckets.length; bucketIndex += 1) {
|
|
582
|
+
const bucketSec = historyBuckets[bucketIndex];
|
|
583
|
+
const isBackfill = bucketIndex < spec.history;
|
|
584
|
+
const rawScaledValue = isBackfill
|
|
585
|
+
? backfillValues[bucketIndex]
|
|
586
|
+
: windowValues[bucketIndex - spec.history];
|
|
587
|
+
const finalValue = applyNoiseAndRound(rawScaledValue, spec.noise, chance);
|
|
588
|
+
if (spec.sparse) {
|
|
589
|
+
if (lastEmittedValue !== undefined && finalValue === lastEmittedValue) continue;
|
|
590
|
+
lastEmittedValue = finalValue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const row = buildRow({
|
|
594
|
+
spec,
|
|
595
|
+
bucketSec,
|
|
596
|
+
seriesKey,
|
|
597
|
+
seriesValues: getSeriesValues(metricState, spec, seriesKey),
|
|
598
|
+
value: finalValue,
|
|
599
|
+
bucketIndex,
|
|
600
|
+
bucketCount: historyBuckets.length,
|
|
601
|
+
grain: spec.grain,
|
|
602
|
+
isBackfill,
|
|
603
|
+
config,
|
|
604
|
+
});
|
|
605
|
+
rows.push(row);
|
|
606
|
+
|
|
607
|
+
const cell = isBackfill
|
|
608
|
+
? createEmptyCell(spec.source.measure)
|
|
609
|
+
: accumulator.getCell(spec.name, seriesKey, bucketSec);
|
|
610
|
+
metas.push({
|
|
611
|
+
spec,
|
|
612
|
+
config,
|
|
613
|
+
configName,
|
|
614
|
+
metricName: spec.name,
|
|
615
|
+
bucketIndex,
|
|
616
|
+
bucketCount: historyBuckets.length,
|
|
617
|
+
grain: spec.grain,
|
|
618
|
+
seriesKey,
|
|
619
|
+
isBackfill,
|
|
620
|
+
raw: snapshotRaw(cell),
|
|
621
|
+
datasetStart: FIXED_BEGIN,
|
|
622
|
+
datasetEnd: FIXED_NOW,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
spec,
|
|
629
|
+
rows,
|
|
630
|
+
metas,
|
|
631
|
+
file: `${configName}-WAREHOUSE-${spec.name}`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function resolveSeriesKeys(metricState, spec) {
|
|
636
|
+
const observed = metricState ? Array.from(metricState.series.keys()) : [];
|
|
637
|
+
if (observed.length === 0) {
|
|
638
|
+
return (spec.source.groupBy || []).length === 0 ? [''] : [];
|
|
639
|
+
}
|
|
640
|
+
return observed.sort((left, right) => left.localeCompare(right));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function computeWindowValues({ spec, accumulator, seriesKey, windowBuckets }) {
|
|
644
|
+
if (spec.type === 'point-in-time') {
|
|
645
|
+
const values = [];
|
|
646
|
+
let balance = 0;
|
|
647
|
+
for (const bucketSec of windowBuckets) {
|
|
648
|
+
const delta = getBucketMeasureValue(spec, accumulator.getCell(spec.name, seriesKey, bucketSec), bucketSec);
|
|
649
|
+
balance += delta;
|
|
650
|
+
values.push(Math.max(0, spec.baseline + balance) * spec.scale);
|
|
651
|
+
}
|
|
652
|
+
return values;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return windowBuckets.map((bucketSec) => getBucketMeasureValue(spec, accumulator.getCell(spec.name, seriesKey, bucketSec), bucketSec) * spec.scale);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function computeBackfillValues(spec, windowValues) {
|
|
659
|
+
if (spec.history === 0) return [];
|
|
660
|
+
const slope = leastSquaresSlope(windowValues);
|
|
661
|
+
const firstValue = windowValues[0] ?? 0;
|
|
662
|
+
const values = [];
|
|
663
|
+
for (let step = spec.history; step >= 1; step -= 1) {
|
|
664
|
+
values.push(Math.max(0, firstValue - (slope * step)));
|
|
665
|
+
}
|
|
666
|
+
return values;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function getBucketMeasureValue(spec, cell, bucketSec) {
|
|
670
|
+
const plus = extractLegMeasure(spec.source.measure, cell, false, spec.grain, bucketSec);
|
|
671
|
+
const minus = extractLegMeasure(spec.source.measure, cell, true, spec.grain, bucketSec);
|
|
672
|
+
return plus - minus;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function extractLegMeasure(measure, cell, isMinus, grain, bucketSec) {
|
|
676
|
+
const countKey = isMinus ? 'mCount' : 'count';
|
|
677
|
+
const sumKey = isMinus ? 'mSum' : 'sum';
|
|
678
|
+
const usersKey = isMinus ? 'mUsers' : 'users';
|
|
679
|
+
const userDaysKey = isMinus ? 'mUserDays' : 'userDays';
|
|
680
|
+
|
|
681
|
+
switch (measure) {
|
|
682
|
+
case 'count':
|
|
683
|
+
return cell[countKey];
|
|
684
|
+
case 'sum':
|
|
685
|
+
return cell[sumKey];
|
|
686
|
+
case 'avg':
|
|
687
|
+
return cell[countKey] ? (cell[sumKey] / cell[countKey]) : 0;
|
|
688
|
+
case 'users':
|
|
689
|
+
return cell[usersKey]?.size || 0;
|
|
690
|
+
case 'dau':
|
|
691
|
+
return bucketDayLength(bucketSec, grain) ? ((cell[userDaysKey]?.size || 0) / bucketDayLength(bucketSec, grain)) : 0;
|
|
692
|
+
default:
|
|
693
|
+
throw new Error(`Unsupported warehouse measure "${measure}"`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function bucketDayLength(bucketSec, grain) {
|
|
698
|
+
if (!Number.isFinite(bucketSec)) return 0;
|
|
699
|
+
return (nextBucket(bucketSec, grain) - bucketSec) / DAY_SECONDS;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function leastSquaresSlope(values) {
|
|
703
|
+
if (!Array.isArray(values) || values.length < 2) return 0;
|
|
704
|
+
const meanIndex = (values.length - 1) / 2;
|
|
705
|
+
const meanValue = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
706
|
+
let numerator = 0;
|
|
707
|
+
let denominator = 0;
|
|
708
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
709
|
+
const centeredIndex = index - meanIndex;
|
|
710
|
+
numerator += centeredIndex * (values[index] - meanValue);
|
|
711
|
+
denominator += centeredIndex * centeredIndex;
|
|
712
|
+
}
|
|
713
|
+
return denominator === 0 ? 0 : (numerator / denominator);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function applyNoiseAndRound(value, noise, chance) {
|
|
717
|
+
if (!noise) return roundTo(value, 2);
|
|
718
|
+
const draw = clamp(chance.normal({ dev: noise }), -2 * noise, 2 * noise);
|
|
719
|
+
return roundTo(value * (1 + draw), 2);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function buildRow({ spec, bucketSec, seriesKey, seriesValues, value, bucketIndex, bucketCount, grain, isBackfill, config }) {
|
|
723
|
+
const row = {
|
|
724
|
+
[spec.timeColumn]: new Date(bucketSec * 1000).toISOString().slice(0, 10),
|
|
725
|
+
};
|
|
726
|
+
for (let index = 0; index < (spec.source.groupBy || []).length; index += 1) {
|
|
727
|
+
row[spec.source.groupBy[index]] = seriesValues[index] ?? '';
|
|
728
|
+
}
|
|
729
|
+
row[spec.valueColumn] = value;
|
|
730
|
+
|
|
731
|
+
for (const [columnName, columnValue] of Object.entries(spec.columns || {})) {
|
|
732
|
+
row[columnName] = typeof columnValue === 'function'
|
|
733
|
+
? columnValue({
|
|
734
|
+
value,
|
|
735
|
+
row,
|
|
736
|
+
time: bucketSec * 1000,
|
|
737
|
+
bucketIndex,
|
|
738
|
+
bucketCount,
|
|
739
|
+
grain,
|
|
740
|
+
isBackfill,
|
|
741
|
+
seriesKey,
|
|
742
|
+
spec,
|
|
743
|
+
config,
|
|
744
|
+
})
|
|
745
|
+
: columnValue;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return row;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function snapshotRaw(cell) {
|
|
752
|
+
return {
|
|
753
|
+
plus: {
|
|
754
|
+
count: cell.count,
|
|
755
|
+
sum: cell.sum,
|
|
756
|
+
users: cell.users?.size || 0,
|
|
757
|
+
},
|
|
758
|
+
minus: {
|
|
759
|
+
count: cell.mCount,
|
|
760
|
+
sum: cell.mSum,
|
|
761
|
+
users: cell.mUsers?.size || 0,
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
function inferBqType(spec, columnName, value) {
|
|
768
|
+
if (columnName === spec.timeColumn) return 'DATE';
|
|
769
|
+
if (columnName === spec.valueColumn) return 'FLOAT64';
|
|
770
|
+
if (typeof value === 'number') return 'FLOAT64';
|
|
771
|
+
if (typeof value === 'boolean') return 'BOOL';
|
|
772
|
+
return 'STRING';
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function roundTo(value, decimals) {
|
|
776
|
+
const factor = 10 ** decimals;
|
|
777
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function buildSeriesRef(spec, record) {
|
|
781
|
+
if (!spec.source.groupBy || spec.source.groupBy.length === 0) {
|
|
782
|
+
return { key: '', values: [] };
|
|
783
|
+
}
|
|
784
|
+
const values = spec.source.groupBy.map((key) => normalizeSeriesValue(record[key]));
|
|
785
|
+
return {
|
|
786
|
+
key: values.map((value) => String(value)).join('|'),
|
|
787
|
+
values,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function normalizeSeriesValue(value) {
|
|
792
|
+
return value ?? '';
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function observeSeriesValues(metric, seriesKey, seriesValues) {
|
|
796
|
+
const existing = metric.seriesValues.get(seriesKey);
|
|
797
|
+
if (!existing) {
|
|
798
|
+
metric.seriesValues.set(seriesKey, [...seriesValues]);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (sameSeriesTuple(existing, seriesValues)) return;
|
|
802
|
+
throw new Error(
|
|
803
|
+
`warehouse metric "${metric.spec.name}" groupBy collision on seriesKey "${seriesKey}": `
|
|
804
|
+
+ `observed ${formatSeriesTuple(metric.spec.source.groupBy || [], existing)} and `
|
|
805
|
+
+ `${formatSeriesTuple(metric.spec.source.groupBy || [], seriesValues)}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function sameSeriesTuple(left, right) {
|
|
810
|
+
if (left.length !== right.length) return false;
|
|
811
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
812
|
+
if (!Object.is(left[index], right[index])) return false;
|
|
813
|
+
}
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function formatSeriesTuple(groupBy, values) {
|
|
818
|
+
return groupBy.map((key, index) => `${key}=${JSON.stringify(values[index] ?? '')}`).join(', ');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function getSeriesValues(metricState, spec, seriesKey) {
|
|
822
|
+
if (!spec.source.groupBy || spec.source.groupBy.length === 0) return [];
|
|
823
|
+
return metricState?.seriesValues.get(seriesKey) || [];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function clamp(value, min, max) {
|
|
827
|
+
return Math.min(max, Math.max(min, value));
|
|
828
|
+
}
|