@ak--47/dungeon-master 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +49 -10
  3. package/.claude/skills/create-project/SKILL.md +22 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +18 -1
  7. package/.claude/skills/powertools/SKILL.md +20 -1
  8. package/.claude/skills/release-check/SKILL.md +99 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +71 -16
  10. package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
  11. package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
  12. package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
  13. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  14. package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
  15. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  16. package/.claude/skills/write-hooks/SKILL.md +33 -3
  17. package/CHANGELOG.md +142 -0
  18. package/HOOKS.md +105 -5
  19. package/README.md +228 -0
  20. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  21. package/dungeons/technical/warehouse.js +187 -0
  22. package/index.js +116 -2
  23. package/lib/core/config-validator.js +21 -0
  24. package/lib/core/dungeon-loader.js +1 -1
  25. package/lib/core/storage.js +51 -3
  26. package/lib/generators/standalone.js +248 -0
  27. package/lib/generators/warehouse.js +828 -0
  28. package/lib/orchestrators/mixpanel-sender.js +27 -2
  29. package/lib/orchestrators/user-loop.js +1 -0
  30. package/lib/templates/story-spec.schema.json +41 -16
  31. package/lib/utils/utils.js +37 -12
  32. package/lib/verify/index.js +1 -0
  33. package/lib/verify/story-runner.js +71 -8
  34. package/lib/verify/warehouse.js +683 -0
  35. package/package.json +4 -2
  36. package/scripts/verify-stories.mjs +150 -44
  37. package/types.d.ts +303 -4
