@ak--47/dungeon-master 1.4.2 → 1.4.4

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.
@@ -13,6 +13,7 @@ import utc from "dayjs/plugin/utc.js";
13
13
  import "dotenv/config";
14
14
  import * as u from "../../lib/utils/utils.js";
15
15
  import * as v from "ak-tools";
16
+ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
16
17
 
17
18
  dayjs.extend(utc);
18
19
  const chance = u.initChance(SEED);
@@ -52,15 +53,14 @@ const chance = u.initChance(SEED);
52
53
 
53
54
  /*
54
55
  * ═══════════════════════════════════════════════════════════════════════════════
55
- * ANALYTICS HOOKS (10 hooks)
56
+ * ANALYTICS HOOKS (11 hooks)
56
57
  * ═══════════════════════════════════════════════════════════════════════════════
57
58
  *
58
59
  * 10 deliberately architected patterns hidden in the data. NOTE: All cohort
59
60
  * effects are HIDDEN — no flag stamping. Discoverable via behavioral cohorts
60
61
  * or raw-prop breakdowns (company_size, day, doc_section). Adds:
61
- * 9. INCIDENT RESPONSE TIME-TO-CONVERT (Enterprise 1.4x faster vs Startup)
62
- * [funnel-post: visible only in Mixpanel funnel median TTC; cross-event
63
- * MIN→MIN SQL queries do NOT show this]
62
+ * 9. INCIDENT RESPONSE TIME-TO-CONVERT (Enterprise 0.67x gap vs Startup 1.5x)
63
+ * [everything hook: scales response_time_mins and resolution_time_mins by company_size]
64
64
  * 10. DOCS MAGIC NUMBER (sweet 4-7 docs → +40% deploys; over 8+ → drop 25%)
65
65
  *
66
66
  * ─────────────────────────────────────────────────────────────────────────────
@@ -264,6 +264,120 @@ const chance = u.initChance(SEED);
264
264
  * customer segments — from a $99/mo startup to a $500K Fortune 500 contract.
265
265
  *
266
266
  * ─────────────────────────────────────────────────────────────────────────────
267
+ * 9. INCIDENT RESPONSE TTC (everything)
268
+ * ─────────────────────────────────────────────────────────────────────────────
269
+ *
270
+ * PATTERN: Enterprise companies resolve incidents faster; startups resolve
271
+ * slower. The hook reads company_size from the user profile and scales
272
+ * response_time_mins (on "alert acknowledged") and resolution_time_mins
273
+ * (on "alert resolved") by a factor: enterprise 0.67x, startup 1.5x,
274
+ * smb/mid_market unchanged. Additionally, the first incident-response
275
+ * funnel sequence (alert triggered → alert acknowledged → alert resolved)
276
+ * has its inter-step timestamps scaled via scaleFunnelTTC with the same
277
+ * factor, so Mixpanel funnel TTC reports reflect the gap. This compounds
278
+ * with Hook 4 (integration users) — an enterprise user with both Slack
279
+ * and PagerDuty configured gets the fastest resolution times.
280
+ *
281
+ * HOW TO FIND IT IN MIXPANEL:
282
+ *
283
+ * Report 1: Avg Response Time by Company Size
284
+ * - Report type: Insights
285
+ * - Event: "alert acknowledged"
286
+ * - Measure: Average of "response_time_mins"
287
+ * - Breakdown: "company_size" (user property)
288
+ * - Expected: enterprise ~ 0.67x startup
289
+ *
290
+ * Report 2: Incident Funnel TTC by Company Size
291
+ * - Report type: Funnels
292
+ * - Steps: "alert triggered" → "alert acknowledged" → "alert resolved"
293
+ * - Measure: Median time to convert
294
+ * - Breakdown: "company_size"
295
+ * - Expected: enterprise ~ 0.67x median TTC vs startup ~ 1.5x
296
+ *
297
+ * Report 3: Avg Resolution Time by Company Size
298
+ * - Report type: Insights
299
+ * - Event: "alert resolved"
300
+ * - Measure: Average of "resolution_time_mins"
301
+ * - Breakdown: "company_size"
302
+ * - Expected: enterprise ~ 0.67x startup
303
+ *
304
+ * REAL-WORLD ANALOGUE: Enterprise teams have dedicated SRE rotations,
305
+ * automated runbooks, and premium support contracts that compress
306
+ * incident timelines. Startups rely on smaller teams with less tooling.
307
+ *
308
+ * ─────────────────────────────────────────────────────────────────────────────
309
+ * 10. DOCS MAGIC NUMBER (everything)
310
+ * ─────────────────────────────────────────────────────────────────────────────
311
+ *
312
+ * PATTERN: Users in the 4-7 documentation-viewed sweet spot get 2-3 extra
313
+ * production "service deployed" events cloned into their stream (boosting
314
+ * deploy frequency ~40%). Users with 8+ documentation views are over-
315
+ * engaged browsers; 25% of their "service deployed" events are dropped.
316
+ * No flag is stamped — discoverable only by binning users on doc-view
317
+ * count and comparing per-user deploy volume.
318
+ *
319
+ * HOW TO FIND IT IN MIXPANEL:
320
+ *
321
+ * Report 1: Deploys per User by Docs-View Bucket
322
+ * - Report type: Insights (with cohort)
323
+ * - Cohort A: users with 4-7 "documentation viewed" events
324
+ * - Cohort B: users with 0-3 "documentation viewed" events
325
+ * - Event: "service deployed"
326
+ * - Measure: Total per user
327
+ * - Expected: Cohort A ~ 1.4x deploys per user vs Cohort B
328
+ *
329
+ * Report 2: Deploys per User on Heavy Doc Readers
330
+ * - Report type: Insights (with cohort)
331
+ * - Cohort C: users with >= 8 "documentation viewed" events
332
+ * - Cohort A: users with 4-7
333
+ * - Event: "service deployed"
334
+ * - Measure: Total per user
335
+ * - Expected: Cohort C ~ 25% fewer deploys per user vs Cohort A
336
+ *
337
+ * REAL-WORLD ANALOGUE: Engineers who read just enough docs deploy with
338
+ * confidence; those who read excessively may be stuck troubleshooting
339
+ * and never ship, or are evaluating the product without committing.
340
+ *
341
+ * ─────────────────────────────────────────────────────────────────────────────
342
+ * 11. DEPLOY PIPELINE EXPERIMENT (funnel experiment — engine-managed)
343
+ * ─────────────────────────────────────────────────────────────────────────────
344
+ *
345
+ * PATTERN: The deployment funnel (deployment pipeline run → service deployed
346
+ * → dashboard viewed) runs a "Canary Deploys" experiment starting 45 days
347
+ * before dataset end. Users are randomly assigned to Control or "Canary
348
+ * Deploys" variant. The Canary variant gets 1.2x conversion multiplier and
349
+ * 0.85x time-to-convert multiplier (faster + higher conversion). The engine
350
+ * emits `$experiment_started` events with `Experiment Name` and
351
+ * `Variant Name` properties. No hook code needed — the engine handles
352
+ * variant assignment and conversion/TTC scaling.
353
+ *
354
+ * HOW TO FIND IT IN MIXPANEL:
355
+ *
356
+ * Report 1: Experiment Enrollment
357
+ * - Report type: Insights
358
+ * - Event: "$experiment_started"
359
+ * - Measure: Total
360
+ * - Breakdown: "Variant Name"
361
+ * - Expected: roughly even split between "Control" and "Canary Deploys"
362
+ *
363
+ * Report 2: Deploy Funnel by Variant
364
+ * - Report type: Funnels
365
+ * - Steps: "deployment pipeline run" → "service deployed" → "dashboard viewed"
366
+ * - Breakdown: "Variant Name" (user property)
367
+ * - Expected: Canary variant ~ 1.2x conversion vs Control
368
+ *
369
+ * Report 3: Deploy TTC by Variant
370
+ * - Report type: Funnels
371
+ * - Steps: same as above
372
+ * - Measure: Median time to convert
373
+ * - Breakdown: "Variant Name"
374
+ * - Expected: Canary variant ~ 0.85x TTC vs Control (faster)
375
+ *
376
+ * REAL-WORLD ANALOGUE: Teams A/B test canary deployment strategies.
377
+ * Canary deploys catch issues earlier, improving both success rate and
378
+ * deployment velocity.
379
+ *
380
+ * ─────────────────────────────────────────────────────────────────────────────
267
381
  * EXPECTED METRICS SUMMARY
268
382
  * ─────────────────────────────────────────────────────────────────────────────
269
383
  *
@@ -277,6 +391,12 @@ const chance = u.initChance(SEED);
277
391
  * Cost Overrun | Scale-down after overrun | 50% | 100% | 2x
278
392
  * Failed Deploy Recovery | Deploy duration (sec) | ~500 | ~750 | 1.5x
279
393
  * Enterprise vs Startup | ACV range | $0-3.6K | $50K-500K | 100x+
394
+ * Incident Response TTC | Enterprise response_time | 1x | 0.67x | -33%
395
+ * Incident Response TTC | Startup response_time | 1x | 1.5x | +50%
396
+ * Docs Magic Number | sweet (4-7) deploys/user | 1x | ~1.4x | +40%
397
+ * Docs Magic Number | over (8+) deploys/user | 1x | ~0.75x | -25%
398
+ * Deploy Experiment | Canary conversion | 65% | ~78% | 1.2x
399
+ * Deploy Experiment | Canary TTC | 1d | ~0.85d | 0.85x
280
400
  *
281
401
  * ─────────────────────────────────────────────────────────────────────────────
282
402
  * ADVANCED ANALYSIS IDEAS
@@ -392,6 +512,14 @@ const config = {
392
512
  conversionRate: 65,
393
513
  timeToConvert: 1,
394
514
  weight: 3,
515
+ experiment: {
516
+ name: "Canary Deploys",
517
+ variants: [
518
+ { name: "Control" },
519
+ { name: "Canary Deploys", conversionMultiplier: 1.2, ttcMultiplier: 0.85 },
520
+ ],
521
+ startDaysBeforeEnd: 45,
522
+ },
395
523
  },
396
524
  {
397
525
  // Infrastructure management
@@ -641,7 +769,9 @@ const config = {
641
769
  /**
642
770
  * ARCHITECTED ANALYTICS HOOKS
643
771
  *
644
- * This hook function creates 8 deliberate patterns in the data:
772
+ * This hook function creates 10 deliberate patterns in the data.
773
+ * Hook 11 (Deploy Pipeline Experiment) is engine-managed via funnel
774
+ * experiment config — no hook code needed.
645
775
  *
646
776
  * 1. END-OF-QUARTER SPIKE: Days 100-110 drive plan upgrades and team expansion
647
777
  * 2. CHURNED ACCOUNT SILENCING: ~10% of users go completely silent after month 1
@@ -651,6 +781,9 @@ const config = {
651
781
  * 6. COST OVERRUN PATTERN: Budget-exceeded users react by scaling down infrastructure
652
782
  * 7. FAILED DEPLOYMENT RECOVERY: Recovery deploys take 1.5x longer, tracked across calls
653
783
  * 8. ENTERPRISE VS STARTUP: Company size determines seat count, ACV, and health score
784
+ * 9. INCIDENT RESPONSE TTC: Enterprise 0.67x faster, startup 1.5x slower incident resolution
785
+ * 10. DOCS MAGIC NUMBER: Sweet 4-7 docs → extra deploys; over 8+ → drop 25% of deploys
786
+ * 11. DEPLOY PIPELINE EXPERIMENT: Canary Deploys A/B test on deployment funnel (engine-managed)
654
787
  */
