@ak--47/dungeon-master 1.7.0 → 1.8.1
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 +30 -11
- package/.claude/skills/create-dungeon/SKILL.md +84 -44
- package/.claude/skills/create-project/SKILL.md +28 -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 +39 -12
- package/.claude/skills/powertools/SKILL.md +26 -3
- package/.claude/skills/release-check/SKILL.md +124 -0
- package/.claude/skills/verify-dungeon/SKILL.md +103 -29
- package/.claude/skills/verify-dungeon/references/alignment-contract.md +84 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +41 -16
- package/.claude/skills/verify-dungeon/references/report-format.md +41 -10
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +171 -226
- package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
- package/.claude/skills/warehouse-metrics/SKILL.md +111 -0
- package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
- package/.claude/skills/write-hooks/SKILL.md +94 -51
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +165 -18
- package/README.md +265 -1
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/docs/guides/1.8.1-upgrade-guide.md +153 -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/events.js +6 -0
- package/lib/generators/funnels.js +15 -0
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/hook-helpers/shape.js +73 -17
- package/lib/orchestrators/mixpanel-sender.js +27 -2
- package/lib/orchestrators/user-loop.js +83 -15
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/utils.js +37 -12
- package/lib/verify/funnel-engine.js +66 -26
- 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 +312 -9
|
@@ -17,6 +17,8 @@ import { resolveSoup } from "../templates/soup-presets.js";
|
|
|
17
17
|
import { resolveMacro } from "../templates/macro-presets.js";
|
|
18
18
|
import { locations as LOCATION_TEMPLATE } from "../templates/defaults.js";
|
|
19
19
|
import { CONDITION_OPERATORS } from "../utils/conditions.js";
|
|
20
|
+
import { validateStandaloneEvents } from "../generators/standalone.js";
|
|
21
|
+
import { validateWarehouseMetrics } from "../generators/warehouse.js";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* v1.7.0 (P2-2): one entry per value the validator changed or flagged. Collected
|
|
@@ -653,6 +655,22 @@ export function validateDungeonConfig(config) {
|
|
|
653
655
|
/** @type {EngineWarning[]} */
|
|
654
656
|
const warnings = [];
|
|
655
657
|
|
|
658
|
+
// v1.8.0 — identity-less metric snapshots. Throws on anything malformed;
|
|
659
|
+
// a silent skip would drop a whole data stream without the author noticing.
|
|
660
|
+
const standaloneEvents = validateStandaloneEvents(config.standaloneEvents);
|
|
661
|
+
const {
|
|
662
|
+
warehouseMetrics,
|
|
663
|
+
warnings: warehouseMetricWarnings,
|
|
664
|
+
} = validateWarehouseMetrics(config);
|
|
665
|
+
for (const reason of warehouseMetricWarnings) {
|
|
666
|
+
const key = String(reason).split(' ')[0] || 'warehouseMetrics';
|
|
667
|
+
warnings.push({
|
|
668
|
+
key,
|
|
669
|
+
reason,
|
|
670
|
+
severity: /clamp/i.test(reason) ? 'clamp' : 'warn',
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
656
674
|
// v1.7.0 (R2-2): resolve singleCountry to the template's canonical name (accepts
|
|
657
675
|
// ISO code or full name, case-insensitive) or throw. Before 1.7.0 a miss
|
|
658
676
|
// filtered the location pool to empty and silently deleted every geo property.
|
|
@@ -1206,6 +1224,9 @@ export function validateDungeonConfig(config) {
|
|
|
1206
1224
|
groupKeys: normalizedGroupKeys,
|
|
1207
1225
|
groupProps,
|
|
1208
1226
|
lookupTables,
|
|
1227
|
+
// v1.8.0 identity-less metric snapshots (normalized)
|
|
1228
|
+
standaloneEvents,
|
|
1229
|
+
warehouseMetrics,
|
|
1209
1230
|
hasAnonIds: hasAnonIdsResolved,
|
|
1210
1231
|
avgDevicePerUser,
|
|
1211
1232
|
hasSessionIds,
|
|
@@ -278,7 +278,7 @@ export function validateDungeonShape(input) {
|
|
|
278
278
|
'events', 'numEvents', 'numUsers', 'numDays', 'funnels',
|
|
279
279
|
'userProps', 'superProps', 'hook', 'token', 'seed',
|
|
280
280
|
'scdProps', 'groupKeys', 'lookupTables', 'mirrorProps',
|
|
281
|
-
'hasAdSpend', 'soup', 'format', 'writeToDisk'
|
|
281
|
+
'hasAdSpend', 'standaloneEvents', 'warehouseMetrics', 'soup', 'format', 'writeToDisk'
|
|
282
282
|
];
|
|
283
283
|
|
|
284
284
|
const hasAnyDungeonKey = dungeonKeys.some(key => key in config);
|
package/lib/core/storage.js
CHANGED
|
@@ -91,15 +91,17 @@ export async function createHookArray(arr = [], opts) {
|
|
|
91
91
|
if (item === null || item === undefined) return false;
|
|
92
92
|
if (typeof item === 'object' && Object.keys(item).length === 0) return false;
|
|
93
93
|
|
|
94
|
+
const isWarehouse = type === "warehouse";
|
|
95
|
+
|
|
94
96
|
// Skip hook for types already hooked in generators/orchestrators to prevent double-firing
|
|
95
97
|
// Types hooked upstream: "event" (events.js), "user" (user-loop.js), "scd" (user-loop.js)
|
|
96
|
-
// Types only hooked here: "mirror", "ad-spend", "group", "lookup"
|
|
98
|
+
// Types only hooked here: "mirror", "ad-spend", "group", "lookup", "standalone"
|
|
97
99
|
const alreadyHooked = type === "event" || type === "user" || type === "scd";
|
|
98
100
|
|
|
99
101
|
// Performance optimization: skip hook overhead for passthrough hooks
|
|
100
102
|
// Only treat as passthrough if the function body is trivially simple (just returns its argument)
|
|
101
103
|
const hookStr = hook.toString();
|
|
102
|
-
const isPassthroughHook = hook.length === 1 || /^\s*function\s*\([^)]*\)\s*\{\s*return\s+\w+;?\s*\}\s*$/.test(hookStr) || /^\s*\(?[^)]*\)?\s*=>\s*\w+\s*$/.test(hookStr);
|
|
104
|
+
const isPassthroughHook = !isWarehouse && (hook.length === 1 || /^\s*function\s*\([^)]*\)\s*\{\s*return\s+\w+;?\s*\}\s*$/.test(hookStr) || /^\s*\(?[^)]*\)?\s*=>\s*\w+\s*$/.test(hookStr));
|
|
103
105
|
|
|
104
106
|
if (alreadyHooked || isPassthroughHook) {
|
|
105
107
|
// Fast path for passthrough hooks - no transformation needed
|
|
@@ -131,6 +133,10 @@ export async function createHookArray(arr = [], opts) {
|
|
|
131
133
|
for (const i of item) {
|
|
132
134
|
try {
|
|
133
135
|
const enriched = await hook(i, type, allMetaData);
|
|
136
|
+
if (isWarehouse) {
|
|
137
|
+
if (isValidEvent(i)) arr.push(i);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
134
140
|
if (Array.isArray(enriched)) {
|
|
135
141
|
enriched.forEach(e => {
|
|
136
142
|
if (isValidEvent(e)) arr.push(e);
|
|
@@ -146,6 +152,10 @@ export async function createHookArray(arr = [], opts) {
|
|
|
146
152
|
} else {
|
|
147
153
|
try {
|
|
148
154
|
const enriched = await hook(item, type, allMetaData);
|
|
155
|
+
if (isWarehouse) {
|
|
156
|
+
if (isValidEvent(item)) arr.push(item);
|
|
157
|
+
return Promise.resolve(false);
|
|
158
|
+
}
|
|
149
159
|
if (Array.isArray(enriched)) {
|
|
150
160
|
enriched.forEach(e => {
|
|
151
161
|
if (isValidEvent(e)) arr.push(e);
|
|
@@ -161,7 +171,7 @@ export async function createHookArray(arr = [], opts) {
|
|
|
161
171
|
}
|
|
162
172
|
|
|
163
173
|
// Check batch size and handle writes synchronously to prevent race conditions
|
|
164
|
-
if (arr.length > BATCH_SIZE && !isWriting) {
|
|
174
|
+
if (!isWarehouse && arr.length > BATCH_SIZE && !isWriting) {
|
|
165
175
|
isWriting = true; // Lock to prevent concurrent writes
|
|
166
176
|
isBatchMode = true;
|
|
167
177
|
runtime.isBatchMode = true; // Update runtime state
|
|
@@ -200,6 +210,9 @@ export async function createHookArray(arr = [], opts) {
|
|
|
200
210
|
const streamOptions = {
|
|
201
211
|
gzip: config.gzip || false
|
|
202
212
|
};
|
|
213
|
+
if (type === "warehouse" && Array.isArray(rest.fixedColumns)) {
|
|
214
|
+
streamOptions.fixedColumns = rest.fixedColumns;
|
|
215
|
+
}
|
|
203
216
|
|
|
204
217
|
switch (format) {
|
|
205
218
|
case "csv":
|
|
@@ -253,6 +266,8 @@ export async function createHookArray(arr = [], opts) {
|
|
|
253
266
|
enrichedArray.getWriteDir = getWriteDir;
|
|
254
267
|
enrichedArray.getWritePath = getWritePath;
|
|
255
268
|
enrichedArray.getWrittenFiles = () => [...writtenFiles];
|
|
269
|
+
enrichedArray.type = type;
|
|
270
|
+
enrichedArray.format = format;
|
|
256
271
|
|
|
257
272
|
// Add additional properties from rest
|
|
258
273
|
for (const key in rest) {
|
|
@@ -309,9 +324,20 @@ export class StorageManager {
|
|
|
309
324
|
context: this.context
|
|
310
325
|
}),
|
|
311
326
|
|
|
327
|
+
// v1.8.0 — identity-less metric snapshots (`standaloneEvents`).
|
|
328
|
+
standaloneEventData: await createHookArray([], {
|
|
329
|
+
hook: config.hook,
|
|
330
|
+
type: "standalone",
|
|
331
|
+
filepath: `${config.name}-STANDALONE`,
|
|
332
|
+
format: config.format || "csv",
|
|
333
|
+
concurrency: config.concurrency || 1,
|
|
334
|
+
context: this.context
|
|
335
|
+
}),
|
|
336
|
+
|
|
312
337
|
scdTableData: [],
|
|
313
338
|
groupProfilesData: [],
|
|
314
339
|
lookupTableData: [],
|
|
340
|
+
warehouseMetricData: [],
|
|
315
341
|
|
|
316
342
|
mirrorEventData: await createHookArray([], {
|
|
317
343
|
hook: config.hook,
|
|
@@ -375,6 +401,28 @@ export class StorageManager {
|
|
|
375
401
|
}
|
|
376
402
|
}
|
|
377
403
|
|
|
404
|
+
if (config.warehouseMetrics && config.warehouseMetrics.length > 0) {
|
|
405
|
+
for (const warehouseMetric of config.warehouseMetrics) {
|
|
406
|
+
const fixedColumns = [
|
|
407
|
+
warehouseMetric.timeColumn,
|
|
408
|
+
...(warehouseMetric.source.groupBy || []),
|
|
409
|
+
warehouseMetric.valueColumn,
|
|
410
|
+
...Object.keys(warehouseMetric.columns || {}),
|
|
411
|
+
];
|
|
412
|
+
const warehouseArray = await createHookArray([], {
|
|
413
|
+
hook: config.hook,
|
|
414
|
+
type: "warehouse",
|
|
415
|
+
filepath: `${config.name}-WAREHOUSE-${warehouseMetric.name}`,
|
|
416
|
+
format: warehouseMetric.format || config.format || "csv",
|
|
417
|
+
concurrency: config.concurrency || 1,
|
|
418
|
+
context: this.context,
|
|
419
|
+
metricName: warehouseMetric.name,
|
|
420
|
+
fixedColumns,
|
|
421
|
+
});
|
|
422
|
+
storage.warehouseMetricData.push(warehouseArray);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
378
426
|
return storage;
|
|
379
427
|
}
|
|
380
428
|
|
package/lib/generators/events.js
CHANGED
|
@@ -17,6 +17,8 @@ import { dataLogger as logger } from "../utils/logger.js";
|
|
|
17
17
|
// Keys that must never be nulled by data quality gremlins
|
|
18
18
|
const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
|
|
19
19
|
|
|
20
|
+
export const engineIdentity = Symbol('engineIdentity');
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Creates a Mixpanel event with a flat shape
|
|
@@ -293,6 +295,9 @@ export async function makeEvent(
|
|
|
293
295
|
|
|
294
296
|
eventTemplate.insert_id = randomUUID();
|
|
295
297
|
|
|
298
|
+
const originalIdentity = { user_id: eventTemplate.user_id, device_id: eventTemplate.device_id };
|
|
299
|
+
Object.defineProperty(eventTemplate, engineIdentity, { value: originalIdentity, configurable: true });
|
|
300
|
+
|
|
296
301
|
// Call hook if configured (hooks override everything — they are the final authority)
|
|
297
302
|
const { hook } = config;
|
|
298
303
|
if (hook) {
|
|
@@ -305,6 +310,7 @@ export async function makeEvent(
|
|
|
305
310
|
});
|
|
306
311
|
// If hook returns a modified event, use it; otherwise use original
|
|
307
312
|
if (hookedEvent && typeof hookedEvent === 'object') {
|
|
313
|
+
Object.defineProperty(hookedEvent, engineIdentity, { value: originalIdentity, configurable: true });
|
|
308
314
|
return hookedEvent;
|
|
309
315
|
}
|
|
310
316
|
}
|
|
@@ -331,6 +331,21 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
331
331
|
dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
|
|
332
332
|
latestTime: Number.isFinite(featureCtx.latestTime) ? featureCtx.latestTime : undefined,
|
|
333
333
|
};
|
|
334
|
+
if (attemptInfo.isFirstFunnel && attemptInfo.isBorn && funnelEventsWithTiming.length) {
|
|
335
|
+
const spanMs = Math.max(0, ...funnelEventsWithTiming.map(event => event.relativeTimeMs || 0));
|
|
336
|
+
const latestStart = Math.min(funnelFeatureCtx.latestTime ?? context.FIXED_NOW, context.FIXED_NOW - spanMs / 1000);
|
|
337
|
+
if (firstEventTime > latestStart) {
|
|
338
|
+
context.addWarning({
|
|
339
|
+
key: 'lifecycle.firstFunnelClipped',
|
|
340
|
+
requested: spanMs,
|
|
341
|
+
applied: Math.max(0, (context.FIXED_NOW - firstEventTime) * 1000),
|
|
342
|
+
reason: 'First-funnel timing exceeds the remaining dataset window; emit the in-window prefix and omit future steps without compressing configured TTC.',
|
|
343
|
+
severity: 'warn',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
funnelFeatureCtx.latestTime = Math.max(firstEventTime, latestStart);
|
|
347
|
+
if (featureCtx.pinFirstTime) funnelFeatureCtx.fixedTimeMs = firstEventTime * 1000;
|
|
348
|
+
}
|
|
334
349
|
|
|
335
350
|
// Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
|
|
336
351
|
// runs, the first event in execution order whose config has `isAuthEvent: true` is
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone event generator module (v1.8.0)
|
|
3
|
+
*
|
|
4
|
+
* Standalone events are IDENTITY-LESS metric snapshots. They carry no `user_id`
|
|
5
|
+
* and no `device_id` — they describe a system, not a person. Think daily CDN
|
|
6
|
+
* egress per region, weekly billing rollups per plan tier, hourly queue depth
|
|
7
|
+
* per cluster. `$ad_spend` is the same idea, hard-coded; this is the general form.
|
|
8
|
+
*
|
|
9
|
+
* One record is emitted per cadence tick per dimension cross-product row:
|
|
10
|
+
*
|
|
11
|
+
* cadence: 'day', dimensions: { region: ['us','eu'], tier: ['a','b'] }
|
|
12
|
+
* → 4 records per day (us/a, us/b, eu/a, eu/b)
|
|
13
|
+
*
|
|
14
|
+
* `distinct_id` is synthetic. It is the value of the dimension named by
|
|
15
|
+
* `distinctIdFrom`, or the event name when no dimension is named. It exists only
|
|
16
|
+
* so Mixpanel accepts the record; it never maps to a person.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @typedef {import('../../types').Context} Context */
|
|
20
|
+
/** @typedef {import('../../types').StandaloneEventConfig} StandaloneEventConfig */
|
|
21
|
+
/** @typedef {import('../../types').ResolvedStandaloneEventConfig} ResolvedStandaloneEventConfig */
|
|
22
|
+
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import dayjs from "dayjs";
|
|
25
|
+
import utc from "dayjs/plugin/utc.js";
|
|
26
|
+
import * as u from "../utils/utils.js";
|
|
27
|
+
|
|
28
|
+
dayjs.extend(utc);
|
|
29
|
+
|
|
30
|
+
/** Seconds per cadence tick. */
|
|
31
|
+
const CADENCE_SECONDS = {
|
|
32
|
+
hour: 60 * 60,
|
|
33
|
+
day: 24 * 60 * 60,
|
|
34
|
+
week: 7 * 24 * 60 * 60,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Cadence names the validator accepts. */
|
|
38
|
+
export const VALID_CADENCES = Object.freeze(Object.keys(CADENCE_SECONDS));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Expands a `dimensions` map into every combination of its values.
|
|
42
|
+
* `{ a: [1,2], b: ['x'] }` → `[{a:1,b:'x'}, {a:2,b:'x'}]`.
|
|
43
|
+
* An empty/absent map yields a single empty row, so an undimensioned
|
|
44
|
+
* standalone event emits exactly one record per tick.
|
|
45
|
+
*
|
|
46
|
+
* @param {Record<string, any[]>} dimensions
|
|
47
|
+
* @returns {Record<string, any>[]}
|
|
48
|
+
*/
|
|
49
|
+
export function expandDimensions(dimensions) {
|
|
50
|
+
const keys = Object.keys(dimensions || {});
|
|
51
|
+
if (keys.length === 0) return [{}];
|
|
52
|
+
|
|
53
|
+
let rows = [{}];
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
const values = dimensions[key];
|
|
56
|
+
/** @type {Record<string, any>[]} */
|
|
57
|
+
const next = [];
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
for (const value of values) {
|
|
60
|
+
next.push({ ...row, [key]: value });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
rows = next;
|
|
64
|
+
}
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every tick timestamp for one standalone event across the dataset window.
|
|
70
|
+
*
|
|
71
|
+
* Ticks start at `datasetStart` and step by the cadence. The final tick is the
|
|
72
|
+
* last one that lands at or before `datasetEnd` — a standalone event never
|
|
73
|
+
* emits a record in the future, matching the engine-wide future-time guard.
|
|
74
|
+
*
|
|
75
|
+
* @param {number} datasetStart - unix seconds
|
|
76
|
+
* @param {number} datasetEnd - unix seconds
|
|
77
|
+
* @param {'hour'|'day'|'week'} cadence
|
|
78
|
+
* @returns {number[]} unix seconds, ascending
|
|
79
|
+
*/
|
|
80
|
+
export function buildTicks(datasetStart, datasetEnd, cadence) {
|
|
81
|
+
const step = CADENCE_SECONDS[cadence];
|
|
82
|
+
if (!step) throw new Error(`unknown cadence: ${cadence}`);
|
|
83
|
+
|
|
84
|
+
/** @type {number[]} */
|
|
85
|
+
const ticks = [];
|
|
86
|
+
for (let t = datasetStart; t <= datasetEnd; t += step) {
|
|
87
|
+
ticks.push(t);
|
|
88
|
+
}
|
|
89
|
+
return ticks;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Builds every record for a single standalone event config.
|
|
94
|
+
*
|
|
95
|
+
* @param {Context} context
|
|
96
|
+
* @param {ResolvedStandaloneEventConfig} spec
|
|
97
|
+
* @returns {Record<string, any>[]}
|
|
98
|
+
*/
|
|
99
|
+
export function makeStandaloneEvents(context, spec) {
|
|
100
|
+
const { config } = context;
|
|
101
|
+
const datasetStart = context.FIXED_BEGIN;
|
|
102
|
+
const datasetEnd = context.FIXED_NOW;
|
|
103
|
+
|
|
104
|
+
const ticks = buildTicks(datasetStart, datasetEnd, spec.cadence);
|
|
105
|
+
const rows = expandDimensions(spec.dimensions);
|
|
106
|
+
const propKeys = Object.keys(spec.properties);
|
|
107
|
+
|
|
108
|
+
/** @type {Record<string, any>[]} */
|
|
109
|
+
const records = [];
|
|
110
|
+
|
|
111
|
+
for (let tickIndex = 0; tickIndex < ticks.length; tickIndex++) {
|
|
112
|
+
const tickUnix = ticks[tickIndex];
|
|
113
|
+
const isoTime = dayjs.unix(tickUnix).utc().toISOString();
|
|
114
|
+
|
|
115
|
+
for (const dimensions of rows) {
|
|
116
|
+
context.incrementOperations();
|
|
117
|
+
|
|
118
|
+
// distinct_id is synthetic: the named dimension's value, else the
|
|
119
|
+
// event name. Never a person. Stable across the run so the record
|
|
120
|
+
// series groups cleanly in Mixpanel.
|
|
121
|
+
const distinctId = spec.distinctIdFrom
|
|
122
|
+
? String(dimensions[spec.distinctIdFrom])
|
|
123
|
+
: spec.event;
|
|
124
|
+
|
|
125
|
+
/** @type {Record<string, any>} */
|
|
126
|
+
const record = {
|
|
127
|
+
event: spec.event,
|
|
128
|
+
time: isoTime,
|
|
129
|
+
insert_id: randomUUID(),
|
|
130
|
+
distinct_id: distinctId,
|
|
131
|
+
...dimensions,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// The context handed to every property function. Shares the
|
|
135
|
+
// ValueContext members (`time`, `config`) so a function written for
|
|
136
|
+
// a normal event property still works here, plus the standalone-only
|
|
137
|
+
// members a snapshot needs to shape a trend.
|
|
138
|
+
const valueContext = {
|
|
139
|
+
time: tickUnix * 1000,
|
|
140
|
+
config,
|
|
141
|
+
dimensions,
|
|
142
|
+
tickIndex,
|
|
143
|
+
tickCount: ticks.length,
|
|
144
|
+
cadence: spec.cadence,
|
|
145
|
+
event: record,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const key of propKeys) {
|
|
149
|
+
record[key] = u.choose(spec.properties[key], valueContext);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
records.push(record);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return records;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validates and normalizes the `standaloneEvents` config array.
|
|
161
|
+
* Throws on anything malformed — a silent skip would hide a whole data stream.
|
|
162
|
+
*
|
|
163
|
+
* @param {unknown} standaloneEvents
|
|
164
|
+
* @returns {ResolvedStandaloneEventConfig[]}
|
|
165
|
+
*/
|
|
166
|
+
export function validateStandaloneEvents(standaloneEvents) {
|
|
167
|
+
if (standaloneEvents === undefined || standaloneEvents === null) return [];
|
|
168
|
+
if (!Array.isArray(standaloneEvents)) {
|
|
169
|
+
throw new Error("standaloneEvents must be an array");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
|
|
174
|
+
return standaloneEvents.map((spec, index) => {
|
|
175
|
+
const label = `standaloneEvents[${index}]`;
|
|
176
|
+
|
|
177
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
|
178
|
+
throw new Error(`${label} must be an object`);
|
|
179
|
+
}
|
|
180
|
+
if (typeof spec.event !== "string" || !spec.event.trim()) {
|
|
181
|
+
throw new Error(`${label}.event must be a non-empty string`);
|
|
182
|
+
}
|
|
183
|
+
if (seen.has(spec.event)) {
|
|
184
|
+
throw new Error(`${label}.event "${spec.event}" is declared more than once`);
|
|
185
|
+
}
|
|
186
|
+
seen.add(spec.event);
|
|
187
|
+
|
|
188
|
+
const cadence = spec.cadence || "day";
|
|
189
|
+
if (!CADENCE_SECONDS[cadence]) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`${label}.cadence must be one of ${VALID_CADENCES.join(", ")} (got "${cadence}")`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @type {Record<string, any[]>} */
|
|
196
|
+
const dimensions = {};
|
|
197
|
+
if (spec.dimensions !== undefined && spec.dimensions !== null) {
|
|
198
|
+
if (typeof spec.dimensions !== "object" || Array.isArray(spec.dimensions)) {
|
|
199
|
+
throw new Error(`${label}.dimensions must be an object of arrays`);
|
|
200
|
+
}
|
|
201
|
+
for (const [key, values] of Object.entries(spec.dimensions)) {
|
|
202
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
203
|
+
throw new Error(`${label}.dimensions.${key} must be a non-empty array`);
|
|
204
|
+
}
|
|
205
|
+
dimensions[key] = values;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (spec.distinctIdFrom !== undefined && spec.distinctIdFrom !== null) {
|
|
210
|
+
if (typeof spec.distinctIdFrom !== "string") {
|
|
211
|
+
throw new Error(`${label}.distinctIdFrom must be a string`);
|
|
212
|
+
}
|
|
213
|
+
if (!(spec.distinctIdFrom in dimensions)) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`${label}.distinctIdFrom "${spec.distinctIdFrom}" is not a declared dimension`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (spec.properties !== undefined && spec.properties !== null) {
|
|
221
|
+
if (typeof spec.properties !== "object" || Array.isArray(spec.properties)) {
|
|
222
|
+
throw new Error(`${label}.properties must be an object`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Schema-first: a property key must not collide with a dimension key or
|
|
227
|
+
// with a reserved record key. A collision would silently overwrite one
|
|
228
|
+
// of them and the author would never see it.
|
|
229
|
+
const properties = spec.properties || {};
|
|
230
|
+
const reserved = new Set(["event", "time", "insert_id", "distinct_id", "user_id", "device_id"]);
|
|
231
|
+
for (const key of Object.keys(properties)) {
|
|
232
|
+
if (reserved.has(key)) {
|
|
233
|
+
throw new Error(`${label}.properties.${key} collides with a reserved record key`);
|
|
234
|
+
}
|
|
235
|
+
if (key in dimensions) {
|
|
236
|
+
throw new Error(`${label}.properties.${key} collides with a dimension of the same name`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
event: spec.event,
|
|
242
|
+
cadence,
|
|
243
|
+
dimensions,
|
|
244
|
+
distinctIdFrom: spec.distinctIdFrom || null,
|
|
245
|
+
properties,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|