@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.
Files changed (66) hide show
  1. package/README.md +518 -0
  2. package/dungeons/array-of-object-lookup-schema.json +327 -0
  3. package/dungeons/array-of-object-lookup.js +220 -0
  4. package/dungeons/ecommerce-schema.json +462 -0
  5. package/dungeons/ecommerce.js +447 -0
  6. package/dungeons/education-schema.json +2409 -0
  7. package/dungeons/education.js +768 -0
  8. package/dungeons/fintech-schema.json +14034 -0
  9. package/dungeons/fintech.js +696 -0
  10. package/dungeons/foobar-schema.json +403 -0
  11. package/dungeons/foobar.js +296 -0
  12. package/dungeons/food-delivery-schema.json +192 -0
  13. package/dungeons/food-delivery.js +602 -0
  14. package/dungeons/food-schema.json +1152 -0
  15. package/dungeons/food.js +754 -0
  16. package/dungeons/gaming-schema.json +1270 -0
  17. package/dungeons/gaming.js +508 -0
  18. package/dungeons/insurance-application-schema.json +204 -0
  19. package/dungeons/insurance-application.js +605 -0
  20. package/dungeons/media-schema.json +906 -0
  21. package/dungeons/media.js +790 -0
  22. package/dungeons/retention-cadence-schema.json +78 -0
  23. package/dungeons/retention-cadence.js +244 -0
  24. package/dungeons/rpg-schema.json +4526 -0
  25. package/dungeons/rpg.js +919 -0
  26. package/dungeons/sanity-schema.json +255 -0
  27. package/dungeons/sanity.js +152 -0
  28. package/dungeons/sass-schema.json +1291 -0
  29. package/dungeons/sass.js +795 -0
  30. package/dungeons/scd-schema.json +919 -0
  31. package/dungeons/scd.js +277 -0
  32. package/dungeons/simple-schema.json +608 -0
  33. package/dungeons/simple.js +285 -0
  34. package/dungeons/simplest-schema.json +1418 -0
  35. package/dungeons/simplest.js +392 -0
  36. package/dungeons/social-schema.json +1118 -0
  37. package/dungeons/social.js +686 -0
  38. package/dungeons/text-generation-schema.json +3096 -0
  39. package/dungeons/text-generation.js +812 -0
  40. package/index.js +567 -0
  41. package/lib/core/config-validator.js +395 -0
  42. package/lib/core/context.js +204 -0
  43. package/lib/core/dungeon-loader.js +337 -0
  44. package/lib/core/storage.js +379 -0
  45. package/lib/generators/adspend.js +132 -0
  46. package/lib/generators/events.js +271 -0
  47. package/lib/generators/funnels.js +407 -0
  48. package/lib/generators/mirror.js +167 -0
  49. package/lib/generators/product-lookup.js +262 -0
  50. package/lib/generators/product-names.js +195 -0
  51. package/lib/generators/profiles.js +93 -0
  52. package/lib/generators/scd.js +124 -0
  53. package/lib/generators/text.js +1192 -0
  54. package/lib/orchestrators/mixpanel-sender.js +266 -0
  55. package/lib/orchestrators/user-loop.js +335 -0
  56. package/lib/templates/abbreviated.d.ts +169 -0
  57. package/lib/templates/defaults.js +1405 -0
  58. package/lib/templates/phrases.js +2526 -0
  59. package/lib/templates/schema.d.ts +173 -0
  60. package/lib/templates/soup-presets.js +188 -0
  61. package/lib/utils/function-registry.js +302 -0
  62. package/lib/utils/json-evaluator.js +172 -0
  63. package/lib/utils/logger.js +34 -0
  64. package/lib/utils/utils.js +1490 -0
  65. package/package.json +89 -0
  66. 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
+ }