655
788
  hook: function (record, type, meta) {
656
789
  // (Hook 1a moved to everything hook for reliable datasetStart access)
@@ -803,25 +936,33 @@ const config = {
803
936
  }
804
937
 
805
938
  // HOOK 9: INCIDENT RESPONSE TTC — enterprise resolves faster,
806
- // startup resolves slower. Scale the time gap between alert
807
- // triggered → acknowledged resolved for this user.
808
- const segment = profile?.company_size;
939
+ // startup resolves slower. Scale response_time_mins on
940
+ // acknowledged events and resolution_time_mins on resolved
941
+ // events by company_size. This compounds with H4 (integration
942
+ // users) — realistic: enterprise + good tooling = fastest.
943
+ const companySegment = profile?.company_size;
809
944
  const ttcFactor = (
810
- segment === "enterprise" ? 0.5 :
811
- segment === "startup" ? 1.8 :
945
+ companySegment === "enterprise" ? 0.67 :
946
+ companySegment === "startup" ? 1.5 :
812
947
  1.0
813
948
  );
814
949
  if (ttcFactor !== 1.0) {
815
- const alertEvents = userEvents
816
- .filter(e => e.event === "alert triggered" || e.event === "alert acknowledged" || e.event === "alert resolved")
817
- .sort((a, b) => a.time.localeCompare(b.time));
818
- for (let i = 1; i < alertEvents.length; i++) {
819
- const prev = dayjs(alertEvents[i - 1].time);
820
- const curr = dayjs(alertEvents[i].time);
821
- const gap = curr.diff(prev);
822
- const newGap = Math.round(gap * ttcFactor);
823
- alertEvents[i].time = prev.add(newGap, "milliseconds").toISOString();
824
- }
950
+ // Timestamp shift: affects Mixpanel funnel TTC
951
+ const incidentSeq = findFirstSequence(
952
+ userEvents,
953
+ ["alert triggered", "alert acknowledged", "alert resolved"],
954
+ 60 * 24 * 30
955
+ );
956
+ if (incidentSeq) scaleFunnelTTC(incidentSeq, ttcFactor);
957
+ // Property scale: affects Insights AVG reports
958
+ userEvents.forEach(e => {
959
+ if (e.event === "alert acknowledged" && e.response_time_mins) {
960
+ e.response_time_mins = Math.max(1, Math.round(e.response_time_mins * ttcFactor));
961
+ }
962
+ if (e.event === "alert resolved" && e.resolution_time_mins) {
963
+ e.resolution_time_mins = Math.max(1, Math.round(e.resolution_time_mins * ttcFactor));
964
+ }
965
+ });
825
966
  }
826
967
  }
