@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.
Files changed (45) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +49 -10
  3. package/.claude/skills/create-project/SKILL.md +22 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +18 -1
  7. package/.claude/skills/powertools/SKILL.md +20 -1
  8. package/.claude/skills/release-check/SKILL.md +99 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +71 -16
  10. package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
  11. package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
  12. package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
  13. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  14. package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
  15. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  16. package/.claude/skills/write-hooks/SKILL.md +33 -3
  17. package/CHANGELOG.md +331 -0
  18. package/HOOKS.md +154 -5
  19. package/README.md +357 -8
  20. package/docs/guides/1.7.0-upgrade-guide.md +154 -0
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/dungeons/technical/warehouse.js +187 -0
  23. package/index.js +131 -2
  24. package/lib/core/config-validator.js +264 -13
  25. package/lib/core/context.js +39 -0
  26. package/lib/core/dungeon-loader.js +5 -2
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +53 -7
  29. package/lib/generators/funnels.js +85 -11
  30. package/lib/generators/profiles.js +9 -4
  31. package/lib/generators/standalone.js +248 -0
  32. package/lib/generators/warehouse.js +828 -0
  33. package/lib/orchestrators/mixpanel-sender.js +39 -3
  34. package/lib/orchestrators/user-loop.js +240 -9
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/conditions.js +62 -0
  37. package/lib/utils/json-evaluator.js +12 -2
  38. package/lib/utils/utils.js +115 -19
  39. package/lib/verify/index.js +1 -0
  40. package/lib/verify/schema-validator.js +8 -0
  41. package/lib/verify/story-runner.js +71 -8
  42. package/lib/verify/warehouse.js +683 -0
  43. package/package.json +5 -11
  44. package/scripts/verify-stories.mjs +150 -44
  45. package/types.d.ts +606 -38
@@ -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 = u.pickRandom(defaults.locationsEvents());
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
- eventTemplate.time = dayjs.unix(unixTime).toISOString();
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 = Math.min(100, Math.max(1,
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 = Math.min(100, Math.max(0, Math.round((funnel.conversionRate || 50) * persona.conversionModifier)));
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 = Math.min(100, Math.max(0, Math.round((funnel.conversionRate || 50) * we.conversionModifier)));
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
- featureCtx,
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
+ }