@ak--47/dungeon-master 1.6.1 → 1.6.3

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 (40) hide show
  1. package/.claude/skills/create-project/provision.mjs +16 -3
  2. package/.claude/skills/headless-build/SKILL.md +254 -0
  3. package/.claude/skills/powertools/SKILL.md +21 -1
  4. package/CHANGELOG.md +152 -0
  5. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +4 -5
  6. package/dungeons/vertical/community/community.verify.mjs +4 -6
  7. package/dungeons/vertical/crypto/crypto.verify.mjs +4 -2
  8. package/dungeons/vertical/dating/dating.verify.mjs +4 -6
  9. package/dungeons/vertical/devtools/devtools.verify.mjs +4 -2
  10. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +4 -4
  11. package/dungeons/vertical/education/education.verify.mjs +4 -9
  12. package/dungeons/vertical/fintech/fintech.verify.mjs +4 -4
  13. package/dungeons/vertical/fitness/fitness.verify.mjs +4 -5
  14. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +4 -2
  15. package/dungeons/vertical/gaming/gaming.verify.mjs +4 -4
  16. package/dungeons/vertical/healthcare/healthcare.verify.mjs +4 -6
  17. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +4 -2
  18. package/dungeons/vertical/logistics/logistics.verify.mjs +4 -7
  19. package/dungeons/vertical/marketplace/marketplace.verify.mjs +4 -2
  20. package/dungeons/vertical/media/media.verify.mjs +4 -2
  21. package/dungeons/vertical/real-estate/real-estate.verify.mjs +4 -9
  22. package/dungeons/vertical/sass/sass.verify.mjs +4 -2
  23. package/dungeons/vertical/social/social.verify.mjs +4 -2
  24. package/dungeons/vertical/streaming/streaming.verify.mjs +4 -2
  25. package/dungeons/vertical/support-desk/support-desk.verify.mjs +4 -2
  26. package/dungeons/vertical/travel/travel.verify.mjs +4 -6
  27. package/index.js +30 -1
  28. package/lib/core/config-validator.js +19 -1
  29. package/lib/hook-helpers/_internal.js +45 -0
  30. package/lib/hook-helpers/inject.js +7 -7
  31. package/lib/hook-helpers/mutate.js +8 -6
  32. package/lib/hook-helpers/shape.js +4 -4
  33. package/lib/hook-patterns/frequency-by-frequency.js +2 -2
  34. package/lib/orchestrators/mixpanel-sender.js +52 -2
  35. package/lib/orchestrators/user-loop.js +27 -0
  36. package/lib/verify/index.js +6 -0
  37. package/lib/verify/verify-dungeon.js +39 -12
  38. package/package.json +4 -4
  39. package/scripts/verify-stories.mjs +2 -1
  40. package/types.d.ts +15 -0
@@ -10,14 +10,64 @@ import { comma, rm } from "ak-tools";
10
10
  import * as u from "../utils/utils.js";
11
11
  import mp from "mixpanel-import";
12
12
 