827
968
 
@@ -252,6 +252,30 @@ const chance = u.initChance(SEED);
252
252
  * REAL-WORLD ANALOGUE: Moderate posters write thoughtful comments; the
253
253
  * over-prolific tend to spam and burn through audience patience.
254
254
  *
255
+ * -------------------------------------------------------------------------------------
256
+ * 10. ONBOARDING TIME-TO-CONVERT (funnel-post)
257
+ * -------------------------------------------------------------------------------------
258
+ *
259
+ * PATTERN: Creator and business account_type users complete the
260
+ * onboarding funnel (account created -> profile updated -> post created)
261
+ * 1.4x faster (factor 0.71). Personal account users complete it 1.25x
262
+ * slower (factor 1.25). The hook intercepts funnel-post arrays, computes
263
+ * the time gap between consecutive steps, and scales each gap by the
264
+ * account-type-specific factor before rewriting the step timestamps.
265
+ *
266
+ * HOW TO FIND IT IN MIXPANEL:
267
+ *
268
+ * Report 1: Onboarding Funnel Median TTC by Account Type
269
+ * - Report type: Funnels
270
+ * - Steps: "account created" -> "profile updated" -> "post created"
271
+ * - Measure: Median time to convert
272
+ * - Breakdown: "account_type" (superProp)
273
+ * - Expected: creator/business ~ 0.71x baseline; personal ~ 1.25x baseline
274
+ *
275
+ * REAL-WORLD ANALOGUE: Creators and businesses arrive with clear intent
276
+ * and complete profile setup faster; personal users browse casually and
277
+ * take longer to commit to their first post.
278
+ *
255
279
  * =====================================================================================
