@ak--47/dungeon-master 1.6.5 → 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 +331 -0
- package/HOOKS.md +154 -5
- package/README.md +357 -8
- package/docs/guides/1.7.0-upgrade-guide.md +154 -0
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/dungeons/technical/warehouse.js +187 -0
- package/index.js +131 -2
- package/lib/core/config-validator.js +264 -13
- package/lib/core/context.js +39 -0
- package/lib/core/dungeon-loader.js +5 -2
- package/lib/core/storage.js +51 -3
- package/lib/generators/events.js +53 -7
- package/lib/generators/funnels.js +85 -11
- package/lib/generators/profiles.js +9 -4
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/orchestrators/mixpanel-sender.js +39 -3
- package/lib/orchestrators/user-loop.js +240 -9
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/conditions.js +62 -0
- package/lib/utils/json-evaluator.js +12 -2
- package/lib/utils/utils.js +115 -19
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +8 -0
- package/lib/verify/story-runner.js +71 -8
- package/lib/verify/warehouse.js +683 -0
- package/package.json +5 -11
- package/scripts/verify-stories.mjs +150 -44
- package/types.d.ts +606 -38
package/lib/generators/events.js
CHANGED
|
@@ -83,9 +83,15 @@ export async function makeEvent(
|
|
|
83
83
|
|
|
84
84
|
let defaultProps = {};
|
|
85
85
|
|
|
86
|
-
// Add default properties based on configuration
|
|
86
|
+
// Add default properties based on configuration.
|
|
87
|
+
// v1.7.0 (P1-2 / B2): a user's events share the user's location. Before 1.7.0
|
|
88
|
+
// `featureCtx.userLocation` was computed per user and never read here, so every
|
|
89
|
+
// event drew a fresh random city — 0.8% of events matched their profile's city.
|
|
90
|
+
// The per-event draw remains only for callers that pass no user location.
|
|
87
91
|
if (hasLocation) {
|
|
88
|
-
defaultProps.location =
|
|
92
|
+
defaultProps.location = (featureCtx && featureCtx.userLocation)
|
|
93
|
+
? featureCtx.userLocation
|
|
94
|
+
: u.pickRandom(defaults.locationsEvents());
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
if (hasBrowser) {
|
|
@@ -114,9 +120,25 @@ export async function makeEvent(
|
|
|
114
120
|
const latestTime = (featureCtx && Number.isFinite(featureCtx.latestTime))
|
|
115
121
|
? featureCtx.latestTime
|
|
116
122
|
: context.FIXED_NOW;
|
|
123
|
+
// TimeSoup ALWAYS runs (even when the caller pins the time below) so the
|
|
124
|
+
// seeded RNG stream is consumed identically to pre-1.7 — byte-identical
|
|
125
|
+
// output for existing dungeons.
|
|
117
126
|
unixTime = u.TimeSoup(earliestTime, latestTime, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
|
|
118
127
|
}
|
|
119
|
-
|
|
128
|
+
// v1.7.0: funnel steps after the first know their final time up front
|
|
129
|
+
// (`fixedTimeMs` = step-0 time + timing offset), so property thunks see the
|
|
130
|
+
// real `ctx.time` / `ctx.event` and world-event windows test the real time.
|
|
131
|
+
// Before 1.7.0 the step's time was overwritten after properties resolved.
|
|
132
|
+
if (featureCtx && Number.isFinite(featureCtx.fixedTimeMs)) {
|
|
133
|
+
eventTemplate.time = new Date(featureCtx.fixedTimeMs).toISOString();
|
|
134
|
+
} else {
|
|
135
|
+
eventTemplate.time = dayjs.unix(unixTime).toISOString();
|
|
136
|
+
}
|
|
137
|
+
// Synchronous side channel: lets the funnel generator learn step 0's time
|
|
138
|
+
// before later steps start resolving (see generateFunnelEvents).
|
|
139
|
+
if (featureCtx && typeof featureCtx.onTimeResolved === 'function') {
|
|
140
|
+
featureCtx.onTimeResolved(Date.parse(eventTemplate.time));
|
|
141
|
+
}
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
// ── Phase 2 identity stamping ──
|
|
@@ -149,6 +171,17 @@ export async function makeEvent(
|
|
|
149
171
|
eventTemplate.user_id = distinct_id;
|
|
150
172
|
}
|
|
151
173
|
|
|
174
|
+
// v1.7.0 (P1-1): value context handed to every property thunk. `event` is the
|
|
175
|
+
// partially-built record (identity + time set; properties resolve in declaration
|
|
176
|
+
// order, so a later key can read an earlier one). `profile` is the user's
|
|
177
|
+
// resolved profile when the caller supplied it (user-loop / funnels do).
|
|
178
|
+
const valueCtx = {
|
|
179
|
+
profile: (featureCtx && featureCtx.profile) || undefined,
|
|
180
|
+
event: eventTemplate,
|
|
181
|
+
time: eventTemplate.time ? Date.parse(eventTemplate.time) : undefined,
|
|
182
|
+
config,
|
|
183
|
+
};
|
|
184
|
+
|
|
152
185
|
// PERFORMANCE: Process properties directly without creating intermediate object
|
|
153
186
|
// Add custom properties from event configuration
|
|
154
187
|
if (chosenEvent.properties) {
|
|
@@ -156,21 +189,21 @@ export async function makeEvent(
|
|
|
156
189
|
for (let i = 0; i < eventKeys.length; i++) {
|
|
157
190
|
const key = eventKeys[i];
|
|
158
191
|
try {
|
|
159
|
-
eventTemplate[key] = u.choose(chosenEvent.properties[key]);
|
|
192
|
+
eventTemplate[key] = u.choose(chosenEvent.properties[key], valueCtx);
|
|
160
193
|
} catch (e) {
|
|
161
194
|
logger.error({ err: e, key, event: chosenEvent.event }, `Error processing property ${key} in ${chosenEvent.event} event`);
|
|
162
195
|
// Continue processing other properties
|
|
163
196
|
}
|
|
164
197
|
}
|
|
165
198
|
}
|
|
166
|
-
|
|
199
|
+
|
|
167
200
|
// Add super properties (override event properties if needed)
|
|
168
201
|
if (superProps) {
|
|
169
202
|
const superKeys = Object.keys(superProps);
|
|
170
203
|
for (let i = 0; i < superKeys.length; i++) {
|
|
171
204
|
const key = superKeys[i];
|
|
172
205
|
try {
|
|
173
|
-
eventTemplate[key] = u.choose(superProps[key]);
|
|
206
|
+
eventTemplate[key] = u.choose(superProps[key], valueCtx);
|
|
174
207
|
} catch (e) {
|
|
175
208
|
logger.error({ err: e, key }, `Error processing super property ${key}`);
|
|
176
209
|
// Continue processing other properties
|
|
@@ -178,6 +211,16 @@ export async function makeEvent(
|
|
|
178
211
|
}
|
|
179
212
|
}
|
|
180
213
|
|
|
214
|
+
// v1.7.0 (P1-2): user-sticky event properties. Resolved once per user in the
|
|
215
|
+
// user loop (after the `user` hook, so hook overrides are honored) and copied
|
|
216
|
+
// onto every event AFTER superProps — a sticky key beats a per-event re-roll of
|
|
217
|
+
// the same name — and BEFORE the `event` hook, which stays the final authority.
|
|
218
|
+
if (featureCtx && featureCtx.stickyValues) {
|
|
219
|
+
for (const key in featureCtx.stickyValues) {
|
|
220
|
+
eventTemplate[key] = featureCtx.stickyValues[key];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
181
224
|
// Add default properties if not skipped
|
|
182
225
|
if (!skipDefaults) {
|
|
183
226
|
addDefaultProperties(eventTemplate, defaultProps);
|
|
@@ -211,7 +254,10 @@ export async function makeEvent(
|
|
|
211
254
|
eventTemplate[k] = v;
|
|
212
255
|
}
|
|
213
256
|
}
|
|
214
|
-
// Volume modulation via accept/reject: if volumeMultiplier < 1, randomly drop
|
|
257
|
+
// Volume modulation via accept/reject: if volumeMultiplier < 1, randomly drop.
|
|
258
|
+
// v1.7.0 (P0-3): volumeMultiplier > 1 is handled per user in
|
|
259
|
+
// user-loop.js `amplifyWorldEvents` — affected events are cloned
|
|
260
|
+
// (fresh insert_id) and spread across the window.
|
|
215
261
|
const volMult = inAftermath ? (we.aftermath?.volumeMultiplier || 1.0) : we.volumeMultiplier;
|
|
216
262
|
if (volMult < 1.0 && !chance.bool({ likelihood: volMult * 100 })) {
|
|
217
263
|
eventTemplate._drop = true;
|
|
@@ -92,12 +92,18 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
92
92
|
if (hashVal < cumWeight) { chosenVariant = expCfg.variants[vi]; chosenIdx = vi; break; }
|
|
93
93
|
}
|
|
94
94
|
experimentVariant = chosenVariant.name;
|
|
95
|
-
funnel.conversionRate =
|
|
96
|
-
Math.round((funnel.conversionRate || 50) * chosenVariant.conversionMultiplier))
|
|
95
|
+
funnel.conversionRate = saturateConversionRate(context, funnel,
|
|
96
|
+
Math.round((funnel.conversionRate || 50) * chosenVariant.conversionMultiplier), `experiment "${experimentName}" variant "${experimentVariant}"`, 1);
|
|
97
97
|
funnel.timeToConvert = Math.max(0.1,
|
|
98
98
|
(funnel.timeToConvert || 1) * chosenVariant.ttcMultiplier);
|
|
99
99
|
funnel._experimentName = experimentName;
|
|
100
100
|
funnel._experimentVariant = experimentVariant;
|
|
101
|
+
// v1.7.0 (P0-2): record the assignment so the user loop can stamp
|
|
102
|
+
// `Experiment: <name>` on the profile. Only when the bucketing is sticky
|
|
103
|
+
// (a re-rolled variant has no single per-user value) and stampProfile is on.
|
|
104
|
+
if (expCfg.sticky !== false && expCfg.stampProfile !== false && featureCtx && featureCtx.experimentAssignments) {
|
|
105
|
+
featureCtx.experimentAssignments.set(experimentName, experimentVariant);
|
|
106
|
+
}
|
|
101
107
|
funnel.sequence = ["$experiment_started", ...funnel.sequence];
|
|
102
108
|
experimentMeta = {
|
|
103
109
|
name: experimentName,
|
|
@@ -113,7 +119,13 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
113
119
|
// Apply persona and world-event modifiers to the funnel BEFORE the hook fires,
|
|
114
120
|
// so funnel-pre sees the effective rate and has final authority.
|
|
115
121
|
if (persona && persona.conversionModifier) {
|
|
116
|
-
funnel.conversionRate =
|
|
122
|
+
funnel.conversionRate = saturateConversionRate(context, funnel,
|
|
123
|
+
Math.round((funnel.conversionRate || 50) * persona.conversionModifier), `persona "${persona.name}" conversionModifier`, 0);
|
|
124
|
+
}
|
|
125
|
+
// v1.7.0 (P1-3): persona time-to-convert multiplier. Composes after the
|
|
126
|
+
// experiment ttcMultiplier, before the funnel-pre hook (hook keeps final say).
|
|
127
|
+
if (persona && Number.isFinite(persona.ttcModifier) && persona.ttcModifier !== 1) {
|
|
128
|
+
funnel.timeToConvert = Math.max(0.1, (funnel.timeToConvert || 1) * persona.ttcModifier);
|
|
117
129
|
}
|
|
118
130
|
const resolvedWorldEvents = /** @type {import('../../types').ResolvedWorldEvent[]} */ (config.worldEvents);
|
|
119
131
|
if (resolvedWorldEvents && firstEventTime) {
|
|
@@ -122,7 +134,8 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
122
134
|
const seq = funnel.sequence || [];
|
|
123
135
|
const affects = we.affectsEvents;
|
|
124
136
|
if (affects === "*" || (Array.isArray(affects) && seq.some(s => affects.includes(s)))) {
|
|
125
|
-
funnel.conversionRate =
|
|
137
|
+
funnel.conversionRate = saturateConversionRate(context, funnel,
|
|
138
|
+
Math.round((funnel.conversionRate || 50) * we.conversionModifier), `worldEvent "${we.name}" conversionModifier`, 0);
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
}
|
|
@@ -144,6 +157,16 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
144
157
|
experiment: experimentMeta,
|
|
145
158
|
});
|
|
146
159
|
|
|
160
|
+
// v1.7.0 (P2-4): a funnel-pre hook that leaves conversionRate above 100 is
|
|
161
|
+
// clamped downstream (processEventRepeats) — record the saturation so the
|
|
162
|
+
// author learns the asked-for lift did not land. The engine can only see its
|
|
163
|
+
// own clamp: a hook's own `Math.min(95, rate * 3)` never reaches here.
|
|
164
|
+
// Record only — the value itself is left for processEventRepeats to clamp exactly
|
|
165
|
+
// as before, so pre-1.7 output is unchanged.
|
|
166
|
+
if (Number.isFinite(funnel.conversionRate) && funnel.conversionRate > 100) {
|
|
167
|
+
saturateConversionRate(context, funnel, funnel.conversionRate, 'funnel-pre hook', 0);
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
// Extract funnel configuration (post-hook — hook's mutations are the final word)
|
|
148
171
|
let {
|
|
149
172
|
sequence,
|
|
@@ -160,18 +183,21 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
160
183
|
const { distinct_id, created, anonymousIds = [] } = user;
|
|
161
184
|
const { superProps = {}, groupKeys = [] } = config;
|
|
162
185
|
|
|
186
|
+
// v1.7.0 (P1-1): value context for funnel-level and step-level property thunks.
|
|
187
|
+
const valueCtx = { profile, config, time: Number.isFinite(firstEventTime) ? firstEventTime * 1000 : undefined };
|
|
188
|
+
|
|
163
189
|
// Choose properties for this funnel instance
|
|
164
190
|
const chosenFunnelProps = { ...props, ...superProps };
|
|
165
191
|
for (const key in props) {
|
|
166
192
|
try {
|
|
167
|
-
chosenFunnelProps[key] = u.choose(chosenFunnelProps[key]);
|
|
193
|
+
chosenFunnelProps[key] = u.choose(chosenFunnelProps[key], valueCtx);
|
|
168
194
|
} catch (e) {
|
|
169
195
|
logger.error({ err: e, key, funnel: funnel.sequence.join(" > ") }, `Error processing property ${key} in funnel`);
|
|
170
196
|
}
|
|
171
197
|
}
|
|
172
198
|
|
|
173
199
|
// Build event specifications for funnel steps
|
|
174
|
-
const funnelPossibleEvents = buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex, expName, expVariant);
|
|
200
|
+
const funnelPossibleEvents = buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex, expName, expVariant, valueCtx);
|
|
175
201
|
|
|
176
202
|
// Handle repeat logic and conversion rate adjustment
|
|
177
203
|
let { processedEvents, adjustedConversionRate } = processEventRepeats(
|
|
@@ -298,6 +324,9 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
298
324
|
persona: featureCtx.persona || persona || null,
|
|
299
325
|
userCampaign: featureCtx.userCampaign || null,
|
|
300
326
|
userLocation: featureCtx.userLocation || null,
|
|
327
|
+
// v1.7.0: value context + sticky event props flow through to makeEvent.
|
|
328
|
+
profile: featureCtx.profile || profile || null,
|
|
329
|
+
stickyValues: featureCtx.stickyValues || null,
|
|
301
330
|
worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
|
|
302
331
|
dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
|
|
303
332
|
latestTime: Number.isFinite(featureCtx.latestTime) ? featureCtx.latestTime : undefined,
|
|
@@ -399,7 +428,7 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
399
428
|
// Resolve declared props on the exclusion event's config.
|
|
400
429
|
if (excConfig && excConfig.properties) {
|
|
401
430
|
for (const k of Object.keys(excConfig.properties)) {
|
|
402
|
-
try { cloned[k] = u.choose(excConfig.properties[k]); }
|
|
431
|
+
try { cloned[k] = u.choose(excConfig.properties[k], { ...valueCtx, event: cloned, time: Date.parse(cloned.time) }); }
|
|
403
432
|
catch (e) { cloned[k] = null; }
|
|
404
433
|
}
|
|
405
434
|
}
|
|
@@ -429,6 +458,33 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
429
458
|
return [finalEvents, doesUserConvert, authTimeMs];
|
|
430
459
|
}
|
|
431
460
|
|
|
461
|
+
/**
|
|
462
|
+
* v1.7.0 (P2-4): clamp a modified conversionRate into `[floor, 100]` and, when the
|
|
463
|
+
* requested value exceeded 100, record ONE aggregated warning per funnel+source on
|
|
464
|
+
* the context (never per user — this runs in the hot loop).
|
|
465
|
+
*
|
|
466
|
+
* @param {Context} context
|
|
467
|
+
* @param {Object} funnel
|
|
468
|
+
* @param {number} requested - pre-clamp rate
|
|
469
|
+
* @param {string} source - what produced the rate (persona / experiment / world event / hook)
|
|
470
|
+
* @param {number} floor - lower bound (experiments floor at 1, others at 0)
|
|
471
|
+
* @returns {number} clamped rate
|
|
472
|
+
*/
|
|
473
|
+
function saturateConversionRate(context, funnel, requested, source, floor) {
|
|
474
|
+
const applied = Math.min(100, Math.max(floor, requested));
|
|
475
|
+
if (requested > 100 && context && typeof context.addWarning === 'function') {
|
|
476
|
+
const label = funnel.name || (Array.isArray(funnel.sequence) ? funnel.sequence.filter(s => s !== '$experiment_started').join(' > ') : '?');
|
|
477
|
+
context.addWarning({
|
|
478
|
+
key: `funnels[${label}].conversionRate:${source}`,
|
|
479
|
+
requested,
|
|
480
|
+
applied: 100,
|
|
481
|
+
reason: `${source} pushed conversionRate above 100; saturated at 100, so the intended lift did not fully land. Lower the base conversionRate or the multiplier.`,
|
|
482
|
+
severity: 'clamp',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return applied;
|
|
486
|
+
}
|
|
487
|
+
|
|
432
488
|
/**
|
|
433
489
|
* Builds event specifications for funnel steps
|
|
434
490
|
* @param {Context} context - Context object
|
|
@@ -437,9 +493,10 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
437
493
|
* @param {number} bindPropsIndex - Index at which to bind properties (if applicable)
|
|
438
494
|
* @param {string} [experimentName] - Name of experiment (if experiment is enabled)
|
|
439
495
|
* @param {string} [experimentVariant] - Variant name (A, B, or C)
|
|
496
|
+
* @param {Object} [valueCtx] - v1.7.0 value context for property thunks
|
|
440
497
|
* @returns {Array} Array of event specifications
|
|
441
498
|
*/
|
|
442
|
-
function buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex, experimentName, experimentVariant) {
|
|
499
|
+
function buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex, experimentName, experimentVariant, valueCtx = undefined) {
|
|
443
500
|
const { config } = context;
|
|
444
501
|
|
|
445
502
|
return sequence.map((eventName, currentIndex) => {
|
|
@@ -463,10 +520,14 @@ function buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex,
|
|
|
463
520
|
properties: { ...foundEvent.properties }
|
|
464
521
|
} : { event: eventName, properties: {} };
|
|
465
522
|
|
|
466
|
-
// Process event properties
|
|
523
|
+
// Process event properties.
|
|
524
|
+
// v1.7.0 (P1-1): context-aware thunks (`(ctx) => …`) are DEFERRED — left in
|
|
525
|
+
// place so makeEvent resolves them per step with the real `ctx.event` and
|
|
526
|
+
// `ctx.time`. Everything else resolves here exactly as before.
|
|
467
527
|
for (const key in eventSpec.properties) {
|
|
528
|
+
if (u.isContextAware(eventSpec.properties[key])) continue;
|
|
468
529
|
try {
|
|
469
|
-
eventSpec.properties[key] = u.choose(eventSpec.properties[key]);
|
|
530
|
+
eventSpec.properties[key] = u.choose(eventSpec.properties[key], valueCtx);
|
|
470
531
|
} catch (e) {
|
|
471
532
|
logger.error({ err: e, key, event: eventSpec.event }, `Error processing property ${key} in ${eventSpec.event} event`);
|
|
472
533
|
}
|
|
@@ -636,9 +697,22 @@ async function generateFunnelEvents(
|
|
|
636
697
|
const stampingByIndex = (identityArgs && identityArgs.stampingByIndex) || null;
|
|
637
698
|
const devicePool = (identityArgs && identityArgs.devicePool) || null;
|
|
638
699
|
|
|
700
|
+
// v1.7.0: step 0 reports its TimeSoup time through a synchronous side channel.
|
|
701
|
+
// Each map callback runs synchronously up to makeEvent's first `await` (the
|
|
702
|
+
// event hook), which is AFTER the time is set — so by the time callback i>0
|
|
703
|
+
// starts, `funnelStartMs` is known and the step's final time can be pinned
|
|
704
|
+
// before its properties resolve. RNG order is unchanged.
|
|
705
|
+
let funnelStartMs = null;
|
|
706
|
+
|
|
639
707
|
const finalEvents = await Promise.all(eventsWithTiming.map(async (event, index) => {
|
|
640
708
|
const stamping = stampingByIndex ? stampingByIndex[index] : 'both';
|
|
641
709
|
const identityCtx = (devicePool || stamping !== 'both') ? { stamping, devicePool } : null;
|
|
710
|
+
let stepFeatureCtx = featureCtx;
|
|
711
|
+
if (index === 0) {
|
|
712
|
+
stepFeatureCtx = { ...featureCtx, onTimeResolved: (ms) => { funnelStartMs = ms; } };
|
|
713
|
+
} else if (funnelStartMs !== null && Number.isFinite(event.relativeTimeMs)) {
|
|
714
|
+
stepFeatureCtx = { ...featureCtx, fixedTimeMs: funnelStartMs + event.relativeTimeMs };
|
|
715
|
+
}
|
|
642
716
|
const newEvent = await makeEvent(
|
|
643
717
|
context,
|
|
644
718
|
distinct_id,
|
|
@@ -649,7 +723,7 @@ async function generateFunnelEvents(
|
|
|
649
723
|
groupKeys,
|
|
650
724
|
false,
|
|
651
725
|
false,
|
|
652
|
-
|
|
726
|
+
stepFeatureCtx,
|
|
653
727
|
identityCtx
|
|
654
728
|
);
|
|
655
729
|
|
|
@@ -18,19 +18,24 @@ import { dataLogger as logger } from "../utils/logger.js";
|
|
|
18
18
|
export async function makeProfile(context, props = {}, defaults = {}) {
|
|
19
19
|
// Update operation counter
|
|
20
20
|
context.incrementOperations();
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
// Keys that should not be processed with the choose function
|
|
23
23
|
const keysToNotChoose = ["anonymousIds", "sessionIds"];
|
|
24
24
|
|
|
25
25
|
// Start with defaults
|
|
26
26
|
const profile = { ...defaults };
|
|
27
27
|
|
|
28
|
+
// v1.7.0 (P1-1): value functions see the profile as it is being built.
|
|
29
|
+
// Keys resolve in insertion order, so a later key can read an earlier one
|
|
30
|
+
// (`revenue: (ctx) => ctx.profile.plan === 'pro' ? 100 : 10`).
|
|
31
|
+
const valueCtx = { profile, config: context.config };
|
|
32
|
+
|
|
28
33
|
// Process default values first
|
|
29
34
|
for (const key in profile) {
|
|
30
35
|
if (keysToNotChoose.includes(key)) continue;
|
|
31
|
-
|
|
36
|
+
|
|
32
37
|
try {
|
|
33
|
-
profile[key] = u.choose(profile[key]);
|
|
38
|
+
profile[key] = u.choose(profile[key], valueCtx);
|
|
34
39
|
} catch (e) {
|
|
35
40
|
logger.error({ err: e, key }, `Error processing default property ${key}`);
|
|
36
41
|
// Keep original value on error
|
|
@@ -40,7 +45,7 @@ export async function makeProfile(context, props = {}, defaults = {}) {
|
|
|
40
45
|
// Process provided props (these override defaults)
|
|
41
46
|
for (const key in props) {
|
|
42
47
|
try {
|
|
43
|
-
profile[key] = u.choose(props[key]);
|
|
48
|
+
profile[key] = u.choose(props[key], valueCtx);
|
|
44
49
|
} catch (e) {
|
|
45
50
|
logger.error({ err: e, key }, `Error processing property ${key}`);
|
|
46
51
|
// Keep original value on error
|
|
@@ -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
|
+
}
|