13
+ export { collectWrittenFiles };
14
+
15
+ /**
16
+ * Count of `sendToMixpanel` calls currently in flight in this process.
17
+ *
18
+ * `mp.destroy()` tears down mixpanel-import's PROCESS-GLOBAL undici pools, so a
19
+ * run that finishes while another is still importing would close the sockets out
20
+ * from under it — surfacing as `UND_ERR_CLOSED`, which mixpanel-import does not
21
+ * retry. Only the last run out turns off the lights.
22
+ */
23
+ let _inFlightImports = 0;
24
+
25
+ /**
26
+ * Release mixpanel-import's shared undici connection pools, if no import is still
27
+ * running. dungeon-master is a library — a host process that runs occasional
28
+ * imports shouldn't hold ingest sockets open in between. Pools are lazily
29
+ * re-created, so a later run is unaffected.
30
+ *
31
+ * Called automatically when the last in-flight import settles; exported so a host
32
+ * that drives `mixpanel-import` directly can force the same cleanup. Note the
33
+ * refcount only sees imports that go through `sendToMixpanel` — a host running its
34
+ * own `mixpanel-import` job concurrently with a dungeon run can still have its
35
+ * pools closed underneath it.
36
+ * @returns {Promise<boolean>} true if pools were torn down.
37
+ */
38
+ export async function releaseConnections() {
39
+ // `destroy` only exists on mixpanel-import >= 3.5.1.
40
+ if (_inFlightImports > 0 || typeof mp.destroy !== 'function') return false;
41
+ try {
42
+ await mp.destroy();
43
+ return true;
44
+ } catch (err) {
45
+ // Never fail a completed import over socket cleanup.
46
+ log(` !! connection pool cleanup failed: ${err.message}\n`);
47
+ return false;
48
+ }
49
+ }
50
+
13
51
  /**
14
52
  * Sends the data to Mixpanel
15
53
  * @param {Context} context - Context object containing config, storage, etc.
16
54
  * @returns {Promise<Object>} Import results for all data types
17
55
  */
