@ak--47/dungeon-master 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +518 -0
- package/dungeons/array-of-object-lookup-schema.json +327 -0
- package/dungeons/array-of-object-lookup.js +220 -0
- package/dungeons/ecommerce-schema.json +462 -0
- package/dungeons/ecommerce.js +447 -0
- package/dungeons/education-schema.json +2409 -0
- package/dungeons/education.js +768 -0
- package/dungeons/fintech-schema.json +14034 -0
- package/dungeons/fintech.js +696 -0
- package/dungeons/foobar-schema.json +403 -0
- package/dungeons/foobar.js +296 -0
- package/dungeons/food-delivery-schema.json +192 -0
- package/dungeons/food-delivery.js +602 -0
- package/dungeons/food-schema.json +1152 -0
- package/dungeons/food.js +754 -0
- package/dungeons/gaming-schema.json +1270 -0
- package/dungeons/gaming.js +508 -0
- package/dungeons/insurance-application-schema.json +204 -0
- package/dungeons/insurance-application.js +605 -0
- package/dungeons/media-schema.json +906 -0
- package/dungeons/media.js +790 -0
- package/dungeons/retention-cadence-schema.json +78 -0
- package/dungeons/retention-cadence.js +244 -0
- package/dungeons/rpg-schema.json +4526 -0
- package/dungeons/rpg.js +919 -0
- package/dungeons/sanity-schema.json +255 -0
- package/dungeons/sanity.js +152 -0
- package/dungeons/sass-schema.json +1291 -0
- package/dungeons/sass.js +795 -0
- package/dungeons/scd-schema.json +919 -0
- package/dungeons/scd.js +277 -0
- package/dungeons/simple-schema.json +608 -0
- package/dungeons/simple.js +285 -0
- package/dungeons/simplest-schema.json +1418 -0
- package/dungeons/simplest.js +392 -0
- package/dungeons/social-schema.json +1118 -0
- package/dungeons/social.js +686 -0
- package/dungeons/text-generation-schema.json +3096 -0
- package/dungeons/text-generation.js +812 -0
- package/index.js +567 -0
- package/lib/core/config-validator.js +395 -0
- package/lib/core/context.js +204 -0
- package/lib/core/dungeon-loader.js +337 -0
- package/lib/core/storage.js +379 -0
- package/lib/generators/adspend.js +132 -0
- package/lib/generators/events.js +271 -0
- package/lib/generators/funnels.js +407 -0
- package/lib/generators/mirror.js +167 -0
- package/lib/generators/product-lookup.js +262 -0
- package/lib/generators/product-names.js +195 -0
- package/lib/generators/profiles.js +93 -0
- package/lib/generators/scd.js +124 -0
- package/lib/generators/text.js +1192 -0
- package/lib/orchestrators/mixpanel-sender.js +266 -0
- package/lib/orchestrators/user-loop.js +335 -0
- package/lib/templates/abbreviated.d.ts +169 -0
- package/lib/templates/defaults.js +1405 -0
- package/lib/templates/phrases.js +2526 -0
- package/lib/templates/schema.d.ts +173 -0
- package/lib/templates/soup-presets.js +188 -0
- package/lib/utils/function-registry.js +302 -0
- package/lib/utils/json-evaluator.js +172 -0
- package/lib/utils/logger.js +34 -0
- package/lib/utils/utils.js +1490 -0
- package/package.json +89 -0
- package/types.d.ts +865 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funnel generator module
|
|
3
|
+
* Creates conversion sequences with realistic timing and ordering
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types').Context} Context */
|
|
7
|
+
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
import * as u from "../utils/utils.js";
|
|
10
|
+
import { makeEvent } from "./events.js";
|
|
11
|
+
import { dataLogger as logger } from "../utils/logger.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a funnel (sequence of events) for a user with conversion logic
|
|
15
|
+
* @param {Context} context - Context object containing config, defaults, etc.
|
|
16
|
+
* @param {Object} funnel - Funnel configuration
|
|
17
|
+
* @param {Object} user - User object with distinct_id, created, etc.
|
|
18
|
+
* @param {number} firstEventTime - Unix timestamp for first event
|
|
19
|
+
* @param {Object} profile - User profile object
|
|
20
|
+
* @param {Object} scd - Slowly changing dimensions object
|
|
21
|
+
* @returns {Promise<[Array, boolean]>} Tuple of [events, didConvert]
|
|
22
|
+
*/
|
|
23
|
+
export async function makeFunnel(context, funnel, user, firstEventTime, profile = {}, scd = {}) {
|
|
24
|
+
if (!funnel) throw new Error("no funnel");
|
|
25
|
+
if (!user) throw new Error("no user");
|
|
26
|
+
|
|
27
|
+
const { config } = context;
|
|
28
|
+
const chance = u.getChance();
|
|
29
|
+
const { hook = async (a) => a } = config;
|
|
30
|
+
|
|
31
|
+
// Get session start events if configured
|
|
32
|
+
const sessionStartEvents = config.events?.filter(a => a.isSessionStartEvent) || [];
|
|
33
|
+
|
|
34
|
+
// Clone funnel to avoid mutating the original object
|
|
35
|
+
funnel = { ...funnel };
|
|
36
|
+
|
|
37
|
+
// Experiment handling: if funnel.experiment === true, create 3 variants
|
|
38
|
+
let experimentVariant = null;
|
|
39
|
+
let experimentName = null;
|
|
40
|
+
|
|
41
|
+
if (funnel.experiment) {
|
|
42
|
+
experimentName = funnel.name + ` Experiment` || "Unnamed Funnel";
|
|
43
|
+
|
|
44
|
+
// Evenly distribute across 3 variants (33.33% each) using seeded chance
|
|
45
|
+
const randomValue = chance.floating({ min: 0, max: 1 });
|
|
46
|
+
if (randomValue < 0.333) {
|
|
47
|
+
// Variant A: WORSE conversion, slower
|
|
48
|
+
funnel.conversionRate = Math.max(1, Math.floor(funnel.conversionRate * 0.7));
|
|
49
|
+
funnel.timeToConvert = Math.max(0.1, funnel.timeToConvert * 1.5);
|
|
50
|
+
experimentVariant = "A";
|
|
51
|
+
} else if (randomValue < 0.666) {
|
|
52
|
+
// Variant B: BETTER conversion, faster
|
|
53
|
+
funnel.conversionRate = Math.min(100, Math.ceil(funnel.conversionRate * 1.3));
|
|
54
|
+
funnel.timeToConvert = Math.max(0.1, funnel.timeToConvert * 0.7);
|
|
55
|
+
experimentVariant = "B";
|
|
56
|
+
} else {
|
|
57
|
+
// Variant C: CONTROL - original values (no changes)
|
|
58
|
+
experimentVariant = "C";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Mark that this funnel has experiment metadata (used later)
|
|
62
|
+
funnel._experimentName = experimentName;
|
|
63
|
+
funnel._experimentVariant = experimentVariant;
|
|
64
|
+
|
|
65
|
+
// Insert $experiment_started at beginning of sequence (clone array to avoid mutation)
|
|
66
|
+
funnel.sequence = ["$experiment_started", ...funnel.sequence];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Call pre-funnel hook
|
|
70
|
+
await hook(funnel, "funnel-pre", { user, profile, scd, funnel, config, firstEventTime });
|
|
71
|
+
|
|
72
|
+
// Extract funnel configuration
|
|
73
|
+
let {
|
|
74
|
+
sequence,
|
|
75
|
+
conversionRate = 50,
|
|
76
|
+
order = 'sequential',
|
|
77
|
+
timeToConvert = 1,
|
|
78
|
+
props = {},
|
|
79
|
+
requireRepeats = false,
|
|
80
|
+
_experimentName: expName,
|
|
81
|
+
_experimentVariant: expVariant,
|
|
82
|
+
bindPropsIndex = 0
|
|
83
|
+
} = funnel;
|
|
84
|
+
|
|
85
|
+
const { distinct_id, created, anonymousIds = [], sessionIds = [] } = user;
|
|
86
|
+
const { superProps = {}, groupKeys = [] } = config;
|
|
87
|
+
|
|
88
|
+
// Choose properties for this funnel instance
|
|
89
|
+
const chosenFunnelProps = { ...props, ...superProps };
|
|
90
|
+
for (const key in props) {
|
|
91
|
+
try {
|
|
92
|
+
chosenFunnelProps[key] = u.choose(chosenFunnelProps[key]);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
logger.error({ err: e, key, funnel: funnel.sequence.join(" > ") }, `Error processing property ${key} in funnel`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build event specifications for funnel steps
|
|
99
|
+
const funnelPossibleEvents = buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex, expName, expVariant);
|
|
100
|
+
|
|
101
|
+
// Handle repeat logic and conversion rate adjustment
|
|
102
|
+
const { processedEvents, adjustedConversionRate } = processEventRepeats(
|
|
103
|
+
funnelPossibleEvents,
|
|
104
|
+
requireRepeats,
|
|
105
|
+
conversionRate,
|
|
106
|
+
chance
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Determine if user converts and how many steps they'll take
|
|
110
|
+
const { doesUserConvert, numStepsUserWillTake } = determineConversion(
|
|
111
|
+
adjustedConversionRate,
|
|
112
|
+
sequence.length,
|
|
113
|
+
chance
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Get steps user will actually take
|
|
117
|
+
const funnelStepsUserWillTake = processedEvents.slice(0, numStepsUserWillTake);
|
|
118
|
+
|
|
119
|
+
// Apply ordering strategy
|
|
120
|
+
const funnelActualOrder = applyOrderingStrategy(
|
|
121
|
+
funnelStepsUserWillTake,
|
|
122
|
+
order,
|
|
123
|
+
config,
|
|
124
|
+
sequence
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Add timing offsets to events
|
|
128
|
+
const funnelEventsWithTiming = addTimingOffsets(
|
|
129
|
+
funnelActualOrder,
|
|
130
|
+
timeToConvert,
|
|
131
|
+
numStepsUserWillTake
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Add session start event if configured (clone to avoid mutating shared config)
|
|
135
|
+
if (sessionStartEvents.length) {
|
|
136
|
+
const sessionStartEvent = { ...chance.pickone(sessionStartEvents), relativeTimeMs: -15000 };
|
|
137
|
+
funnelEventsWithTiming.push(sessionStartEvent);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Generate actual events with timing
|
|
141
|
+
const finalEvents = await generateFunnelEvents(
|
|
142
|
+
context,
|
|
143
|
+
funnelEventsWithTiming,
|
|
144
|
+
distinct_id,
|
|
145
|
+
firstEventTime || dayjs(created).unix(),
|
|
146
|
+
anonymousIds,
|
|
147
|
+
sessionIds,
|
|
148
|
+
groupKeys
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Call post-funnel hook
|
|
152
|
+
await hook(finalEvents, "funnel-post", { user, profile, scd, funnel, config });
|
|
153
|
+
|
|
154
|
+
return [finalEvents, doesUserConvert];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Builds event specifications for funnel steps
|
|
159
|
+
* @param {Context} context - Context object
|
|
160
|
+
* @param {Array} sequence - Array of event names
|
|
161
|
+
* @param {Object} chosenFunnelProps - Properties to apply to all events
|
|
162
|
+
* @param {number} bindPropsIndex - Index at which to bind properties (if applicable)
|
|
163
|
+
* @param {string} [experimentName] - Name of experiment (if experiment is enabled)
|
|
164
|
+
* @param {string} [experimentVariant] - Variant name (A, B, or C)
|
|
165
|
+
* @returns {Array} Array of event specifications
|
|
166
|
+
*/
|
|
167
|
+
function buildFunnelEvents(context, sequence, chosenFunnelProps, bindPropsIndex, experimentName, experimentVariant) {
|
|
168
|
+
const { config } = context;
|
|
169
|
+
|
|
170
|
+
return sequence.map((eventName, currentIndex) => {
|
|
171
|
+
// Handle $experiment_started event specially
|
|
172
|
+
if (eventName === "$experiment_started" && experimentName && experimentVariant) {
|
|
173
|
+
return {
|
|
174
|
+
event: "$experiment_started",
|
|
175
|
+
properties: {
|
|
176
|
+
"Experiment name": experimentName,
|
|
177
|
+
"Variant name": experimentVariant
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const foundEvent = config.events?.find((e) => e.event === eventName);
|
|
183
|
+
|
|
184
|
+
// PERFORMANCE: Shallow copy instead of deepClone for better performance
|
|
185
|
+
// We only need to copy the top-level structure since we're rebuilding properties anyway
|
|
186
|
+
const eventSpec = foundEvent ? {
|
|
187
|
+
event: foundEvent.event,
|
|
188
|
+
properties: { ...foundEvent.properties }
|
|
189
|
+
} : { event: eventName, properties: {} };
|
|
190
|
+
|
|
191
|
+
// Process event properties
|
|
192
|
+
for (const key in eventSpec.properties) {
|
|
193
|
+
try {
|
|
194
|
+
eventSpec.properties[key] = u.choose(eventSpec.properties[key]);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
logger.error({ err: e, key, event: eventSpec.event }, `Error processing property ${key} in ${eventSpec.event} event`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Merge funnel properties (no need to delete properties since we're creating a new object)
|
|
201
|
+
eventSpec.properties = { ...eventSpec.properties, ...chosenFunnelProps };
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if (bindPropsIndex && currentIndex < bindPropsIndex) {
|
|
205
|
+
// Remove funnel properties that were added but should not be bound yet
|
|
206
|
+
for (const key in chosenFunnelProps) {
|
|
207
|
+
delete eventSpec.properties[key];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return eventSpec;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Processes event repeats and adjusts conversion rate
|
|
217
|
+
* @param {Array} events - Array of event specifications
|
|
218
|
+
* @param {boolean} requireRepeats - Whether repeats are required
|
|
219
|
+
* @param {number} conversionRate - Base conversion rate
|
|
220
|
+
* @param {Object} chance - Chance.js instance
|
|
221
|
+
* @returns {Object} Object with processedEvents and adjustedConversionRate
|
|
222
|
+
*/
|
|
223
|
+
function processEventRepeats(events, requireRepeats, conversionRate, chance) {
|
|
224
|
+
let adjustedConversionRate = conversionRate;
|
|
225
|
+
|
|
226
|
+
const processedEvents = events.reduce((acc, step) => {
|
|
227
|
+
if (!requireRepeats) {
|
|
228
|
+
if (acc.find(e => e.event === step.event)) {
|
|
229
|
+
if (chance.bool({ likelihood: 50 })) {
|
|
230
|
+
adjustedConversionRate = Math.floor(adjustedConversionRate * 1.35); // Increase conversion rate
|
|
231
|
+
acc.push(step);
|
|
232
|
+
} else {
|
|
233
|
+
adjustedConversionRate = Math.floor(adjustedConversionRate * 0.70); // Reduce conversion rate
|
|
234
|
+
return acc; // Skip the step
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
acc.push(step);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
acc.push(step);
|
|
241
|
+
}
|
|
242
|
+
return acc;
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
// Clamp conversion rate
|
|
246
|
+
if (adjustedConversionRate > 100) adjustedConversionRate = 100;
|
|
247
|
+
if (adjustedConversionRate < 0) adjustedConversionRate = 0;
|
|
248
|
+
|
|
249
|
+
return { processedEvents, adjustedConversionRate };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Determines if user converts and how many steps they'll take
|
|
254
|
+
* @param {number} conversionRate - Adjusted conversion rate
|
|
255
|
+
* @param {number} totalSteps - Total number of steps in funnel
|
|
256
|
+
* @param {Object} chance - Chance.js instance
|
|
257
|
+
* @returns {Object} Object with doesUserConvert and numStepsUserWillTake
|
|
258
|
+
*/
|
|
259
|
+
function determineConversion(conversionRate, totalSteps, chance) {
|
|
260
|
+
const doesUserConvert = chance.bool({ likelihood: conversionRate });
|
|
261
|
+
const numStepsUserWillTake = doesUserConvert ?
|
|
262
|
+
totalSteps :
|
|
263
|
+
u.integer(1, totalSteps - 1);
|
|
264
|
+
|
|
265
|
+
return { doesUserConvert, numStepsUserWillTake };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Applies ordering strategy to funnel steps
|
|
270
|
+
* @param {Array} steps - Funnel steps to order
|
|
271
|
+
* @param {string} order - Ordering strategy
|
|
272
|
+
* @param {Object} config - Configuration object
|
|
273
|
+
* @param {Array} sequence - Original sequence for interrupted mode
|
|
274
|
+
* @returns {Array} Ordered funnel steps
|
|
275
|
+
*/
|
|
276
|
+
function applyOrderingStrategy(steps, order, config, sequence) {
|
|
277
|
+
switch (order) {
|
|
278
|
+
case "sequential":
|
|
279
|
+
return steps;
|
|
280
|
+
case "random":
|
|
281
|
+
return u.shuffleArray(steps);
|
|
282
|
+
case "first-fixed":
|
|
283
|
+
return u.shuffleExceptFirst(steps);
|
|
284
|
+
case "last-fixed":
|
|
285
|
+
return u.shuffleExceptLast(steps);
|
|
286
|
+
case "first-and-last-fixed":
|
|
287
|
+
return u.fixFirstAndLast(steps);
|
|
288
|
+
case "middle-fixed":
|
|
289
|
+
return u.shuffleOutside(steps);
|
|
290
|
+
case "interrupted":
|
|
291
|
+
const potentialSubstitutes = config.events
|
|
292
|
+
?.filter(e => !e.isFirstEvent)
|
|
293
|
+
?.filter(e => !sequence.includes(e.event)) || [];
|
|
294
|
+
return u.interruptArray(steps, potentialSubstitutes);
|
|
295
|
+
default:
|
|
296
|
+
return steps;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Adds timing offsets to funnel events
|
|
302
|
+
* @param {Array} events - Events to add timing to
|
|
303
|
+
* @param {number} timeToConvert - Total time to convert (in hours)
|
|
304
|
+
* @param {number} numSteps - Number of steps in funnel
|
|
305
|
+
* @returns {Array} Events with timing information
|
|
306
|
+
*/
|
|
307
|
+
function addTimingOffsets(events, timeToConvert, numSteps) {
|
|
308
|
+
const msInHour = 60000 * 60;
|
|
309
|
+
let lastTimeJump = 0;
|
|
310
|
+
|
|
311
|
+
return events.map((event, index) => {
|
|
312
|
+
if (index === 0) {
|
|
313
|
+
event.relativeTimeMs = 0;
|
|
314
|
+
return event;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Calculate base increment for each step
|
|
318
|
+
const baseIncrement = (timeToConvert * msInHour) / numSteps;
|
|
319
|
+
|
|
320
|
+
// Add random fluctuation
|
|
321
|
+
const fluctuation = u.integer(
|
|
322
|
+
-baseIncrement / u.integer(3, 5),
|
|
323
|
+
baseIncrement / u.integer(3, 5)
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Ensure increasing timestamps
|
|
327
|
+
const previousTime = lastTimeJump;
|
|
328
|
+
const currentTime = previousTime + baseIncrement + fluctuation;
|
|
329
|
+
const chosenTime = Math.max(currentTime, previousTime + 1);
|
|
330
|
+
|
|
331
|
+
lastTimeJump = chosenTime;
|
|
332
|
+
event.relativeTimeMs = chosenTime;
|
|
333
|
+
|
|
334
|
+
return event;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Generates actual events with proper timing
|
|
340
|
+
* @param {Context} context - Context object
|
|
341
|
+
* @param {Array} eventsWithTiming - Events with timing information
|
|
342
|
+
* @param {string} distinct_id - User ID
|
|
343
|
+
* @param {number} earliestTime - Base timestamp
|
|
344
|
+
* @param {Array} anonymousIds - Anonymous IDs
|
|
345
|
+
* @param {Array} sessionIds - Session IDs
|
|
346
|
+
* @param {Array} groupKeys - Group keys
|
|
347
|
+
* @returns {Promise<Array>} Generated events
|
|
348
|
+
*/
|
|
349
|
+
async function generateFunnelEvents(
|
|
350
|
+
context,
|
|
351
|
+
eventsWithTiming,
|
|
352
|
+
distinct_id,
|
|
353
|
+
earliestTime,
|
|
354
|
+
anonymousIds,
|
|
355
|
+
sessionIds,
|
|
356
|
+
groupKeys
|
|
357
|
+
) {
|
|
358
|
+
let funnelStartTime;
|
|
359
|
+
|
|
360
|
+
const finalEvents = await Promise.all(eventsWithTiming.map(async (event, index) => {
|
|
361
|
+
const newEvent = await makeEvent(
|
|
362
|
+
context,
|
|
363
|
+
distinct_id,
|
|
364
|
+
earliestTime,
|
|
365
|
+
event,
|
|
366
|
+
anonymousIds,
|
|
367
|
+
sessionIds,
|
|
368
|
+
{},
|
|
369
|
+
groupKeys,
|
|
370
|
+
false // Let all funnel events use TimeSoup for proper time distribution
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (index === 0) {
|
|
374
|
+
const parsedTime = dayjs(newEvent.time);
|
|
375
|
+
// Validate the first event's time - if invalid, use TimeSoup-generated time as-is
|
|
376
|
+
funnelStartTime = parsedTime.isValid() ? parsedTime : null;
|
|
377
|
+
delete newEvent.relativeTimeMs;
|
|
378
|
+
return newEvent;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// If funnelStartTime is invalid, just use the TimeSoup-generated time from makeEvent
|
|
382
|
+
if (!funnelStartTime || !funnelStartTime.isValid()) {
|
|
383
|
+
delete newEvent.relativeTimeMs;
|
|
384
|
+
return newEvent;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
let computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
|
|
389
|
+
// Drop events that would land in the future
|
|
390
|
+
if (context.MAX_TIME && computedTime.unix() > context.MAX_TIME) {
|
|
391
|
+
newEvent._drop = true;
|
|
392
|
+
}
|
|
393
|
+
if (computedTime.isValid()) {
|
|
394
|
+
newEvent.time = computedTime.toISOString();
|
|
395
|
+
}
|
|
396
|
+
// If invalid, keep the TimeSoup-generated time from makeEvent
|
|
397
|
+
delete newEvent.relativeTimeMs;
|
|
398
|
+
return newEvent;
|
|
399
|
+
} catch (e) {
|
|
400
|
+
// Graceful fallback: keep the TimeSoup-generated time from makeEvent
|
|
401
|
+
delete newEvent.relativeTimeMs;
|
|
402
|
+
return newEvent;
|
|
403
|
+
}
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
return finalEvents;
|
|
407
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror dataset generator module
|
|
3
|
+
* Creates mirror datasets in a future state with different transformation strategies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types').Context} Context */
|
|
7
|
+
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
import * as u from "../utils/utils.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Takes event data and creates mirror datasets in a future state
|
|
13
|
+
* depending on the mirror strategy configuration
|
|
14
|
+
* @param {Context} context - Context object containing config, defaults, etc.
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
export async function makeMirror(context) {
|
|
18
|
+
const { config, storage } = context;
|
|
19
|
+
const { mirrorProps } = config;
|
|
20
|
+
const { eventData, mirrorEventData } = storage;
|
|
21
|
+
|
|
22
|
+
if (!mirrorProps || Object.keys(mirrorProps).length === 0) {
|
|
23
|
+
return; // No mirror properties configured
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const now = dayjs();
|
|
27
|
+
|
|
28
|
+
for (const oldEvent of eventData) {
|
|
29
|
+
let newEvent = null;
|
|
30
|
+
const eventTime = dayjs(oldEvent.time);
|
|
31
|
+
const delta = now.diff(eventTime, "day");
|
|
32
|
+
|
|
33
|
+
for (const mirrorProp in mirrorProps) {
|
|
34
|
+
const prop = mirrorProps[mirrorProp];
|
|
35
|
+
const {
|
|
36
|
+
daysUnfilled = 7,
|
|
37
|
+
events = "*",
|
|
38
|
+
strategy = "create",
|
|
39
|
+
values = []
|
|
40
|
+
} = prop;
|
|
41
|
+
|
|
42
|
+
// Check if this event should be processed
|
|
43
|
+
if (shouldProcessEvent(oldEvent.event, events)) {
|
|
44
|
+
// Clone event only when needed
|
|
45
|
+
if (!newEvent) {
|
|
46
|
+
newEvent = u.deepClone(oldEvent);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Apply the specified strategy
|
|
50
|
+
applyMirrorStrategy(
|
|
51
|
+
strategy,
|
|
52
|
+
newEvent,
|
|
53
|
+
oldEvent,
|
|
54
|
+
mirrorProp,
|
|
55
|
+
values,
|
|
56
|
+
delta,
|
|
57
|
+
daysUnfilled
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Push the processed event (or original if no changes)
|
|
63
|
+
const mirrorDataPoint = newEvent || oldEvent;
|
|
64
|
+
await mirrorEventData.hookPush(mirrorDataPoint);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Determines if an event should be processed based on event filter
|
|
70
|
+
* @param {string} eventName - Name of the event to check
|
|
71
|
+
* @param {string|Array} eventFilter - Event filter ("*" for all, or array of event names)
|
|
72
|
+
* @returns {boolean} True if event should be processed
|
|
73
|
+
*/
|
|
74
|
+
function shouldProcessEvent(eventName, eventFilter) {
|
|
75
|
+
if (eventFilter === "*") {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(eventFilter)) {
|
|
80
|
+
return eventFilter.includes(eventName);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Applies the specified mirror strategy to an event
|
|
88
|
+
* @param {string} strategy - Mirror strategy to apply
|
|
89
|
+
* @param {Object} newEvent - Event object to modify
|
|
90
|
+
* @param {Object} oldEvent - Original event object
|
|
91
|
+
* @param {string} propName - Property name to modify
|
|
92
|
+
* @param {Array} values - Possible values for the property
|
|
93
|
+
* @param {number} delta - Days between event time and now
|
|
94
|
+
* @param {number} daysUnfilled - Days threshold for fill strategy
|
|
95
|
+
*/
|
|
96
|
+
function applyMirrorStrategy(strategy, newEvent, oldEvent, propName, values, delta, daysUnfilled) {
|
|
97
|
+
switch (strategy) {
|
|
98
|
+
case "create":
|
|
99
|
+
// Always add the property with a random value
|
|
100
|
+
newEvent[propName] = u.choose(values);
|
|
101
|
+
break;
|
|
102
|
+
|
|
103
|
+
case "delete":
|
|
104
|
+
// Remove the property from the event
|
|
105
|
+
delete newEvent[propName];
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case "fill":
|
|
109
|
+
// Fill missing properties if enough time has passed
|
|
110
|
+
if (delta >= daysUnfilled) {
|
|
111
|
+
oldEvent[propName] = u.choose(values);
|
|
112
|
+
}
|
|
113
|
+
newEvent[propName] = u.choose(values);
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
case "update":
|
|
117
|
+
// Update only if property doesn't exist
|
|
118
|
+
if (!oldEvent[propName]) {
|
|
119
|
+
newEvent[propName] = u.choose(values);
|
|
120
|
+
} else {
|
|
121
|
+
newEvent[propName] = oldEvent[propName];
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
|
|
125
|
+
default:
|
|
126
|
+
throw new Error(`Unknown mirror strategy: ${strategy}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validates mirror properties configuration
|
|
132
|
+
* @param {Object} mirrorProps - Mirror properties configuration to validate
|
|
133
|
+
* @returns {boolean} True if valid, throws error if invalid
|
|
134
|
+
*/
|
|
135
|
+
export function validateMirrorProps(mirrorProps) {
|
|
136
|
+
if (!mirrorProps || typeof mirrorProps !== 'object') {
|
|
137
|
+
return true; // Mirror props are optional
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const validStrategies = ['create', 'delete', 'fill', 'update'];
|
|
141
|
+
|
|
142
|
+
for (const [propName, config] of Object.entries(mirrorProps)) {
|
|
143
|
+
if (!config || typeof config !== 'object') {
|
|
144
|
+
throw new Error(`Mirror property '${propName}' must have a configuration object`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { strategy = 'create', values = [], events = '*', daysUnfilled = 7 } = config;
|
|
148
|
+
|
|
149
|
+
if (!validStrategies.includes(strategy)) {
|
|
150
|
+
throw new Error(`Invalid mirror strategy '${strategy}' for property '${propName}'. Must be one of: ${validStrategies.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (strategy !== 'delete' && (!values || !Array.isArray(values) || values.length === 0)) {
|
|
154
|
+
throw new Error(`Mirror property '${propName}' with strategy '${strategy}' must have non-empty values array`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (events !== '*' && (!Array.isArray(events) || events.length === 0)) {
|
|
158
|
+
throw new Error(`Mirror property '${propName}' events filter must be "*" or non-empty array`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof daysUnfilled !== 'number' || daysUnfilled < 0) {
|
|
162
|
+
throw new Error(`Mirror property '${propName}' daysUnfilled must be a non-negative number`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return true;
|
|
167
|
+
}
|