256
280
  * EXPECTED METRICS SUMMARY
257
281
  * =====================================================================================
@@ -268,6 +292,7 @@ const chance = u.initChance(SEED);
268
292
  * Weekend Surge | Weekend vs weekday | 1x | ~ 1.3x | 1.3x
269
293
  * Post-Created Magic Number | sweet comment_length | 1x | ~ 1.4x | 1.4x
270
294
  * Post-Created Magic Number | over engagement/user | 1x | ~ 0.7x | -30%
295
+ * Onboarding TTC | median TTC by acct type | 1x | 0.71/1.25x | ~ 1.8x range
271
296
  * =====================================================================================
272
297
  */
273
298
 
@@ -112,7 +112,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
112
112
  * ───────────────────────────────────────────────────────────────
113
113
  * 4. CANCELLATION BY BOOKING WINDOW (everything hook)
114
114
  * ───────────────────────────────────────────────────────────────
115
- * PATTERN: Users who book last-minute (< 7 days out) have 40% of
115
+ * PATTERN: Users who book last-minute (< 7 days out) have 60% of
116
116
  * their "booking cancelled" events dropped — they rarely cancel.
117
117
  * Advance bookers keep all cancellation events.
118
118
  *
@@ -192,6 +192,74 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
192
192
  * REAL-WORLD ANALOGUE: Business travelers book the same hotels
