@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
@@ -91,15 +91,17 @@ export async function createHookArray(arr = [], opts) {
91
91
  if (item === null || item === undefined) return false;
92
92
  if (typeof item === 'object' && Object.keys(item).length === 0) return false;
93
93
 
94
+ const isWarehouse = type === "warehouse";
95
+
94
96
  // Skip hook for types already hooked in generators/orchestrators to prevent double-firing
95
97
  // Types hooked upstream: "event" (events.js), "user" (user-loop.js), "scd" (user-loop.js)
96
- // Types only hooked here: "mirror", "ad-spend", "group", "lookup"
98
+ // Types only hooked here: "mirror", "ad-spend", "group", "lookup", "standalone"
97
99
  const alreadyHooked = type === "event" || type === "user" || type === "scd";
98
100
 
99
101
  // Performance optimization: skip hook overhead for passthrough hooks
100
102
  // Only treat as passthrough if the function body is trivially simple (just returns its argument)
101
103
  const hookStr = hook.toString();
102
- const isPassthroughHook = hook.length === 1 || /^\s*function\s*\([^)]*\)\s*\{\s*return\s+\w+;?\s*\}\s*$/.test(hookStr) || /^\s*\(?[^)]*\)?\s*=>\s*\w+\s*$/.test(hookStr);
104
+ const isPassthroughHook = !isWarehouse && (hook.length === 1 || /^\s*function\s*\([^)]*\)\s*\{\s*return\s+\w+;?\s*\}\s*$/.test(hookStr) || /^\s*\(?[^)]*\)?\s*=>\s*\w+\s*$/.test(hookStr));
103
105
 
