@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
@@ -34,15 +34,18 @@ import readline from 'readline';
34
34
  import { execFile } from 'child_process';
35
35
  import { promisify } from 'util';
36
36
  import { pathToFileURL } from 'url';
37
+ import { parse as parseCsv } from 'csv-parse';
38
+ import generate from '../index.js';
37
39
  import { extractComments } from '../lib/core/extract-comments.js';
38
40
  import { validateDungeonConfig } from '../lib/core/config-validator.js';
39
41
  import {
40
42
  buildIdentityMap,
41
- verifyDungeon,
42
43
  VERDICT_RANK,
43
44
  validateStories,
44
- storiesToChecks,
45
+ validateSchema,
45
46
  evaluateStories,
47
+ auditWarehouseRows,
48
+ computeWarehouseSourceRows,
46
49
  } from '../lib/verify/index.js';
47
50
 
48
51
  const USAGE = `Usage: node scripts/verify-stories.mjs <dungeon-path> [--data-prefix <prefix>] [--in-memory] [--json]
@@ -89,13 +92,16 @@ if (!fs.existsSync(abs)) die(`verify-stories: dungeon not found at ${abs}`);
89
92
 
90
93
  const mod = await import(pathToFileURL(abs).href);
91
94
  const config = mod.default;
92
- const stories = mod.stories;
95
+ const stories = Array.isArray(mod.stories) ? mod.stories : [];
93
96
  if (!config || typeof config !== 'object') die(`verify-stories: ${dungeonPath} has no default-exported config object`);
94
- if (!Array.isArray(stories) || !stories.length) {
97
+ const hasWarehouseMetrics = Array.isArray(config.warehouseMetrics) && config.warehouseMetrics.length > 0;
98
+ if (!stories.length && !hasWarehouseMetrics) {
95
99
  die(`verify-stories: ${dungeonPath} has no \`stories\` named export — add one (see lib/templates/story-spec.schema.json) or use scripts/verify-runner.mjs for ad-hoc checks.`);
96
100
  }
97
- const sv = validateStories(stories);
98
- if (!sv.valid) die(`verify-stories: invalid stories:\n ${sv.errors.join('\n ')}`);
101
+ if (stories.length) {
102
+ const sv = validateStories(stories);
103
+ if (!sv.valid) die(`verify-stories: invalid stories:\n ${sv.errors.join('\n ')}`);
104
+ }
99
105
 
100
106
  // ── coverage discipline ─────────────────────────────────────────────────────
101
107
  // Every numbered hook in the HOOK STORIES comment block needs >=1 story. One