193
193
  * repeatedly, leading to faster, more confident conversions.
194
194
  *
195
+ * ───────────────────────────────────────────────────────────────
196
+ * 9. BOOKING TIME-TO-CONVERT (funnel-post hook)
197
+ * ───────────────────────────────────────────────────────────────
198
+ *
199
+ * PATTERN: Business travelers complete the Search-to-Book funnel
200
+ * 1.35x faster (time gaps scaled by 0.74). Budget and leisure-family
201
+ * users complete it 1.25x slower (gaps scaled by 1.25). The hook
202
+ * iterates over the funnel-post event array, compresses or stretches
203
+ * the inter-step time gaps based on the user's customer_segment from
204
+ * meta.profile, then rewrites each event's timestamp.
205
+ *
206
+ * HOW TO FIND IT IN MIXPANEL:
207
+ *
208
+ * Report 1: Search-to-Book TTC by Segment
209
+ * - Report type: Funnels
210
+ * - Steps: "destination searched" -> "hotel viewed" -> "price compared" -> "booking completed"
211
+ * - Breakdown: user property "customer_segment"
212
+ * - Metric: Median time to convert
213
+ * - Expected: business_traveler median TTC ~ 0.74x of budget/leisure TTC
214
+ * (e.g., business ~ 24h vs budget ~ 43h)
215
+ *
216
+ * NOTE: This effect is visible ONLY in Mixpanel funnel median TTC.
217
+ * Cross-event MIN->MIN SQL queries on raw events do NOT show this
218
+ * because funnel-post mutates timestamps after event generation but
219
+ * before storage.
220
+ *
221
+ * REAL-WORLD ANALOGUE: Business travelers know their preferred hotel
222
+ * chains and corporate rates, moving from search to booking with
223
+ * minimal comparison. Leisure and budget travelers deliberate longer,
224
+ * comparing options and waiting for deals.
225
+ *
226
+ * ───────────────────────────────────────────────────────────────
227
+ * 10. HOTEL-VIEWED MAGIC NUMBER (everything hook)
228
+ * ───────────────────────────────────────────────────────────────
229
+ *
230
+ * PATTERN: Users who viewed 5-10 hotels sit in a "decisive comparison
231
+ * shopper" sweet spot -- all their nightly_rate values on "booking
232
+ * completed" events are boosted by +30% (factor 1.3), indicating they
233
+ * chose higher-tier rooms after deliberate comparison. Users who
234
+ * viewed 11+ hotels trigger analysis paralysis: 35% of their
235
+ * "booking completed" events are dropped entirely. No flag is
236
+ * stamped -- discoverable only by binning users on hotel-viewed
237
+ * COUNT and comparing booking revenue or conversion volume.
238
+ *
239
+ * HOW TO FIND IT IN MIXPANEL:
240
+ *
241
+ * Report 1: Nightly Rate by Hotel-View Cohort
242
+ * - Report type: Insights (with cohorts)
243
+ * - Cohort A: users who did "hotel viewed" 5-10 times
244
+ * - Cohort B: users who did "hotel viewed" 0-4 times
245
+ * - Event: "booking completed"
246
+ * - Measure: Average of "nightly_rate"
247
+ * - Compare cohort A vs cohort B
248
+ * - Expected: cohort A ~ 1.3x higher avg nightly_rate
249
+ *
250
+ * Report 2: Bookings per User by Hotel-View Volume
251
+ * - Report type: Insights (with cohorts)
252
+ * - Cohort C: users who did "hotel viewed" 11+ times
253
+ * - Cohort A: users who did "hotel viewed" 5-10 times
254
+ * - Event: "booking completed"
255
+ * - Measure: Total events per user
256
+ * - Compare cohort C vs cohort A
257
+ * - Expected: cohort C ~ 35% fewer bookings per user
258
+ *
259
+ * REAL-WORLD ANALOGUE: Travelers who compare a handful of hotels
260
+ * make confident, higher-value bookings; those who endlessly browse
261
+ * suffer decision fatigue and often abandon the search entirely.
262
+ *
195
263
  * ═══════════════════════════════════════════════════════════════
196
264
  * EXPECTED METRICS SUMMARY
197
265
  * ═══════════════════════════════════════════════════════════════
@@ -206,6 +274,9 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
206
274
  * Review Quality | review_length | 120 | 180/80 | 1.5x/0.67x