104
106
  if (alreadyHooked || isPassthroughHook) {
105
107
  // Fast path for passthrough hooks - no transformation needed
@@ -131,6 +133,10 @@ export async function createHookArray(arr = [], opts) {
131
133
  for (const i of item) {
132
134
  try {
133
135
  const enriched = await hook(i, type, allMetaData);
136
+ if (isWarehouse) {
137
+ if (isValidEvent(i)) arr.push(i);
138
+ continue;
139
+ }
134
140
  if (Array.isArray(enriched)) {
135
141
  enriched.forEach(e => {
136
142
  if (isValidEvent(e)) arr.push(e);
@@ -146,6 +152,10 @@ export async function createHookArray(arr = [], opts) {
146
152
  } else {
147
153
  try {
148
154
  const enriched = await hook(item, type, allMetaData);
155
+ if (isWarehouse) {
156
+ if (isValidEvent(item)) arr.push(item);
157
+ return Promise.resolve(false);
158
+ }
149
159
  if (Array.isArray(enriched)) {
150
160
  enriched.forEach(e => {
151
161
  if (isValidEvent(e)) arr.push(e);
@@ -161,7 +171,7 @@ export async function createHookArray(arr = [], opts) {
161
171
  }
162
172
 
163
173
  // Check batch size and handle writes synchronously to prevent race conditions
164
- if (arr.length > BATCH_SIZE && !isWriting) {
174
+ if (!isWarehouse && arr.length > BATCH_SIZE && !isWriting) {
165
175
  isWriting = true; // Lock to prevent concurrent writes
166
176
  isBatchMode = true;
167
177
  runtime.isBatchMode = true; // Update runtime state
@@ -200,6 +210,9 @@ export async function createHookArray(arr = [], opts) {
200
210
  const streamOptions = {
201
211
  gzip: config.gzip || false
202
212
  };
213
+ if (type === "warehouse" && Array.isArray(rest.fixedColumns)) {
214
+ streamOptions.fixedColumns = rest.fixedColumns;
215
+ }
203
216
 
204
217
  switch (format) {
205
218
  case "csv":
@@ -253,6 +266,8 @@ export async function createHookArray(arr = [], opts) {
253
266
  enrichedArray.getWriteDir = getWriteDir;
254
267
  enrichedArray.getWritePath = getWritePath;
255
268
  enrichedArray.getWrittenFiles = () => [...writtenFiles];
269
+ enrichedArray.type = type;
270
+ enrichedArray.format = format;
256
271
 
257
272
  // Add additional properties from rest
258
273
  for (const key in rest) {
@@ -309,9 +324,20 @@ export class StorageManager {
309
324
  context: this.context
310
325
  }),
311
326
 
327
+ // v1.8.0 — identity-less metric snapshots (`standaloneEvents`).
328
+ standaloneEventData: await createHookArray([], {
329
+ hook: config.hook,
330
+ type: "standalone",
331
+ filepath: `${config.name}-STANDALONE`,
332
+ format: config.format || "csv",
333
+ concurrency: config.concurrency || 1,
334
+ context: this.context
335
+ }),
336
+
312
337
  scdTableData: [],
313
338
  groupProfilesData: [],
314
339
  lookupTableData: [],
340
+ warehouseMetricData: [],
315
341
 
316
342
  mirrorEventData: await createHookArray([], {
317
343
  hook: config.hook,
@@ -375,6 +401,28 @@ export class StorageManager {
375
401
  }
376
402
  }
377
403
 
404
+ if (config.warehouseMetrics && config.warehouseMetrics.length > 0) {
405
+ for (const warehouseMetric of config.warehouseMetrics) {
406
+ const fixedColumns = [
407
+ warehouseMetric.timeColumn,
408
+ ...(warehouseMetric.source.groupBy || []),
409
+ warehouseMetric.valueColumn,
410
+ ...Object.keys(warehouseMetric.columns || {}),
411
+ ];
412
+ const warehouseArray = await createHookArray([], {
413
+ hook: config.hook,
414
+ type: "warehouse",
415
+ filepath: `${config.name}-WAREHOUSE-${warehouseMetric.name}`,
416
+ format: warehouseMetric.format || config.format || "csv",
417
+ concurrency: config.concurrency || 1,
418
+ context: this.context,
419
+ metricName: warehouseMetric.name,
420
+ fixedColumns,
421
+ });
422
+ storage.warehouseMetricData.push(warehouseArray);
423
+ }
424
+ }
425
+
378
426
  return storage;
379
427
  }
380
428
 
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Standalone event generator module (v1.8.0)
3
+ *
4
+ * Standalone events are IDENTITY-LESS metric snapshots. They carry no `user_id`
5
+ * and no `device_id` — they describe a system, not a person. Think daily CDN
6
+ * egress per region, weekly billing rollups per plan tier, hourly queue depth
7
+ * per cluster. `$ad_spend` is the same idea, hard-coded; this is the general form.
8
+ *
9
+ * One record is emitted per cadence tick per dimension cross-product row:
10
+ *
11
+ * cadence: 'day', dimensions: { region: ['us','eu'], tier: ['a','b'] }
12
+ * → 4 records per day (us/a, us/b, eu/a, eu/b)
13
+ *
14
+ * `distinct_id` is synthetic. It is the value of the dimension named by
15
+ * `distinctIdFrom`, or the event name when no dimension is named. It exists only
16
+ * so Mixpanel accepts the record; it never maps to a person.
17
+ */
18
+
19
+ /** @typedef {import('../../types').Context} Context */
20
+ /** @typedef {import('../../types').StandaloneEventConfig} StandaloneEventConfig */
21
+ /** @typedef {import('../../types').ResolvedStandaloneEventConfig} ResolvedStandaloneEventConfig */
22
+
23
+ import { randomUUID } from "node:crypto";
24
+ import dayjs from "dayjs";
25
+ import utc from "dayjs/plugin/utc.js";
26
+ import * as u from "../utils/utils.js";
27
+
28
+ dayjs.extend(utc);
29
+
30
+ /** Seconds per cadence tick. */
31
+ const CADENCE_SECONDS = {
32
+ hour: 60 * 60,
33
+ day: 24 * 60 * 60,
34
+ week: 7 * 24 * 60 * 60,
35
+ };
36
+
37
+ /** Cadence names the validator accepts. */
38
+ export const VALID_CADENCES = Object.freeze(Object.keys(CADENCE_SECONDS));
39
+
40
+ /**
41
+ * Expands a `dimensions` map into every combination of its values.
42
+ * `{ a: [1,2], b: ['x'] }` → `[{a:1,b:'x'}, {a:2,b:'x'}]`.
43
+ * An empty/absent map yields a single empty row, so an undimensioned
44
+ * standalone event emits exactly one record per tick.
45
+ *
46
+ * @param {Record<string, any[]>} dimensions
47
+ * @returns {Record<string, any>[]}
48
+ */
49
+ export function expandDimensions(dimensions) {
50
+ const keys = Object.keys(dimensions || {});
51
+ if (keys.length === 0) return [{}];
52
+
53
+ let rows = [{}];
54
+ for (const key of keys) {
55
+ const values = dimensions[key];
56
+ /** @type {Record<string, any>[]} */
57
+ const next = [];
58
+ for (const row of rows) {
59
+ for (const value of values) {
60
+ next.push({ ...row, [key]: value });
61
+ }
62
+ }
63
+ rows = next;
64
+ }
65
+ return rows;
66
+ }
67
+
68
+ /**
69
+ * Every tick timestamp for one standalone event across the dataset window.
70
+ *
71
+ * Ticks start at `datasetStart` and step by the cadence. The final tick is the
72
+ * last one that lands at or before `datasetEnd` — a standalone event never
73
+ * emits a record in the future, matching the engine-wide future-time guard.
74
+ *
75
+ * @param {number} datasetStart - unix seconds
76
+ * @param {number} datasetEnd - unix seconds
77
+ * @param {'hour'|'day'|'week'} cadence
78
+ * @returns {number[]} unix seconds, ascending
79
+ */
80
+ export function buildTicks(datasetStart, datasetEnd, cadence) {
81
+ const step = CADENCE_SECONDS[cadence];
82
+ if (!step) throw new Error(`unknown cadence: ${cadence}`);
83
+
84
+ /** @type {number[]} */
85
+ const ticks = [];
86
+ for (let t = datasetStart; t <= datasetEnd; t += step) {
87
+ ticks.push(t);
88
+ }
89
+ return ticks;
90
+ }
91
+
92
+ /**
93
+ * Builds every record for a single standalone event config.
94
+ *
95
+ * @param {Context} context
96
+ * @param {ResolvedStandaloneEventConfig} spec
97
+ * @returns {Record<string, any>[]}
98
+ */
99
+ export function makeStandaloneEvents(context, spec) {
100
+ const { config } = context;
101
+ const datasetStart = context.FIXED_BEGIN;
102
+ const datasetEnd = context.FIXED_NOW;
103
+
104
+ const ticks = buildTicks(datasetStart, datasetEnd, spec.cadence);
105
+ const rows = expandDimensions(spec.dimensions);
106
+ const propKeys = Object.keys(spec.properties);
107
+
108
+ /** @type {Record<string, any>[]} */
109
+ const records = [];
110
+
111
+ for (let tickIndex = 0; tickIndex < ticks.length; tickIndex++) {
112
+ const tickUnix = ticks[tickIndex];
113
+ const isoTime = dayjs.unix(tickUnix).utc().toISOString();
114
+
115
+ for (const dimensions of rows) {
116
+ context.incrementOperations();
117
+
118
+ // distinct_id is synthetic: the named dimension's value, else the
119
+ // event name. Never a person. Stable across the run so the record
120
+ // series groups cleanly in Mixpanel.
121
+ const distinctId = spec.distinctIdFrom
122
+ ? String(dimensions[spec.distinctIdFrom])
123
+ : spec.event;
124
+
125
+ /** @type {Record<string, any>} */
126
+ const record = {
127
+ event: spec.event,
128
+ time: isoTime,
129
+ insert_id: randomUUID(),
130
+ distinct_id: distinctId,
131
+ ...dimensions,
132
+ };
133
+
134
+ // The context handed to every property function. Shares the
135
+ // ValueContext members (`time`, `config`) so a function written for
136
+ // a normal event property still works here, plus the standalone-only
137
+ // members a snapshot needs to shape a trend.
138
+ const valueContext = {
139
+ time: tickUnix * 1000,
140
+ config,
141
+ dimensions,
142
+ tickIndex,
143
+ tickCount: ticks.length,
144
+ cadence: spec.cadence,
145
+ event: record,
146
+ };
147
+
148
+ for (const key of propKeys) {
149
+ record[key] = u.choose(spec.properties[key], valueContext);
150
+ }
151
+
152
+ records.push(record);
153
+ }
154
+ }
155
+
156
+ return records;
157
+ }
158
+
159
+ /**
160
+ * Validates and normalizes the `standaloneEvents` config array.
161
+ * Throws on anything malformed — a silent skip would hide a whole data stream.
162
+ *
163
+ * @param {unknown} standaloneEvents
164
+ * @returns {ResolvedStandaloneEventConfig[]}
165
+ */
166
+ export function validateStandaloneEvents(standaloneEvents) {
167
+ if (standaloneEvents === undefined || standaloneEvents === null) return [];
168
+ if (!Array.isArray(standaloneEvents)) {
169
+ throw new Error("standaloneEvents must be an array");
170
+ }
171
+
172
+ const seen = new Set();
173
+
174
+ return standaloneEvents.map((spec, index) => {
175
+ const label = `standaloneEvents[${index}]`;
176
+
177
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
178
+ throw new Error(`${label} must be an object`);
179
+ }
180
+ if (typeof spec.event !== "string" || !spec.event.trim()) {
181
+ throw new Error(`${label}.event must be a non-empty string`);
182
+ }
183
+ if (seen.has(spec.event)) {
184
+ throw new Error(`${label}.event "${spec.event}" is declared more than once`);
185
+ }
186
+ seen.add(spec.event);
187
+
188
+ const cadence = spec.cadence || "day";
189
+ if (!CADENCE_SECONDS[cadence]) {
190
+ throw new Error(
191
+ `${label}.cadence must be one of ${VALID_CADENCES.join(", ")} (got "${cadence}")`
192
+ );
193
+ }
194
+
195
+ /** @type {Record<string, any[]>} */
196
+ const dimensions = {};
197
+ if (spec.dimensions !== undefined && spec.dimensions !== null) {
198
+ if (typeof spec.dimensions !== "object" || Array.isArray(spec.dimensions)) {
199
+ throw new Error(`${label}.dimensions must be an object of arrays`);
200
+ }
201
+ for (const [key, values] of Object.entries(spec.dimensions)) {
202
+ if (!Array.isArray(values) || values.length === 0) {
203
+ throw new Error(`${label}.dimensions.${key} must be a non-empty array`);
204
+ }
205
+ dimensions[key] = values;
206
+ }
207
+ }
208
+
209
+ if (spec.distinctIdFrom !== undefined && spec.distinctIdFrom !== null) {
210
+ if (typeof spec.distinctIdFrom !== "string") {
211
+ throw new Error(`${label}.distinctIdFrom must be a string`);
212
+ }
213
+ if (!(spec.distinctIdFrom in dimensions)) {
214
+ throw new Error(
215
+ `${label}.distinctIdFrom "${spec.distinctIdFrom}" is not a declared dimension`
216
+ );
217
+ }
218
+ }
219
+
220
+ if (spec.properties !== undefined && spec.properties !== null) {
221
+ if (typeof spec.properties !== "object" || Array.isArray(spec.properties)) {
222
+ throw new Error(`${label}.properties must be an object`);
223
+ }
224
+ }
225
+
226
+ // Schema-first: a property key must not collide with a dimension key or
227
+ // with a reserved record key. A collision would silently overwrite one
228
+ // of them and the author would never see it.
229
+ const properties = spec.properties || {};
230
+ const reserved = new Set(["event", "time", "insert_id", "distinct_id", "user_id", "device_id"]);
231
+ for (const key of Object.keys(properties)) {
232
+ if (reserved.has(key)) {
233
+ throw new Error(`${label}.properties.${key} collides with a reserved record key`);
234
+ }
235
+ if (key in dimensions) {
236
+ throw new Error(`${label}.properties.${key} collides with a dimension of the same name`);
237
+ }
238
+ }
239
+
240
+ return {
241
+ event: spec.event,
242
+ cadence,
243
+ dimensions,
244
+ distinctIdFrom: spec.distinctIdFrom || null,
245
+ properties,
246
+ };
247
+ });
248
+ }