18
- export { collectWrittenFiles };
19
-
20
56
  export async function sendToMixpanel(context) {
57
+ _inFlightImports++;
58
+ try {
59
+ return await _sendToMixpanel(context);
60
+ } finally {
61
+ _inFlightImports--;
62
+ await releaseConnections();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {Context} context
68
+ * @returns {Promise<Object>}
69
+ */
70
+ async function _sendToMixpanel(context) {
21
71
  const { config, storage } = context;
22
72
  const {
23
73
  adSpendData,
@@ -750,6 +750,33 @@ export async function userLoop(context) {
750
750
  });
751
751
  }
752
752
 
753
+ // v1.6.3: guarantee unique `insert_id` across the user's final stream.
754
+ //
755
+ // Hooks clone events by spreading an existing one — the documented way
756
+ // to inject — and a spread copies `insert_id` along with everything
757
+ // else. Mixpanel deduplicates on `insert_id` at ingest, so those clones
758
+ // are accepted, reported as successful, and then silently dropped: the
759
+ // engineered volume never appears in the project. Nothing downstream
760
+ // catches it, because local verification never inspects `insert_id`.
761
+ //
762
+ // The hook-helper clone atoms stamp fresh ids themselves, but hooks are
763
+ // free to hand-roll a spread (and many shipped dungeons do), so the
764
+ // only reliable place to enforce this is here, over the finished set.
765
+ // A legitimate stream never contains two events with the same id, so
766
+ // any collision at this point is a clone that needs its own id.
767
+ // `> 0`, not `> 1`: a lone event cannot collide, but it can still be
768
+ // missing an id if a hook replaced it with a constructed object.
769
+ if (usersEvents.length > 0) {
770
+ const seenInsertIds = new Set();
771
+ for (const ev of usersEvents) {
772
+ if (!ev) continue;
773
+ if (!ev.insert_id || seenInsertIds.has(ev.insert_id)) {
774
+ ev.insert_id = randomUUID();
775
+ }
776
+ seenInsertIds.add(ev.insert_id);
777
+ }
778
+ }
779
+
753
780
  // Defensive guard: drop any events whose timestamp landed past the
754
781
  // configured dataset end. Hooks that duplicate events with time offsets
755
782
  // (weekend surges, viral spreads) can leak a few past the boundary.
@@ -11,6 +11,12 @@
11
11
 
12
12
  export { emulateBreakdown } from './emulate-breakdown.js';
13
13
  export { verifyDungeon } from './verify-dungeon.js';
14
+ // v1.6.2: exposed on the verify surface so a standalone verify script — one that
15
+ // reads shards off disk instead of running the dungeon — can resolve funnel
16
+ // defaults (`conversionWindowDays`, `order`) before handing funnels to
17
+ // `evaluateStories` / `applyFunnelDefaults`. Use the RETURN value; it does not
18
+ // enrich the config you pass in.
19
+ export { validateDungeonConfig } from '../core/config-validator.js';
14
20
  export { deriveExpectedSchema, validateSchema } from './schema-validator.js';
15
21
  export {
16
22
  evaluateFunnel,
@@ -59,9 +59,10 @@ function findMatchingFunnel(funnels, steps) {
59
59
  * args object — the input is not mutated. Extracted from `verifyDungeon` in
60
60
  * v1.6 so the story runner (P3.3) reuses the exact same threading.
61
61
  * @param {Object} breakdownArgs - Args destined for `emulateBreakdown`.
62
- * @param {Array<Object>} funnels - VALIDATED dungeon funnels (`validateDungeonConfig`
63
- * mutates funnels in place, so post-run `config.funnels` carries resolved
64
- * `conversionWindowDays` / `order`).
62
+ * @param {Array<Object>} funnels - VALIDATED dungeon funnels — i.e. the funnels off
63
+ * `validateDungeonConfig`'s RETURN value (post-1.6.2 it no longer enriches the
64
+ * caller's object), which carry resolved `conversionWindowDays` / `order`. A run
65
+ * result exposes them as `result.validatedConfig.funnels`.
65
66
  * @param {Array<Object>} [profiles] - User profiles, threaded into `timeToConvert`.
66
67
  * @returns {Object}
67
68
  */
@@ -113,20 +114,44 @@ export function applyFunnelDefaults(breakdownArgs, funnels, profiles) {
113
114
  /**
114
115
  * @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
115
116
  * @param {VerifyCheck[]} checks
116
- * @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }>, schemaReport: Object }>}
117
+ * @param {Object} [overrides] - v1.6.2: merged into the dungeon before it runs, same as
118
+ * `DUNGEON_MASTER`'s second argument. Lets CI verify a production-scale dungeon at a
119
+ * small `numUsers` / `numEvents` without editing it.
120
+ * @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }>, schemaReport: Object, validatedConfig: Object }>}
117
121
  */
118
- export async function verifyDungeon(config, checks) {
122
+ export async function verifyDungeon(config, checks, overrides) {
119
123
  if (!checks || !checks.length) throw new Error('verifyDungeon: at least one check required');
120
- let result = await DUNGEON_MASTER(config);
121
- if (Array.isArray(result)) result = result[0];
124
+ let result = await DUNGEON_MASTER(config, overrides);
125
+ if (Array.isArray(result)) {
126
+ // v1.6.2: an array input runs N dungeons but checks are written against ONE
127
+ // schema, and we used to silently verify `result[0]` and throw the rest away —
128
+ // a green report for dungeons that were never looked at. Call verifyDungeon
129
+ // per dungeon instead.
130
+ if (result.length !== 1) {
131
+ throw new Error(
132
+ `verifyDungeon: got ${result.length} dungeons, but checks apply to one. ` +
133
+ `Call verifyDungeon once per dungeon.`
134
+ );
135
+ }
136
+ result = result[0];
137
+ }
122
138
  const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData);
123
139
  const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData);
124
- const schemaReport = validateSchema(events, config);
140
+ // v1.6.2: schema-check against the config the run actually used. `config` may be a
141
+ // PATH STRING (no fields at all → every property reads as unexpected), and even for
142
+ // an object input a v1.5.1 dungeon keeps `hasAndroidDevices` / `hasBrowser` under
143
+ // `switches`, which `deriveExpectedSchema` only reads once flattened.
144
+ const schemaReport = validateSchema(events, result.validatedConfig || config);
125
145
  const ctx = { events, profiles, schemaReport };
126
146
  const results = [];
127
- // `validateDungeonConfig` mutates funnels in place — by the time we read here,
128
- // `conversionWindowDays` is populated and `order` is the validated value.
129
- const validatedFunnels = (config && Array.isArray(config.funnels)) ? config.funnels : [];
147
+ // v1.6.2: read the funnels off the run's `validatedConfig` `conversionWindowDays`
148
+ // and the resolved `order` live there. Before 1.6.2 we read them back off the
149
+ // caller's own `config`, relying on `validateDungeonConfig` enriching in place;
150
+ // that also meant a path/JSON input (no `.funnels` on the string) silently got an
151
+ // empty funnel list and thus an unbounded conversion window. Both are fixed here.
152
+ const validatedFunnels = Array.isArray(result?.validatedConfig?.funnels)
153
+ ? result.validatedConfig.funnels
154
+ : (config && Array.isArray(config.funnels)) ? config.funnels : [];
130
155
  for (const check of checks) {
131
156
  try {
132
157
  const breakdownArgs = applyFunnelDefaults(check.breakdown, validatedFunnels, profiles);
@@ -138,5 +163,7 @@ export async function verifyDungeon(config, checks) {
138
163
  }
139
164
  }
140
165
  const pass = results.every(r => r.pass) && schemaReport.pass;
141
- return { pass, results, schemaReport };
166
+ // v1.6.2: hand back the enriched config the run used, so callers can read
167
+ // resolved funnel/event values without re-validating.
168
+ return { pass, results, schemaReport, validatedConfig: result.validatedConfig };
142
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -33,7 +33,7 @@
33
33
  "HOOKS.md"
34
34
  ],
35
35
  "engines": {
36
- "node": ">=18.0.0"
36
+ "node": ">=20.20.0"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
@@ -87,7 +87,7 @@
87
87
  "dotenv": "^16.4.5",
88
88
  "hyparquet-writer": "^0.6.1",
89
89
  "mixpanel": "^0.18.0",
90
- "mixpanel-import": "^3.3.2",
90
+ "mixpanel-import": "^3.6.1",
91
91
  "p-limit": "^3.1.0",
92
92
  "pino": "^9.0.0",
93
93
  "pino-pretty": "^11.0.0",
@@ -108,4 +108,4 @@
108
108
  "tmp/"
109
109
  ]
110
110
  }
111
- }
111
+ }
@@ -176,7 +176,8 @@ if (inMemory) {
176
176
 
177
177
  // Funnel auto-threading reads VALIDATED funnel fields (conversionWindowDays,
178
178
  // order). The dungeon was not run in this process, so validate the config
179
- // here — validateDungeonConfig resolves those defaults in place.
179
+ // here and use the RETURN value as of v1.6.2 validateDungeonConfig does not
180
+ // enrich the object you hand it.
180
181
  const validated = validateDungeonConfig({ ...config, token: '' });
181
182
  const identityMap = buildIdentityMap(profiles);
182
183
 
package/types.d.ts CHANGED
@@ -1324,6 +1324,21 @@ export type Result = {
1324
1324
  * so `userProfilesData.length - profilesPushed` = dropped profile count.
1325
1325
  */
1326
1326
  profilesPushed?: number;
1327
+ /**
1328
+ * v1.6.2: the enriched config this run actually used. `validateDungeonConfig`
1329
+ * no longer writes back to the object you passed in, so resolved values —
1330
+ * `funnels[].conversionWindowDays`, `events[].isStrictEvent`, the resolved
1331
+ * dataset window — must be read here rather than off your own config.
1332
+ *
1333
+ * READ-ONLY. Do NOT feed this back into `DUNGEON_MASTER` — validation is not
1334
+ * idempotent (each pass appends to the funnel set, eventually producing an
1335
+ * empty `sequence`), and the object carries engine scratch fields. Re-run the
1336
+ * ORIGINAL config instead.
1337
+ *
1338
+ * Credentials (`token`, `serviceAccount`, `serviceSecret`, `projectId`,
1339
+ * `credentials`) are stripped — a Result is a thing hosts log.
1340
+ */
1341
+ validatedConfig?: Dungeon;
1327
1342
  /** Progress callback summary. Only present when `onProgress` was provided. */
1328
1343
  progress?: ProgressSummary;
1329
1344
  };