207
275
  * Business Profile | travel_frequency | mixed | weekly | —
208
276
  * Repeat Destination | funnel conversion | 40% | 52% | 1.3x
277
+ * Booking TTC | funnel median TTC | 1x | 0.74x | 1.35x faster (business)
278
+ * Hotel-Viewed Magic Num | sweet nightly_rate | 1x | 1.3x | +30%
279
+ * Hotel-Viewed Magic Num | over bookings/user | 1x | 0.65x | -35%
209
280
  */
210
281
 
211
282
  /** @type {Config} */
@@ -774,10 +845,10 @@ const config = {
774
845
  }
775
846
  });
776
847
 
777
- // Last-minute bookers rarely cancel — drop 40%.
848
+ // Last-minute bookers rarely cancel — drop 60%.
778
849
  for (let i = events.length - 1; i >= 0; i--) {
779
850
  if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
780
- if (chance.bool({ likelihood: 40 })) {
851
+ if (chance.bool({ likelihood: 60 })) {
781
852
  events.splice(i, 1);
782
853
  }
783
854
  }
package/index.js CHANGED
@@ -25,7 +25,7 @@ import { makeMirror } from './lib/generators/mirror.js';
25
25
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
26
26
 
27
27
  // Utilities
28
- import { initChance, setDatasetNow, deleteFile } from './lib/utils/utils.js';
28
+ import { initChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
29
29
 
30
30
  // External dependencies
31
31
  import dayjs from "dayjs";
@@ -143,6 +143,7 @@ async function runDungeon(config) {
143
143
  // dungeon configs. Without this, those factories produce values relative
144
144
  // to process-start, which leaks wall-clock time into the output.
145
145
  setDatasetNow(fixedNow);
146
+ setDatasetBegin(fixedBegin);
146
147
 
147
148
  // Step 2: Create context with validated config (pass time constants explicitly)
148
149
  const context = createContext(validatedConfig, null, { fixedNow, fixedBegin });
@@ -283,7 +283,7 @@ export function validateDungeonConfig(config) {
283
283
  hasAnonIds = false,
284
284
  hasSessionIds = false,
285
285
  sessionTimeout = 30,
286
- format = "csv",
286
+ format,
287
287
  token = null,
288
288
  region = "US",
289
289
  writeToDisk = false,
@@ -592,7 +592,7 @@ export function validateDungeonConfig(config) {
592
592
  avgDevicePerUser,
593
593
  hasSessionIds,
594
594
  sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
595
- format,
595
+ format: format || (typeof writeToDisk === 'string' && writeToDisk.startsWith('gs://') ? 'json' : 'csv'),
596
596
  token,
597
597
  region,
598
598
  writeToDisk,
@@ -121,7 +121,8 @@ export async function sendToMixpanel(context) {
121
121
  // Import group profiles
122
122
  if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
123
123
  for (const groupEntity of groupProfilesData) {
124
- if (!groupEntity || groupEntity.length === 0) continue;
124
+ if (!groupEntity) continue;
125
+ if (groupEntity.length === 0 && !isBATCH_MODE) continue;
125
126
  const groupKey = groupEntity?.groupKey;
126
127
  log(` Group Profiles (${groupKey})`);
127
128
  let groupProfilesToImport = u.deepClone(groupEntity);
@@ -141,7 +142,7 @@ export async function sendToMixpanel(context) {
141
142
  }
142
143
 
143
144
  // Import group events
144
- if (groupEventData?.length > 0) {
145
+ if (groupEventData?.length > 0 || (isBATCH_MODE && groupEventData)) {
145
146
  log(` Group Events`);
146
147
  let groupEventDataToImport = u.deepClone(groupEventData);
147
148
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
@@ -33,19 +33,29 @@ const weightedArrayCache = new Map();
33
33
  // window is resolved, so date helpers in dungeon configs produce deterministic
34
34
  // values relative to the dataset end (not the process start).
35
35
  let DATASET_NOW = dayjs.utc();
36
+ let DATASET_BEGIN = dayjs.utc().subtract(30, 'day');
36
37
 
37
- /**
38
- * Override the reference "now" used by date()/day() factories. Called by the
39
- * orchestrator with the resolved dataset end so every date helper produces
40
- * deterministic values relative to the dataset window.
41
- * @param {number} unixSeconds
42
- */
38
+ /** @param {number} unixSeconds */
43
39
  function setDatasetNow(unixSeconds) {
44
40
  if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds)) {
45
41
  DATASET_NOW = dayjs.unix(unixSeconds).utc();
46
42
  }
47
43
  }
48
44
 
45
+ /** @param {number} unixSeconds */
46
+ function setDatasetBegin(unixSeconds) {
47
+ if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds)) {
48
+ DATASET_BEGIN = dayjs.unix(unixSeconds).utc();
49
+ }
50
+ }
51
+
52
+
53
+ class ListValue extends Array {
54
+ constructor(items) {
55
+ super(items.length);
56
+ for (let i = 0; i < items.length; i++) this[i] = items[i];
57
+ }
58
+ }
49
59
 
50
60
  import { Storage as cloudStorage } from '@google-cloud/storage';
51
61
  const projectId = 'YOUR_PROJECT_ID';
@@ -198,11 +208,66 @@ function day(start, end) {
198
208
 
199
209
  };
200
210
 
211
+ /**
212
+ * @param {string|number|dayjs.Dayjs} [start] - Defaults to DATASET_BEGIN.
213
+ * @param {string|number|dayjs.Dayjs} [end] - Defaults to DATASET_NOW.
214
+ * @param {string|null} [format='YYYY-MM-DDTHH:mm:ss'] - null for ISO string.
215
+ * @returns {() => string}
216
+ */
217
+ function dateRange(start, end, format = 'YYYY-MM-DDTHH:mm:ss') {
218
+ return function () {
219
+ const chance = getChance();
220
+ const s = start != null ? dayjs(typeof start === 'number' ? start * 1000 : start).utc() : DATASET_BEGIN;
221
+ const e = end != null ? dayjs(typeof end === 'number' ? end * 1000 : end).utc() : DATASET_NOW;
222
+ const diffSec = e.diff(s, 'second');
223
+ const offsetSec = chance.integer({ min: 0, max: Math.max(0, diffSec) });
224
+ const result = s.add(offsetSec, 'second');
225
+ return format ? result.format(format) : result.toISOString();
226
+ };
227
+ }
228
+
229
+ /**
230
+ * @param {Array} pool - Values to pick from.
231
+ * @param {{ min?: number, max?: number }} [options]
232
+ * @returns {() => ListValue}
233
+ */
234
+ function listOf(pool, options = {}) {
235
+ const { min = 1, max = 3 } = options;
236
+ return function () {
237
+ const chance = getChance();
238
+ const count = chance.integer({ min, max: Math.min(max, pool.length) });
239
+ return new ListValue(chance.pickset(pool, count));
240
+ };
241
+ }
242
+
243
+ /**
244
+ * @param {Record<string, import('../../types.js').ValueValid>} template
245
+ * @param {{ min?: number, max?: number }} [options]
246
+ * @returns {() => ListValue}
247
+ */
248
+ function objectList(template, options = {}) {
249
+ const { min = 1, max = 5 } = options;
250
+ return function () {
251
+ const chance = getChance();
252
+ const count = chance.integer({ min, max });
253
+ const result = [];
254
+ for (let i = 0; i < count; i++) {
255
+ const obj = {};
256
+ for (const [key, val] of Object.entries(template)) {
257
+ obj[key] = choose(val);
258
+ }
259
+ result.push(obj);
260
+ }
261
+ return new ListValue(result);
262
+ };
263
+ }
264
+
201
265
  /**
202
266
  * similar to pick
203
267
  * @param {ValueValid} value
204
268
  */
205
269
  function choose(value) {
270
+ if (value instanceof ListValue) return value;
206
271
  const chance = getChance();
207
272
 
208
273
  // most of the time this will receive a list of strings;
@@ -245,6 +310,7 @@ function choose(value) {
245
310
  }
246
311
 
247
312
  const result = value();
313
+ if (result instanceof ListValue) return result;
248
314
  if (Array.isArray(result) && result.length > 10) {
249
315
  // Cache large arrays (likely weighted arrays)
250
316
  weightedArrayCache.set(funcString, result);
@@ -252,7 +318,9 @@ function choose(value) {
252
318
  value = result;
253
319
  }
254
320
 
255
- if (Array.isArray(value) && value.length === 0) {
321
+ if (value instanceof ListValue) return value;
322
+
323
+ if (Array.isArray(value) && value.length === 0) {
256
324
  return ""; // Return empty string if the array is empty
257
325
  }
258
326
 
@@ -511,7 +579,16 @@ function streamJSON(filePath, data, options = {}) {
511
579
 
512
580
  if (filePath?.startsWith('gs://')) {
513
581
  const { uri, bucket, file } = parseGCSUri(filePath);
514
- writeStream = storage.bucket(bucket).file(file).createWriteStream({ gzip: true });
582
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
583
+ gcsStream.on('finish', () => resolve(filePath));
584
+ gcsStream.on('error', reject);
585
+ if (gzip) {
586
+ const gzipStream = zlib.createGzip();
587
+ gzipStream.pipe(gcsStream);
588
+ writeStream = gzipStream;
589
+ } else {
590
+ writeStream = gcsStream;
591
+ }
515
592
  }
516
593
  else {
517
594
  writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
@@ -525,9 +602,9 @@ function streamJSON(filePath, data, options = {}) {
525
602
  writeStream.write(JSON.stringify(item) + '\n');
526
603
  });
527
604
  writeStream.end();
528
- writeStream.on('finish', () => {
529
- resolve(filePath);
530
- });
605
+ if (!filePath?.startsWith('gs://')) {
606
+ writeStream.on('finish', () => resolve(filePath));
607
+ }
531
608
  writeStream.on('error', reject);
532
609
  });
533
610
  }
@@ -539,7 +616,16 @@ function streamCSV(filePath, data, options = {}) {
539
616
 
540
617
  if (filePath?.startsWith('gs://')) {
541
618
  const { uri, bucket, file } = parseGCSUri(filePath);
542
- writeStream = storage.bucket(bucket).file(file).createWriteStream({ gzip: true });
619
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
620
+ gcsStream.on('finish', () => resolve(filePath));
621
+ gcsStream.on('error', reject);
622
+ if (gzip) {
623
+ const gzipStream = zlib.createGzip();
624
+ gzipStream.pipe(gcsStream);
625
+ writeStream = gzipStream;
626
+ } else {
627
+ writeStream = gcsStream;
628
+ }
543
629
  }
544
630
  else {
545
631
  writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
@@ -567,9 +653,9 @@ function streamCSV(filePath, data, options = {}) {
567
653
  });
568
654
 
569
655
  writeStream.end();
570
- writeStream.on('finish', () => {
571
- resolve(filePath);
572
- });
656
+ if (!filePath?.startsWith('gs://')) {
657
+ writeStream.on('finish', () => resolve(filePath));
658
+ }
573
659
  writeStream.on('error', reject);
574
660
  });
575
661
  }
@@ -642,20 +728,23 @@ async function streamParquet(filePath, data, options = {}) {
642
728
  });
643
729
 
644
730
  if (filePath?.startsWith('gs://')) {
645
- // For GCS, write to buffer first, then upload
646
731
  // @ts-ignore
647
732
  const arrayBuffer = parquetWriteBuffer({ columnData });
648
733
  const { bucket, file } = parseGCSUri(filePath);
649
-
650
- const writeStream = storage.bucket(bucket).file(file).createWriteStream({
651
- gzip: gzip || true // Always gzip for GCS
652
- });
734
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
653
735
 
654
736
  return new Promise((resolve, reject) => {
655
- writeStream.write(Buffer.from(arrayBuffer));
656
- writeStream.end();
657
- writeStream.on('finish', () => resolve(filePath));
658
- writeStream.on('error', reject);
737
+ gcsStream.on('finish', () => resolve(filePath));
738
+ gcsStream.on('error', reject);
739
+ if (gzip) {
740
+ const gzipStream = zlib.createGzip();
741
+ gzipStream.pipe(gcsStream);
742
+ gzipStream.write(Buffer.from(arrayBuffer));
743
+ gzipStream.end();
744
+ } else {
745
+ gcsStream.write(Buffer.from(arrayBuffer));
746
+ gcsStream.end();
747
+ }
659
748
  });
660
749
  } else {
661
750
  // For local files
@@ -1660,5 +1749,10 @@ export {
1660
1749
  assignSessionIds,
1661
1750
  bunchIntoSessions,
1662
1751
  setDatasetNow,
1752
+ setDatasetBegin,
1753
+ dateRange,
1754
+ listOf,
1755
+ objectList,
1756
+ ListValue,
1663
1757
  deleteFile,
1664
1758
  };