@@ -0,0 +1,151 @@
1
+ # 1.8.0 Upgrade Guide
2
+
3
+ **TL;DR: 1.8.0 adds two new metric-table surfaces and one CSV serialization fix.**
4
+ `standaloneEvents` is the identity-less event stream shipped earlier on this
5
+ branch. `warehouseMetrics` is new in this release: warehouse source tables plus a
6
+ manifest, derived from the run's own events. The one behavior change is that CSV
7
+ output now preserves falsy cells, so `0` and `false` stop turning into empty
8
+ strings.
9
+
10
+ ## What changed
11
+
12
+ ### 1. `standaloneEvents` is the general identity-less stream
13
+
14
+ If you already adopted the branch version of `standaloneEvents`, 1.8.0 is the
15
+ release that ships it. These rows describe a system, not a person. They carry no
16
+ `user_id` and no `device_id`, but they do import to Mixpanel as normal events.
17
+
18
+ ```js
19
+ standaloneEvents: [{
20
+ event: 'cdn_egress',
21
+ cadence: 'day',
22
+ dimensions: { region: ['us-east', 'us-west', 'eu'] },
23
+ distinctIdFrom: 'region',
24
+ properties: {
25
+ gb_out: (ctx) => 400 + ctx.tickIndex * 3,
26
+ cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
27
+ },
28
+ }]
29
+ ```
30
+
31
+ Use it for infra, finance, and ops telemetry. If the thing should show up in
32
+ Mixpanel as an event stream, use `standaloneEvents`.
33
+
34
+ ### 2. `warehouseMetrics` materializes local warehouse tables
35
+
36
+ If the thing should become a warehouse metric source table, use
37
+ `warehouseMetrics` instead. This pass runs after event generation, reads the
38
+ run's final event stream, and emits local CSV/JSON tables plus a manifest.
39
+
40
+ ```js
41
+ warehouseMetrics: [
42
+ {
43
+ name: 'daily_new_bookings',
44
+ source: { event: 'new_booking', measure: 'sum', property: 'booking_value' },
45
+ timeColumn: 'date',
46
+ valueColumn: 'bookings',
47
+ },
48
+ {
49
+ name: 'daily_active_subscriptions',
50
+ type: 'point-in-time',
51
+ source: { event: 'subscription_started', minus: 'subscription_cancelled', measure: 'count' },
52
+ baseline: 40,
53
+ timeColumn: 'date',
54
+ valueColumn: 'active_subscriptions',
55
+ },
56
+ {
57
+ name: 'monthly_arr_snapshot',
58
+ type: 'point-in-time',
59
+ grain: 'month',
60
+ sparse: true,
61
+ history: 18,
62
+ source: {
63
+ event: 'subscription_started',
64
+ minus: 'subscription_cancelled',
65
+ measure: 'sum',
66
+ property: 'monthly_value',
67
+ },
68
+ baseline: 24000,
69
+ scale: 12,
70
+ timeColumn: 'month',
71
+ valueColumn: 'arr_usd',
72
+ },
73
+ ]
74
+ ```
75
+
76
+ Key rules:
77
+
78
+ - `type`: `'additive'` or `'point-in-time'`.
79
+ - `grain`: `'day'`, `'week'`, `'month'`.
80
+ - `source.measure`: `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`.
81
+ - `sum` and `avg` require `source.property`.
82
+ - `point-in-time` forbids `avg` and `dau`.
83
+ - `source.groupBy` allows up to two declared keys.
84
+ - `history` prepends synthetic buckets before the live window.
85
+ - `sparse: true` is only valid on point-in-time metrics and emits the first row
86
+ plus changed values.
87
+
88
+ Artifacts:
89
+
90
+ - `result.warehouseMetricData`
91
+ - `result.warehouseManifest`
92
+ - `<name>-WAREHOUSE-<table>.csv|json`
93
+ - `<name>-WAREHOUSE-MANIFEST.json`
94
+
95
+ `token` does not import these tables. They stay local until you deploy them.
96
+
97
+ ### 3. Warehouse deploy is a separate, confirm-before-live step
98
+
99
+ The shipped path is `/warehouse-metrics` or:
100
+
101
+ ```bash
102
+ node .claude/skills/warehouse-metrics/deploy.mjs <dungeon-path> --data-prefix <verified-prefix> --dry-run
103
+ ```
104
+
105
+ Review the SQL files and `warehouse/GAPS.md` first. Live mode uses
106
+ `bq load --replace`, so it overwrites destination tables. The direct live script
107
+ does not prompt, so the operator or agent must obtain explicit consent before
108
+ running the live command.
109
+
110
+ If the warehouse metric CRUD docs route is unavailable (`GET
111
+ /crud/getWarehouseMetrics` returns 404), deploy still loads the tables and
112
+ connects the source, then writes `warehouse/GAPS.md` for the manual create flow.
113
+
114
+ Two real gotchas are now documented in the shipped flow:
115
+
116
+ - Manifest `recommendedAggregation: 'last value'` becomes API
117
+ `aggregation: 'last_value'`.
118
+ - `previewWarehouseMetric` blocks raw substrings like `CREATE` and `UPDATE`, so
119
+ identifiers like `created_at` and `updated_at` fail preview. A fake alias does
120
+ not help if the blocked text still appears anywhere in the SQL.
121
+
122
+ ### 4. CSV falsy cells stop collapsing to empty strings
123
+
124
+ Before 1.8.0, CSV serialization wrote `0` and `false` as blank cells. That made
125
+ some downstream previews and ad hoc SQL look fine until a real warehouse load or
126
+ verification run compared them to JSON or in-memory output.
127
+
128
+ Now:
129
+
130
+ - `0` stays `0`
131
+ - `false` stays `false`
132
+ - only missing values stay empty
133
+
134
+ If you have downstream logic that treated `''` as a stand-in for zero or false,
135
+ fix that logic.
136
+
137
+ ## Migration checklist
138
+
139
+ 1. Keep `standaloneEvents` when you want Mixpanel events without a person.
140
+ 2. Add `warehouseMetrics` when you want local warehouse tables and a manifest.
141
+ 3. Run the dungeon once before deploy so the warehouse artifacts exist.
142
+ 4. Use `/warehouse-metrics` in dry-run mode, then obtain explicit operator
143
+ consent before the live `bq load --replace` step.
144
+ 5. Recheck any CSV consumers that depended on blank falsy cells.
145
+
146
+ ## Notes on row counts
147
+
148
+ The shipped warehouse fixture is a good shape reference, not a row-count
149
+ contract. It uses a 60-day live window and `history: 18` on the monthly ARR
150
+ table. `grain`, `history`, `sparse`, and `groupBy` all change row counts, so
151
+ sample numbers in review output are illustrative.
@@ -0,0 +1,187 @@
1
+ // ── IMPORTS ──
2
+ /** @typedef {import('../../types').Dungeon} Config */
3
+
4
+ // ── OVERVIEW ──
5
+ /*
6
+ * NAME: warehouse
7
+ * PURPOSE: Minimal warehouse-metrics fixture covering the three canonical table shapes.
8
+ * SCALE: 200 users, 120 events, 60 days
9
+ * EVENTS (4): page_view (14) > subscription_started (2) > new_booking (1) > subscription_cancelled (1)
10
+ * FUNNELS: none
11
+ * USER PROPS: none
12
+ * SUPER PROPS: none
13
+ * GROUPS: none
14
+ */
15
+
16
+ // ── SCALE ──
17
+ const SEED = 'warehouse-fixture';
18
+ const DATASET_START = '2025-01-01T00:00:00Z';
19
+ const DATASET_END = '2025-03-01T23:59:59Z';
20
+
21
+ // ── CONFIG ──
22
+ /** @type {Config} */
23
+ const config = {
24
+ name: 'warehouse',
25
+ seed: SEED,
26
+ datasetStart: DATASET_START,
27
+ datasetEnd: DATASET_END,
28
+ numUsers: 200,
29
+ numEvents: 120,
30
+ format: 'csv',
31
+ writeToDisk: false,
32
+ verbose: false,
33
+ concurrency: 1,
34
+ credentials: {
35
+ token: '',
36
+ region: 'US',
37
+ },
38
+ switches: {
39
+ hasSessionIds: false,
40
+ hasAdSpend: false,
41
+ hasLocation: false,
42
+ hasAndroidDevices: false,
43
+ hasIOSDevices: false,
44
+ hasDesktopDevices: false,
45
+ hasBrowser: false,
46
+ hasCampaigns: false,
47
+ isAnonymous: false,
48
+ alsoInferFunnels: false,
49
+ },
50
+ events: [
51
+ {
52
+ event: 'page_view',
53
+ weight: 14,
54
+ isStrictEvent: false,
55
+ properties: {
56
+ page: ['/', '/pricing', '/reports', '/billing'],
57
+ },
58
+ },
59
+ {
60
+ event: 'new_booking',
61
+ weight: 1,
62
+ isStrictEvent: false,
63
+ properties: {
64
+ booking_value: [1200, 1800, 2400, 3600],
65
+ },
66
+ },
67
+ {
68
+ event: 'subscription_started',
69
+ weight: 2,
70
+ isStrictEvent: false,
71
+ properties: {
72
+ monthly_value: [100, 250, 500],
73
+ },
74
+ },
75
+ {
76
+ event: 'subscription_cancelled',
77
+ weight: 1,
78
+ isStrictEvent: false,
79
+ properties: {
80
+ monthly_value: [100, 250, 500],
81
+ },
82
+ },
83
+ ],
84
+ warehouseMetrics: [
85
+ {
86
+ name: 'daily_new_bookings',
87
+ type: 'additive',
88
+ grain: 'day',
89
+ source: {
90
+ event: 'new_booking',
91
+ measure: 'sum',
92
+ property: 'booking_value',
93
+ },
94
+ timeColumn: 'date',
95
+ valueColumn: 'bookings',
96
+ },
97
+ {
98
+ name: 'daily_active_subscriptions',
99
+ type: 'point-in-time',
100
+ grain: 'day',
101
+ source: {
102
+ event: 'subscription_started',
103
+ minus: 'subscription_cancelled',
104
+ measure: 'count',
105
+ },
106
+ baseline: 40,
107
+ timeColumn: 'date',
108
+ valueColumn: 'active_subscriptions',
109
+ },
110
+ {
111
+ name: 'monthly_arr_snapshot',
112
+ type: 'point-in-time',
113
+ grain: 'month',
114
+ sparse: true,
115
+ history: 18,
116
+ source: {
117
+ event: 'subscription_started',
118
+ minus: 'subscription_cancelled',
119
+ measure: 'sum',
120
+ property: 'monthly_value',
121
+ },
122
+ baseline: 24000,
123
+ scale: 12,
124
+ timeColumn: 'month',
125
+ valueColumn: 'arr_usd',
126
+ },
127
+ ],
128
+ };
129
+
130
+ export default config;
131
+
132
+ export const stories = [
133
+ {
134
+ id: 'H1-bookings-corr',
135
+ hook: 'H1',
136
+ archetype: 'temporal-inflection',
137
+ narrative: 'The additive warehouse bookings table should track the generated booking revenue closely enough for a warehouse metric demo.',
138
+ assertions: [
139
+ {
140
+ breakdown: { type: 'warehouse-stats', table: 'daily_new_bookings' },
141
+ select: { s: { where: {} } },
142
+ expect: { metric: 's.corr', op: '>=', target: 0.9, floor: 0.7 },
143
+ },
144
+ ],
145
+ },
146
+ {
147
+ id: 'H2-active-subs-shape',
148
+ hook: 'H2',
149
+ archetype: 'session-shape',
150
+ narrative: 'The dense active subscription snapshot should stay fully ordered, gap-free, and numerically populated across the full dataset window.',
151
+ assertions: [
152
+ {
153
+ breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
154
+ select: { s: { where: {} } },
155
+ expect: { metric: 's.gaps', op: '<=', target: 0 },
156
+ },
157
+ {
158
+ breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
159
+ select: { s: { where: {} } },
160
+ expect: { metric: 's.emptyNumericCells', op: '<=', target: 0 },
161
+ },
162
+ {
163
+ breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
164
+ select: { s: { where: {} } },
165
+ expect: { metric: 's.nonMonotonicTime', op: '<=', target: 0 },
166
+ },
167
+ ],
168
+ },
169
+ {
170
+ id: 'H3-arr-history',
171
+ hook: 'H3',
172
+ archetype: 'composition-drift',
173
+ narrative: 'The sparse ARR snapshot should carry meaningful monthly history before the event window without a large seam jump into the live months.',
174
+ assertions: [
175
+ {
176
+ breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
177
+ select: { s: { where: {} } },
178
+ expect: { metric: 's.buckets', op: '>=', target: 18 },
179
+ },
180
+ {
181
+ breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
182
+ select: { s: { where: {} } },
183
+ expect: { metric: 's.seamJumpPct', op: '<=', target: 30 },
184
+ },
185
+ ],
186
+ },
187
+ ];
package/index.js CHANGED
@@ -23,14 +23,18 @@ import { userLoop } from './lib/orchestrators/user-loop.js';
23
23
  import { sendToMixpanel, collectWrittenFiles, releaseConnections } from './lib/orchestrators/mixpanel-sender.js';
