@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -11
- package/.claude/skills/create-dungeon/SKILL.md +49 -10
- package/.claude/skills/create-project/SKILL.md +22 -3
- package/.claude/skills/create-project/context.mjs +89 -0
- package/.claude/skills/create-project/provision.mjs +1 -60
- package/.claude/skills/headless-build/SKILL.md +18 -1
- package/.claude/skills/powertools/SKILL.md +20 -1
- package/.claude/skills/release-check/SKILL.md +99 -0
- package/.claude/skills/verify-dungeon/SKILL.md +71 -16
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
- package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
- package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
- package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
- package/.claude/skills/write-hooks/SKILL.md +33 -3
- package/CHANGELOG.md +142 -0
- package/HOOKS.md +105 -5
- package/README.md +228 -0
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/dungeons/technical/warehouse.js +187 -0
- package/index.js +116 -2
- package/lib/core/config-validator.js +21 -0
- package/lib/core/dungeon-loader.js +1 -1
- package/lib/core/storage.js +51 -3
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/orchestrators/mixpanel-sender.js +27 -2
- package/lib/orchestrators/user-loop.js +1 -0
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/utils.js +37 -12
- package/lib/verify/index.js +1 -0
- package/lib/verify/story-runner.js +71 -8
- package/lib/verify/warehouse.js +683 -0
- package/package.json +4 -2
- package/scripts/verify-stories.mjs +150 -44
- package/types.d.ts +303 -4
package/lib/core/storage.js
CHANGED
|
@@ -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
|
+
}
|