@@ -129,28 +135,30 @@ const coverage = {
129
135
 
130
136
  let storyResults; // Array<{ id, hook, archetype, verdict, assertions }>
131
137
  let schemaPass = true;
138
+ let warehouseAudits = [];
132
139
 
133
140
  if (inMemory) {
134
- const checks = storiesToChecks(stories); // warns + skips duckdb assertions
135
- if (!checks.length) die('verify-stories: every assertion is duckdb (disk-mode-only) nothing to run in-memory. Drop --in-memory.');
136
- // token: '' prevents any Mixpanel send; in-memory verification never writes.
137
- const report = await verifyDungeon({ ...config, token: '', writeToDisk: false }, checks);
138
- schemaPass = !!report.schemaReport?.pass;
139
- const byStory = new Map(stories.map(s => [s.id, { id: s.id, hook: s.hook, archetype: s.archetype, verdict: null, assertions: [] }]));
140
- for (const r of report.results) {
141
- const m = /^(.+)\[(\d+)\]$/.exec(r.name);
142
- const st = m && byStory.get(m[1]);
143
- if (!st) continue;
144
- const verdict = (/^([A-Z]+) /.exec(r.detail || '') || [])[1] || (r.pass ? 'STRONG' : 'NONE');
145
- st.assertions.push({ name: r.name, verdict, observed: null, detail: r.detail || '' });
146
- }
147
- for (const st of byStory.values()) {
148
- // duckdb-only stories have zero in-memory assertions: informational SKIPPED.
149
- st.verdict = st.assertions.length
150
- ? st.assertions.reduce((w, a) => VERDICT_RANK[a.verdict] < VERDICT_RANK[w] ? a.verdict : w, 'NAILED')
151
- : 'SKIPPED';
152
- }
153
- storyResults = [...byStory.values()];
141
+ const result = await generate({ ...config, token: '', writeToDisk: false });
142
+ const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData || []);
143
+ const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData || []);
144
+ const validated = result.validatedConfig || validateDungeonConfig({ ...config, token: '' });
145
+ schemaPass = !!validateSchema(events, validated)?.pass;
146
+ const warehouseSpecs = Object.fromEntries((validated.warehouseMetrics || []).map((spec) => [spec.name, spec]));
147
+ const warehouseRows = result.warehouseMetricData || {};
148
+ storyResults = stories.length
149
+ ? await evaluateStories(stories, events, {
150
+ profiles,
151
+ funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
152
+ identityMap: buildIdentityMap(profiles),
153
+ warehouseRows,
154
+ warehouseSpecs,
155
+ datasetStart: validated.datasetStart,
156
+ datasetEnd: validated.datasetEnd,
157
+ skipDiskOnlyDuckdb: true,
158
+ onWarning: (message) => console.error(message),
159
+ })
160
+ : [];
161
+ warehouseAudits = buildWarehouseAudits(warehouseSpecs, warehouseRows, validated, events);
154
162
  } else {
155
163
  const prefix = dataPrefix || `verify-${path.basename(abs).replace(/\.(js|mjs)$/, '')}`;
156
164
  const prefixPath = prefix.includes('/') ? prefix : path.join('data', prefix);
@@ -180,6 +188,12 @@ if (inMemory) {
180
188
  // enrich the object you hand it.
181
189
  const validated = validateDungeonConfig({ ...config, token: '' });
182
190
  const identityMap = buildIdentityMap(profiles);
191
+ schemaPass = !!validateSchema(events, validated)?.pass;
192
+ const warehouseSpecs = Object.fromEntries((validated.warehouseMetrics || []).map((spec) => [spec.name, spec]));
193
+ const warehouseManifest = loadWarehouseManifest(prefixPath);
194
+ const warehouseRows = Object.fromEntries(await Promise.all(
195
+ Object.values(warehouseSpecs).map(async (spec) => [spec.name, await loadWarehouseRows(prefixPath, spec, warehouseManifest)])
196
+ ));
183
197
 
184
198
  const execFileP = promisify(execFile);
185
199
  const runSql = async (sql) => {
@@ -195,19 +209,27 @@ if (inMemory) {
195
209
  return trimmed ? JSON.parse(trimmed) : [];
196
210
  };
197
211
 
198
- storyResults = await evaluateStories(stories, events, {
199
- profiles,
200
- funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
201
- identityMap,
202
- runSql,
203
- });
212
+ storyResults = stories.length
213
+ ? await evaluateStories(stories, events, {
214
+ profiles,
215
+ funnels: Array.isArray(validated.funnels) ? validated.funnels : [],
216
+ identityMap,
217
+ runSql,
218
+ warehouseRows,
219
+ warehouseSpecs,
220
+ datasetStart: validated.datasetStart,
221
+ datasetEnd: validated.datasetEnd,
222
+ })
223
+ : [];
224
+ warehouseAudits = buildWarehouseAudits(warehouseSpecs, warehouseRows, validated, events, warehouseManifest);
204
225
  }
205
226
 
206
227
  // ── report ──────────────────────────────────────────────────────────────────
207
228
 
208
229
  const counted = storyResults.filter(s => s.verdict !== 'SKIPPED');
209
230
  const failing = counted.filter(s => VERDICT_RANK[s.verdict] < VERDICT_RANK.STRONG);
210
- const pass = failing.length === 0 && missing.length === 0 && schemaPass;
231
+ const failingAudits = warehouseAudits.filter((audit) => !audit.pass);
232
+ const pass = failing.length === 0 && failingAudits.length === 0 && missing.length === 0 && schemaPass;
211
233
 
212
234
  if (asJson) {
213
235
  console.log(JSON.stringify({
@@ -215,26 +237,36 @@ if (asJson) {
215
237
  mode: inMemory ? 'in-memory' : 'disk',
216
238
  coverage,
217
239
  stories: storyResults,
240
+ warehouseAudits,
218
241
  schemaPass,
219
242
  pass,
220
243
  }, null, 2));
221
244
  } else {
222
- const wId = Math.max(5, ...storyResults.map(s => s.id.length));
223
- const wHook = Math.max(4, ...storyResults.map(s => String(s.hook).length));
224
- const wArch = Math.max(9, ...storyResults.map(s => s.archetype.length));
225
- console.log('');
226
- console.log(`${'STORY'.padEnd(wId)} ${'HOOK'.padEnd(wHook)} ${'ARCHETYPE'.padEnd(wArch)} VERDICT`);
227
- for (const s of storyResults) {
228
- console.log(`${s.id.padEnd(wId)} ${String(s.hook).padEnd(wHook)} ${s.archetype.padEnd(wArch)} ${s.verdict}`);
229
- for (const a of s.assertions) {
230
- console.log(` ${a.name.padEnd(wId)} ${a.verdict} — ${a.detail.replace(/^[A-Z]+ — /, '')}`);
245
+ if (storyResults.length) {
246
+ const wId = Math.max(5, ...storyResults.map(s => s.id.length));
247
+ const wHook = Math.max(4, ...storyResults.map(s => String(s.hook).length));
248
+ const wArch = Math.max(9, ...storyResults.map(s => s.archetype.length));
249
+ console.log('');
250
+ console.log(`${'STORY'.padEnd(wId)} ${'HOOK'.padEnd(wHook)} ${'ARCHETYPE'.padEnd(wArch)} VERDICT`);
251
+ for (const s of storyResults) {
252
+ console.log(`${s.id.padEnd(wId)} ${String(s.hook).padEnd(wHook)} ${s.archetype.padEnd(wArch)} ${s.verdict}`);
253
+ for (const a of s.assertions) {
254
+ console.log(` ${a.name.padEnd(wId)} ${a.verdict} — ${a.detail.replace(/^[A-Z]+ — /, '')}`);
255
+ }
231
256
  }
257
+ console.log('');
232
258
  }
233
- console.log('');
234
259
  const tally = {};
235
260
  for (const s of counted) tally[s.verdict] = (tally[s.verdict] || 0) + 1;
236
261
  const skipped = storyResults.length - counted.length;
237
- console.log(`${counted.length} stories: ${Object.entries(tally).map(([v, n]) => `${n} ${v}`).join(', ') || 'none'}${skipped ? ` (${skipped} skipped — duckdb-only, disk mode required)` : ''}`);
262
+ console.log(`${counted.length} stories: ${Object.entries(tally).map(([v, n]) => `${n} ${v}`).join(', ') || 'none'}${skipped ? ` (${skipped} skipped — disk mode required)` : ''}`);
263
+ if (warehouseAudits.length) {
264
+ console.log('warehouse audit:');
265
+ for (const audit of warehouseAudits) {
266
+ const summary = `stats corr=${formatNumber(audit.stats.corr)} buckets=${audit.stats.buckets} gaps=${audit.stats.gaps} empty=${audit.stats.emptyNumericCells}`;
267
+ console.log(` ${audit.table}: ${audit.pass ? 'PASS' : 'FAIL'} — ${audit.failures.length ? audit.failures.join('; ') : summary}`);
268
+ }
269
+ }
238
270
  if (coverage.note) console.log(`coverage: ${coverage.note}`);
239
271
  else if (missing.length) console.log(`coverage: FAIL — hooks with no story: ${missing.map(n => `H${n}`).join(', ')} (declared H${coverage.declared.join(', H')})`);
240
272
  else console.log(`coverage: ${coverage.declared.length} hooks declared in HOOK STORIES, all covered`);
@@ -243,3 +275,77 @@ if (asJson) {
243
275
  }
244
276
 
245
277
  process.exit(pass ? 0 : 1);
278
+
279
+ function buildWarehouseAudits(warehouseSpecs, warehouseRows, validated, events = [], warehouseManifest = null) {
280
+ return Object.values(warehouseSpecs || {}).map((spec) => {
281
+ const rows = warehouseRows?.[spec.name] || [];
282
+ const sourceRows = events.length ? computeWarehouseSourceRows(events, spec) : [];
283
+ const manifestColumns = warehouseManifest?.tables?.find((table) => table.table === spec.name)?.columns || [];
284
+ return {
285
+ table: spec.name,
286
+ ...auditWarehouseRows(rows, spec, {
287
+ datasetStart: validated.datasetStart,
288
+ datasetEnd: validated.datasetEnd,
289
+ sourceRows,
290
+ manifestColumns,
291
+ }),
292
+ };
293
+ });
294
+ }
295
+
296
+ function loadWarehouseManifest(prefixPath) {
297
+ const manifestPath = `${prefixPath}-WAREHOUSE-MANIFEST.json`;
298
+ if (!fs.existsSync(manifestPath)) return null;
299
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
300
+ }
301
+
302
+ async function loadWarehouseRows(prefixPath, spec, manifest) {
303
+ const manifestEntry = manifest?.tables?.find((table) => table.table === spec.name) || null;
304
+ const fileBase = manifestEntry?.file || `${path.basename(prefixPath)}-WAREHOUSE-${spec.name}`;
305
+ const format = manifestEntry?.format || spec.format || 'csv';
306
+ const filePath = path.join(path.dirname(prefixPath), `${fileBase}.${format}`);
307
+ if (!fs.existsSync(filePath)) return [];
308
+ if (format === 'json') return loadNdjson(filePath);
309
+ return loadCsv(filePath, manifestEntry?.columns || []);
310
+ }
311
+
312
+ async function loadNdjson(filePath) {
313
+ const out = [];
314
+ const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity });
315
+ for await (const line of rl) {
316
+ if (line.trim()) out.push(JSON.parse(line));
317
+ }
318
+ return out;
319
+ }
320
+
321
+ async function loadCsv(filePath, columns) {
322
+ const out = [];
323
+ const parser = fs.createReadStream(filePath).pipe(parseCsv({
324
+ bom: true,
325
+ columns: true,
326
+ skip_empty_lines: true,
327
+ }));
328
+ for await (const record of parser) {
329
+ const row = {};
330
+ for (const [header, rawValue] of Object.entries(record)) {
331
+ const type = columns.find((column) => column.name === header)?.bqType;
332
+ row[header] = coerceWarehouseCell(rawValue ?? '', type);
333
+ }
334
+ out.push(row);
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function coerceWarehouseCell(value, bqType) {
340
+ if (bqType === 'FLOAT64') {
341
+ if (value === '') return '';
342
+ const numeric = Number(value);
343
+ return Number.isFinite(numeric) ? numeric : value;
344
+ }
345
+ if (bqType === 'BOOL') return value === 'true';
346
+ return value;
347
+ }
348
+
349
+ function formatNumber(value) {
350
+ return Number.isFinite(value) ? value.toFixed(3) : String(value);
351
+ }
package/types.d.ts CHANGED
@@ -453,6 +453,18 @@ export interface Dungeon {
453
453
  groupProps?: Record<string, Record<string, ValueValid>>;
454
454
  /** Lookup table definitions for dimension tables. */
455
455
  lookupTables?: LookupTableSchema[];
456
+ /**
457
+ * v1.8.0 — identity-less metric snapshots. One record per cadence tick per
458
+ * dimension cross-product row, carrying NO `user_id` and NO `device_id`.
459
+ *
460
+ * Use for infrastructure and finance telemetry: daily CDN egress per region,
461
+ * weekly billing rollups per plan tier, hourly queue depth per cluster.
462
+ * `$ad_spend` (`hasAdSpend: true`) is the same idea hard-coded; this is the
463
+ * general form and it does not use a Mixpanel reserved event name.
464
+ */
465
+ standaloneEvents?: StandaloneEventConfig[];
466
+ /** v1.8.0 — warehouse-backed metric source tables derived from the run's own event stream. */
467
+ warehouseMetrics?: WarehouseMetricConfig[];
456
468
  /** TimeSoup configuration: shapes intra-week and intra-day rhythm (peaks, deviation, DOW/HOD weights). Pair with `macro` for big-picture trend control. */
457
469
  soup?: soup;
458
470
  /** Macro trend shape across the full dataset window: birth distribution + per-user event allocation. Default: "flat". Use "growth"/"viral"/"steady"/"decline" or a custom object. */
@@ -703,7 +715,7 @@ export interface ResolvedMacro {
703
715
  * - "everything" — array of ALL events for one user (return array to replace; meta.profile available)
704
716
  *
705
717
  * Storage-only hooks (fire during hookPush, not in generators):
706
- * - "ad-spend", "group", "mirror", "lookup"
718
+ * - "ad-spend", "group", "mirror", "lookup", "standalone", "warehouse"
707
719
  */
708
720
  export type hookTypes =
709
721
  | "event"
@@ -716,6 +728,8 @@ export type hookTypes =
716
728
  | "funnel-pre"
717
729
  | "funnel-post"
718
730
  | "ad-spend"
731
+ | "standalone"
732
+ | "warehouse"
719
733
  | "churn"
720
734
  | "group-event"
721
735
  | "everything"
@@ -732,7 +746,9 @@ export type hookTypes =
732
746
  * - "event": return value REPLACES the event (must be the event object).
733
747
  * - "everything": return an array to REPLACE the user's event list (filter/inject/dedupe).
734
748
  * - "user", "scd-pre", "funnel-pre", "funnel-post": return value is IGNORED — mutate in place.
735
- * - storage-only ("ad-spend", "group", "mirror", "lookup"): return value is IGNORED.
749
+ * - storage-only ("ad-spend", "group", "mirror", "lookup", "standalone"): return an object or array of records; undefined drops the record.
750
+ * - "warehouse": return value is IGNORED; mutate the row in place.
751
+ * - "standalone" runs before the user loop; "warehouse" runs after it. Neither receives person metadata or enters "everything".
736
752
  *
737
753
  * @param record - The data being processed (event, profile, array of events, funnel config, etc.).
738
754
  * @param type - Which hook type is firing — see `hookTypes`.
@@ -908,6 +924,10 @@ export interface hookArrayOptions<T> {
908
924
  concurrency?: number;
909
925
  /** Generation context (config, runtime, defaults). */
910
926
  context?: Context;
927
+ /** Warehouse metric name for warehouse containers. */
928
+ metricName?: string;
929
+ /** Fixed CSV column order for warehouse metric tables. */
930
+ fixedColumns?: string[];
911
931
  }
912
932
 
913
933
  /**
@@ -929,6 +949,10 @@ export interface HookedArray<T> extends Array<T> {
929
949
  getWritePath: () => string;
930
950
  /** Returns all file paths written by this container during the current run. */
931
951
  getWrittenFiles: () => string[];
952
+ /** Storage hook type this array is configured for. */
953
+ type?: hookTypes | string;
954
+ /** Output serialization format for this array. */
955
+ format?: string;
932
956
  /** SCD prop name this array carries (only set on SCD HookedArrays). */
933
957
  scdKey?: string;
934
958
  /** Entity type for SCDs ("user" or a group key). */
@@ -937,6 +961,10 @@ export interface HookedArray<T> extends Array<T> {
937
961
  groupKey?: string;
938
962
  /** Lookup table key this array carries (only set on lookup table HookedArrays). */
939
963
  lookupKey?: string;
964
+ /** Warehouse metric name this array carries (only set on warehouse HookedArrays). */
965
+ metricName?: string;
966
+ /** Fixed CSV column order for warehouse metric tables. */
967
+ fixedColumns?: string[];
940
968
  }
941
969
 
942
970
  export type AllData =
@@ -954,8 +982,11 @@ export interface Storage {
954
982
  mirrorEventData?: HookedArray<EventSchema>;
955
983
  userProfilesData?: HookedArray<UserProfile>;
956
984
  adSpendData?: HookedArray<EventSchema>;
985
+ standaloneEventData?: HookedArray<EventSchema>;
957
986
  groupProfilesData?: HookedArray<GroupProfileSchema>[];
958
987
  lookupTableData?: HookedArray<LookupTableSchema>[];
988
+ warehouseMetricData?: HookedArray<Record<string, any>>[];
989
+ warehouseManifestFile?: string;
959
990
  scdTableData?: HookedArray<SCDSchema>[];
960
991
  groupEventData?: HookedArray<EventSchema>;
961
992
  }
@@ -1023,6 +1054,14 @@ export interface Context {
1023
1054
  FIXED_NOW: number;
1024
1055
  /** Start of the resolved dataset window (unix seconds). Equal to the user-supplied `datasetStart`, or fallback `today_start - numDays`. */
1025
1056
  FIXED_BEGIN?: number;
1057
+ /** Runtime accumulator for post-loop warehouse metric materialization. */
1058
+ warehouseAccumulator?: {
1059
+ warnings?: string[];
1060
+ ingest: (events: EventSchema[]) => void;
1061
+ getCell: (metricName: string, seriesKey: string, bucketStartSec: number) => any;
1062
+ };
1063
+ /** Manifest describing materialized warehouse tables for downstream tooling. */
1064
+ warehouseManifest?: WarehouseManifest;
1026
1065
  /** Alias of `FIXED_BEGIN` — surfaced on hook `meta.datasetStart`. */
1027
1066
  DATASET_START_SECONDS: number;
1028
1067
  /** Alias of `FIXED_NOW` — surfaced on hook `meta.datasetEnd`. */
@@ -1596,6 +1635,12 @@ export type Result = {
1596
1635
  scdTableData: SCDSchema[][];
1597
1636
  /** Ad-spend events (only populated when `hasAdSpend: true`). */
1598
1637
  adSpendData: EventSchema[];
1638
+ /** Identity-less metric snapshots (only populated when `standaloneEvents` is set). v1.8.0. */
1639
+ standaloneEventData: EventSchema[];
1640
+ /** Materialized warehouse metric tables keyed by metric name. */
1641
+ warehouseMetricData: Record<string, Record<string, any>[]>;
1642
+ /** Warehouse table manifest surfaced whenever `warehouseMetrics` is configured. */
1643
+ warehouseManifest?: WarehouseManifest;
1599
1644
  /** Group profiles — one inner array per group key. */
1600
1645
  groupProfilesData: GroupProfileSchema[][];
1601
1646
  /** Lookup tables — one inner array per table. */
@@ -2007,9 +2052,10 @@ export interface StoryAssertion {
2007
2052
  /**
2008
2053
  * Byte-compatible with `emulateBreakdown` / `verifyDungeon` args — or the
2009
2054
  * `{ type: 'duckdb', sql }` escape hatch (disk mode only; `{{PREFIX}}` in
2010
- * the SQL is substituted with the run's data prefix path).
2055
+ * the SQL is substituted with the run's data prefix path), or warehouse
2056
+ * verification rows via `{ type: 'warehouse' | 'warehouse-stats', table }`.
2011
2057
  */
2012
- breakdown: Record<string, unknown> & { type: string; sql?: string };
2058
+ breakdown: Record<string, unknown> & { type: string; sql?: string; table?: string };
2013
2059
  select?: StorySelect;
2014
2060
  expect?: StoryExpect;
2015
2061
  /**
@@ -2325,13 +2371,266 @@ export interface WritePaths {
2325
2371
  eventFiles: string[];
2326
2372
  userFiles: string[];
2327
2373
  adSpendFiles: string[];
2374
+ standaloneFiles: string[];
2328
2375
  scdFiles: string[];
2329
2376
  mirrorFiles: string[];
2330
2377
  groupFiles: string[];
2331
2378
  lookupFiles: string[];
2379
+ warehouseFiles: string[];
2332
2380
  folder: string;
2333
2381
  }
2334
2382
 
2383
+ // ============= Standalone (identity-less) Events — v1.8.0 =============
2384
+
2385
+ /**
2386
+ * An identity-less metric snapshot stream.
2387
+ *
2388
+ * The engine emits one record per cadence tick per dimension cross-product row.
2389
+ * Records carry `event`, `time`, `insert_id`, `distinct_id`, every dimension as
2390
+ * a flat property, and every resolved entry in `properties`. They never carry
2391
+ * `user_id` or `device_id`, because they describe a system, not a person.
2392
+ *
2393
+ * @example
2394
+ * standaloneEvents: [{
2395
+ * event: 'cdn_egress',
2396
+ * cadence: 'day',
2397
+ * dimensions: { region: ['us-east', 'us-west', 'eu', 'apac'] },
2398
+ * distinctIdFrom: 'region',
2399
+ * properties: {
2400
+ * gb_out: (ctx) => 400 + ctx.tickIndex * 3,
2401
+ * cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
2402
+ * p95_ms: [120, 140, 160],
2403
+ * },
2404
+ * }]
2405
+ */
2406
+ export interface StandaloneEventConfig {
2407
+ /** Event name as it lands in Mixpanel. Must be unique across `standaloneEvents`. */
2408
+ event: string;
2409
+ /**
2410
+ * How often a snapshot fires. Ticks start at the dataset start and step by
2411
+ * the cadence; the last tick is the final one at or before the dataset end.
2412
+ * Default: `'day'`.
2413
+ */
2414
+ cadence?: 'hour' | 'day' | 'week';
2415
+ /**
2416
+ * Dimension values to cross-product. Each key becomes a flat property on the
2417
+ * record. `{ region: ['us','eu'], tier: ['a','b'] }` emits 4 records per tick.
2418
+ * Omit for a single record per tick.
2419
+ */
2420
+ dimensions?: Record<string, any[]>;
2421
+ /**
2422
+ * Which dimension supplies the synthetic `distinct_id`. Must name a declared
2423
+ * dimension. When omitted, `distinct_id` is the event name. The id exists so
2424
+ * Mixpanel accepts the record; it never maps to a person.
2425
+ */
2426
+ distinctIdFrom?: string;
2427
+ /**
2428
+ * Snapshot metrics. Same `ValueValid` forms as event properties, and value
2429
+ * functions receive a `StandaloneValueContext` so a metric can shape a trend
2430
+ * across the window.
2431
+ */
2432
+ properties?: Record<string, ValueValid>;
2433
+ }
2434
+
2435
+ /** @internal Normalized `StandaloneEventConfig` produced by the validator. */
2436
+ export interface ResolvedStandaloneEventConfig {
2437
+ event: string;
2438
+ cadence: 'hour' | 'day' | 'week';
2439
+ dimensions: Record<string, any[]>;
2440
+ distinctIdFrom: string | null;
2441
+ properties: Record<string, ValueValid>;
2442
+ }
2443
+
2444
+ /**
2445
+ * Context handed to every standalone property value function.
2446
+ * Shares `time` and `config` with `ValueContext`, so a function written for a
2447
+ * normal event property still works unchanged.
2448
+ */
2449
+ export interface StandaloneValueContext {
2450
+ /** Tick timestamp in unix MILLISECONDS. */
2451
+ time: number;
2452
+ /** The full validated dungeon config. */
2453
+ config: Dungeon;
2454
+ /** This row's dimension values, e.g. `{ region: 'us-east' }`. */
2455
+ dimensions: Record<string, any>;
2456
+ /** Zero-based index of this tick within the window. Use it to shape a trend. */
2457
+ tickIndex: number;
2458
+ /** Total number of ticks in the window. `tickIndex / (tickCount - 1)` is window progress. */
2459
+ tickCount: number;
2460
+ /** The cadence this stream fires on. */
2461
+ cadence: 'hour' | 'day' | 'week';
2462
+ /** The partially built record (`event`, `time`, `insert_id`, `distinct_id`, dimensions). */
2463
+ event: Record<string, any>;
2464
+ }
2465
+
2466
+ /**
2467
+ * Meta passed to the `"standalone"` hook.
2468
+ *
2469
+ * Storage-only: return the record or an array of records to retain them.
2470
+ * Returning undefined drops the record. Warehouse hooks instead ignore returns.
2471
+ */
2472
+ export interface HookMetaStandalone extends HookMetaTimeAnchors {
2473
+ /** The resolved config for the stream this record belongs to. */
2474
+ spec: ResolvedStandaloneEventConfig;
2475
+ /** The full validated dungeon config. */
2476
+ config: Dungeon;
2477
+ }
2478
+
2479
+ export interface WarehouseMetricSource {
2480
+ /** Source event names whose bucketed measure contributes positively to the series. */
2481
+ event: string | string[];
2482
+ /** Source event names whose bucketed measure is subtracted from the series. */
2483
+ minus?: string | string[];
2484
+ /** Per-bucket measure. Default: `'count'`. */
2485
+ measure?: 'count' | 'sum' | 'avg' | 'dau' | 'users';
2486
+ /** Required when `measure` is `'sum'` or `'avg'`. */
2487
+ property?: string;
2488
+ /** Optional row filter over flat event records. */
2489
+ where?: ((event: Record<string, any>) => boolean) | null;
2490
+ /** Optional dimension columns copied from source event or super prop keys. */
2491
+ groupBy?: string | string[];
2492
+ }
2493
+
2494
+ export interface WarehouseMetricConfig {
2495
+ /**
2496
+ * @example
2497
+ * warehouseMetrics: [{
2498
+ * name: 'daily_active_subscriptions',
2499
+ * type: 'point-in-time',
2500
+ * source: {
2501
+ * event: 'subscription_started',
2502
+ * minus: 'subscription_cancelled',
2503
+ * measure: 'count',
2504
+ * },
2505
+ * baseline: 40,
2506
+ * timeColumn: 'date',
2507
+ * valueColumn: 'active_subscriptions',
2508
+ * }]
2509
+ */
2510
+ /** Unique metric/table name. Must match `/^[a-z][a-z0-9_]{0,63}$/`. */
2511
+ name: string;
2512
+ /** Metric family: additive sums per bucket vs point-in-time carried levels. Default: `'additive'`. */
2513
+ type?: 'additive' | 'point-in-time';
2514
+ /** Bucket grain. Default: `'day'`. */
2515
+ grain?: 'day' | 'week' | 'month';
2516
+ /** Point-in-time only: emit only the first bucket and changed values. Default: `false`. */
2517
+ sparse?: boolean;
2518
+ /** Declarative source spec describing how to derive the table from generated events. */
2519
+ source: WarehouseMetricSource;
2520
+ /** Output time column name. Default: `'date'`. */
2521
+ timeColumn?: string;
2522
+ /** Output value column name. Default: `'value'`. */
2523
+ valueColumn?: string;
2524
+ /** Point-in-time starting level at the dataset window start. Default: `0`. */
2525
+ baseline?: number;
2526
+ /** Multiplier applied after bucket aggregation. Default: `1`. */
2527
+ scale?: number;
2528
+ /** Seeded jitter fraction clamped to `[0, 0.5]`. Default: `0`. */
2529
+ noise?: number;
2530
+ /** Grain periods of backfill before the dataset window. Default: `0`. */
2531
+ history?: number;
2532
+ /** Extra declared output columns, preserved in declaration order. */
2533
+ columns?: Record<string, ValueValid | ((ctx: WarehouseValueContext) => ValueValid)>;
2534
+ /** Output file format. Defaults to the dungeon format, else `'csv'`. */
2535
+ format?: 'csv' | 'json';
2536
+ }
2537
+
2538
+ /** @internal Normalized `WarehouseMetricConfig` produced by the validator. */
2539
+ export interface ResolvedWarehouseMetricConfig {
2540
+ name: string;
2541
+ type: 'additive' | 'point-in-time';
2542
+ grain: 'day' | 'week' | 'month';
2543
+ sparse: boolean;
2544
+ source: {
2545
+ event: string[];
2546
+ minus: string[];
2547
+ measure: 'count' | 'sum' | 'avg' | 'dau' | 'users';
2548
+ property: string | null;
2549
+ where: ((event: Record<string, any>) => boolean) | null;
2550
+ groupBy: string[];
2551
+ };
2552
+ timeColumn: string;
2553
+ valueColumn: string;
2554
+ baseline: number;
2555
+ scale: number;
2556
+ noise: number;
2557
+ history: number;
2558
+ columns: Record<string, ValueValid | ((ctx: WarehouseValueContext) => ValueValid)>;
2559
+ format: 'csv' | 'json';
2560
+ }
2561
+
2562
+ export interface WarehouseValueContext {
2563
+ /** Final bucket value after scale and noise. */
2564
+ value: number;
2565
+ /** Partially built row so later columns can depend on earlier ones. */
2566
+ row: Record<string, any>;
2567
+ /** Bucket start in unix milliseconds. */
2568
+ time: number;
2569
+ /** Zero-based chronological bucket index within this series, including backfill buckets and sparse gaps when present. */
2570
+ bucketIndex: number;
2571
+ /** Total chronological buckets in this series, including history buckets even when sparse rows are skipped. */
2572
+ bucketCount: number;
2573
+ /** Bucket grain for this metric. */
2574
+ grain: 'day' | 'week' | 'month';
2575
+ /** True when this row was synthesized before the dataset window by `history`. */
2576
+ isBackfill: boolean;
2577
+ /** Stable joined dimension key for this series. Empty string when undimensioned. */
2578
+ seriesKey: string;
2579
+ /** The resolved metric spec for this table. */
2580
+ spec: ResolvedWarehouseMetricConfig;
2581
+ /** The full validated dungeon config. */
2582
+ config: Dungeon;
2583
+ }
2584
+
2585
+ export interface HookMetaWarehouse extends HookMetaTimeAnchors {
2586
+ /** The resolved config for the metric this row belongs to. */
2587
+ spec: ResolvedWarehouseMetricConfig;
2588
+ /** The full validated dungeon config. */
2589
+ config: Dungeon;
2590
+ /** Metric/table name. */
2591
+ metricName: string;
2592
+ /** Zero-based chronological bucket index within this series, including history buckets and sparse gaps. */
2593
+ bucketIndex: number;
2594
+ /** Total chronological buckets in this series, including history buckets even when sparse rows are skipped. */
2595
+ bucketCount: number;
2596
+ /** Bucket grain for the metric. */
2597
+ grain: 'day' | 'week' | 'month';
2598
+ /** Stable joined dimension key for this series. Empty string when undimensioned. */
2599
+ seriesKey: string;
2600
+ /** True when the row belongs to the `history` backfill before the dataset window. */
2601
+ isBackfill: boolean;
2602
+ /** Raw bucket contributions before scale/noise and before point-in-time carry-forward. */
2603
+ raw: {
2604
+ plus: { count: number; sum: number; users: number };
2605
+ minus: { count: number; sum: number; users: number };
2606
+ };
2607
+ }
2608
+
2609
+ export interface WarehouseManifestColumn {
2610
+ name: string;
2611
+ bqType: 'DATE' | 'FLOAT64' | 'BOOL' | 'STRING';
2612
+ }
2613
+
2614
+ export interface WarehouseManifestTable {
2615
+ table: string;
2616
+ file: string;
2617
+ format: 'csv' | 'json';
2618
+ grain: 'day' | 'week' | 'month';
2619
+ type: 'additive' | 'point-in-time';
2620
+ timeColumn: string;
2621
+ valueColumn: string;
2622
+ dimensionColumns: string[];
2623
+ columns: WarehouseManifestColumn[];
2624
+ recommendedAggregation: 'sum' | 'last value';
2625
+ sql: string;
2626
+ refreshHint: string;
2627
+ }
2628
+
2629
+ export interface WarehouseManifest {
2630
+ configName: string;
2631
+ tables: WarehouseManifestTable[];
2632
+ }
2633
+
2335
2634
  /**
2336
2635
  * Configuration for TimeSoup time distribution function
2337
2636
  */