24
24
  // Generators
25
25
  import { makeAdSpend } from './lib/generators/adspend.js';
26
+ import { makeStandaloneEvents } from './lib/generators/standalone.js';
26
27
  import { makeMirror } from './lib/generators/mirror.js';
27
28
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
29
+ import { WarehouseAccumulator, materializeWarehouseMetrics, buildManifest } from './lib/generators/warehouse.js';
28
30
 
29
31
  // Utilities
30
- import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
32
+ import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile, getChance } from './lib/utils/utils.js';
31
33
  import { runWithDataset } from './lib/utils/dataset-context.js';
32
34
 
33
35
  // External dependencies
36
+ import { writeFile } from 'node:fs/promises';
37
+ import path from 'node:path';
34
38
  import dayjs from "dayjs";
35
39
  import utc from "dayjs/plugin/utc.js";
36
40
  import { timer } from 'ak-tools';
@@ -184,6 +188,13 @@ async function runDungeon(config) {
184
188
  storage = await storageManager.initializeContainers();
185
189
  updateContextWithStorage(context, storage);
186
190
 
191
+ if (validatedConfig.warehouseMetrics?.length > 0) {
192
+ context.warehouseAccumulator = new WarehouseAccumulator(validatedConfig.warehouseMetrics, {
193
+ FIXED_BEGIN: context.FIXED_BEGIN,
194
+ FIXED_NOW: context.FIXED_NOW,
195
+ });
196
+ }
197
+
187
198
  // ! DATA GENERATION STARTS HERE
188
199
 
189
200
  // Step 4: Generate ad spend data (if enabled)
@@ -194,6 +205,14 @@ async function runDungeon(config) {
194
205
  context.reportProgress({ phase: "step", step: "adspend", status: "complete", duration: Date.now() - _t4 });
195
206
  }
196
207
 
208
+ // Step 4b: Generate standalone identity-less metric snapshots (if configured) — v1.8.0
209
+ if (validatedConfig.standaloneEvents?.length > 0) {
210
+ context.reportProgress({ phase: "step", step: "standalone", status: "start" });
211
+ const _t4b = Date.now();
212
+ await generateStandaloneData(context);
213
+ context.reportProgress({ phase: "step", step: "standalone", status: "complete", duration: Date.now() - _t4b });
214
+ }
215
+
197
216
  if (context.config.verbose) logger.info('Starting user and event generation...');
198
217
  // Step 5: Main user and event generation
199
218
  context.reportProgress({ phase: "step", step: "users", status: "start" });
@@ -233,6 +252,13 @@ async function runDungeon(config) {
233
252
  context.reportProgress({ phase: "step", step: "mirrors", status: "complete", duration: Date.now() - _t9 });
234
253
  }
235
254
 
255
+ if (validatedConfig.warehouseMetrics?.length > 0) {
256
+ context.reportProgress({ phase: "step", step: "warehouse", status: "start" });
257
+ const _t9b = Date.now();
258
+ await generateWarehouseData(context);
259
+ context.reportProgress({ phase: "step", step: "warehouse", status: "complete", duration: Date.now() - _t9b });
260
+ }
261
+
236
262
  if (context.config.verbose) logger.info('Data generation completed successfully');
237
263
 
238
264
  // ! DATA GENERATION ENDS HERE
@@ -293,11 +319,17 @@ async function runDungeon(config) {
293
319
  // users matching no funnel, …). Always present, even when empty.
294
320
  const warnings = [
295
321
  ...(Array.isArray(validatedConfig._warnings) ? validatedConfig._warnings : []),
322
+ ...(Array.isArray(context.warehouseAccumulator?.warnings) ? context.warehouseAccumulator.warnings.map((reason) => ({
323
+ key: 'warehouseMetrics',
324
+ reason,
325
+ severity: 'warn',
326
+ })) : []),
296
327
  ...context.getWarnings(),
297
328
  ];
298
329
 
299
330
  return {
300
331
  ...extractedData,
332
+ warehouseManifest: context.warehouseManifest,
301
333
  importResults,
302
334
  warnings,
303
335
  files: extractFileInfo(storage),
@@ -356,6 +388,79 @@ async function generateAdSpendData(context) {
356
388
  }
357
389
  }
358
390
 
391
+ /**
392
+ * Generate standalone identity-less metric snapshots — v1.8.0.
393
+ *
394
+ * One record per cadence tick per dimension cross-product row. Records carry no
395
+ * `user_id` and no `device_id`; they describe a system, not a person.
396
+ *
397
+ * @param {Context} context - Context object
398
+ */
399
+ async function generateStandaloneData(context) {
400
+ const { config, storage } = context;
401
+ const specs = /** @type {import('./types').ResolvedStandaloneEventConfig[]} */ (config.standaloneEvents);
402
+
403
+ for (const spec of specs) {
404
+ const records = makeStandaloneEvents(context, spec);
405
+ for (const record of records) {
406
+ // The `standalone` hook fires on push, like ad-spend. Meta carries the
407
+ // stream's resolved spec so a hook can tell the streams apart.
408
+ // `datasetStart`/`datasetEnd` are added by hookPush itself.
409
+ await storage.standaloneEventData.hookPush(
410
+ /** @type {import('./types').EventSchema} */ (record),
411
+ { spec, config }
412
+ );
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Materialize configured warehouse metric tables after the user loop completes.
419
+ *
420
+ * The accumulator taps the final per-user event stream during Step 5. This step
421
+ * runs afterward so seeded noise and column callbacks cannot perturb event generation.
422
+ *
423
+ * @param {Context} context - Context object
424
+ */
425
+ async function generateWarehouseData(context) {
426
+ const { config, storage } = context;
427
+ const specs = /** @type {import('./types').ResolvedWarehouseMetricConfig[]} */ (config.warehouseMetrics);
428
+ const accumulator = context.warehouseAccumulator;
429
+ if (!Array.isArray(specs) || specs.length === 0 || !accumulator) return;
430
+
431
+ const materialized = materializeWarehouseMetrics({
432
+ specs,
433
+ accumulator,
434
+ chance: getChance(),
435
+ FIXED_BEGIN: context.FIXED_BEGIN,
436
+ FIXED_NOW: context.FIXED_NOW,
437
+ configName: config.name,
438
+ config,
439
+ });
440
+
441
+ for (let index = 0; index < materialized.length; index += 1) {
442
+ const entry = materialized[index];
443
+ const container = storage.warehouseMetricData?.[index];
444
+ if (!container) continue;
445
+
446
+ for (let rowIndex = 0; rowIndex < entry.rows.length; rowIndex += 1) {
447
+ await container.hookPush(entry.rows[rowIndex], entry.metas[rowIndex]);
448
+ }
449
+ }
450
+
451
+ const postHookMaterialized = specs.map((spec, index) => ({
452
+ spec,
453
+ rows: Array.from(storage.warehouseMetricData?.[index] || []),
454
+ }));
455
+ context.warehouseManifest = buildManifest(specs, postHookMaterialized, config.name);
456
+
457
+ if (config.writeToDisk && storage.warehouseMetricData?.[0]?.getWriteDir) {
458
+ const manifestPath = path.join(storage.warehouseMetricData[0].getWriteDir(), `${config.name}-WAREHOUSE-MANIFEST.json`);
459
+ await writeFile(manifestPath, JSON.stringify(context.warehouseManifest, null, 2));
460
+ storage.warehouseManifestFile = manifestPath;
461
+ }
462
+ }
463
+
359
464
  /**
360
465
  * Generate group profiles for all configured group keys
361
466
  * @param {Context} context - Context object
@@ -567,11 +672,12 @@ async function flushStorageToDisk(storage, config) {
567
672
  if (storage.eventData?.flush) flushPromises.push(storage.eventData.flush());
568
673
  if (storage.userProfilesData?.flush) flushPromises.push(storage.userProfilesData.flush());
569
674
  if (storage.adSpendData?.flush) flushPromises.push(storage.adSpendData.flush());
675
+ if (storage.standaloneEventData?.flush) flushPromises.push(storage.standaloneEventData.flush());
570
676
  if (storage.mirrorEventData?.flush) flushPromises.push(storage.mirrorEventData.flush());
571
677
  if (storage.groupEventData?.flush) flushPromises.push(storage.groupEventData.flush());
572
678
 
573
679
  // Flush arrays of HookedArrays (excluding lookup tables which are handled separately)
574
- [storage.scdTableData, storage.groupProfilesData].forEach(arrayOfContainers => {
680
+ [storage.scdTableData, storage.groupProfilesData, storage.warehouseMetricData].forEach(arrayOfContainers => {
575
681
  if (Array.isArray(arrayOfContainers)) {
576
682
  arrayOfContainers.forEach(container => {
577
683
  if (container?.flush) flushPromises.push(container.flush());
@@ -635,11 +741,19 @@ function countProfilesPushed(profilesContainer) {
635
741
  * @returns {object} Extracted data in Result format
636
742
  */
637
743
  function extractStorageData(storage) {
744
+ const warehouseMetricData = {};
745
+ for (const container of storage.warehouseMetricData || []) {
746
+ if (!container?.metricName) continue;
747
+ warehouseMetricData[container.metricName] = Array.from(container);
748
+ }
749
+
638
750
  return {
639
751
  eventData: storage.eventData || [],
640
752
  mirrorEventData: storage.mirrorEventData || [],
641
753
  userProfilesData: storage.userProfilesData || [],
642
754
  adSpendData: storage.adSpendData || [],
755
+ standaloneEventData: storage.standaloneEventData || [],
756
+ warehouseMetricData,
643
757
  // Keep arrays of HookedArrays as separate arrays (don't flatten)
644
758
  scdTableData: storage.scdTableData || [],
645
759
  groupProfilesData: storage.groupProfilesData || [],
@@ -17,6 +17,8 @@ import { resolveSoup } from "../templates/soup-presets.js";
17
17
  import { resolveMacro } from "../templates/macro-presets.js";
18
18
  import { locations as LOCATION_TEMPLATE } from "../templates/defaults.js";
19
19
  import { CONDITION_OPERATORS } from "../utils/conditions.js";
20
+ import { validateStandaloneEvents } from "../generators/standalone.js";
21
+ import { validateWarehouseMetrics } from "../generators/warehouse.js";
20
22
 
21
23
  /**
22
24
  * v1.7.0 (P2-2): one entry per value the validator changed or flagged. Collected
@@ -653,6 +655,22 @@ export function validateDungeonConfig(config) {
653
655
  /** @type {EngineWarning[]} */
654
656
  const warnings = [];
655
657
 
658
+ // v1.8.0 — identity-less metric snapshots. Throws on anything malformed;
659
+ // a silent skip would drop a whole data stream without the author noticing.
660
+ const standaloneEvents = validateStandaloneEvents(config.standaloneEvents);
661
+ const {
662
+ warehouseMetrics,
663
+ warnings: warehouseMetricWarnings,
664
+ } = validateWarehouseMetrics(config);
665
+ for (const reason of warehouseMetricWarnings) {
666
+ const key = String(reason).split(' ')[0] || 'warehouseMetrics';
667
+ warnings.push({
668
+ key,
669
+ reason,
670
+ severity: /clamp/i.test(reason) ? 'clamp' : 'warn',
671
+ });
672
+ }
673
+
656
674
  // v1.7.0 (R2-2): resolve singleCountry to the template's canonical name (accepts
657
675
  // ISO code or full name, case-insensitive) or throw. Before 1.7.0 a miss
658
676
  // filtered the location pool to empty and silently deleted every geo property.
@@ -1206,6 +1224,9 @@ export function validateDungeonConfig(config) {
1206
1224
  groupKeys: normalizedGroupKeys,
1207
1225
  groupProps,
1208
1226
  lookupTables,
1227
+ // v1.8.0 identity-less metric snapshots (normalized)
1228
+ standaloneEvents,
1229
+ warehouseMetrics,
1209
1230
  hasAnonIds: hasAnonIdsResolved,
1210
1231
  avgDevicePerUser,
1211
1232
  hasSessionIds,
@@ -278,7 +278,7 @@ export function validateDungeonShape(input) {
278
278
  'events', 'numEvents', 'numUsers', 'numDays', 'funnels',
279
279
  'userProps', 'superProps', 'hook', 'token', 'seed',
280
280
  'scdProps', 'groupKeys', 'lookupTables', 'mirrorProps',
281
- 'hasAdSpend', 'soup', 'format', 'writeToDisk'
281
+ 'hasAdSpend', 'standaloneEvents', 'warehouseMetrics', 'soup', 'format', 'writeToDisk'
282
282
  ];
283
283
 
284
284
  const hasAnyDungeonKey = dungeonKeys.some(key => key in config);