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

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 (43) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +30 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +84 -44
  3. package/.claude/skills/create-project/SKILL.md +28 -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 +39 -12
  7. package/.claude/skills/powertools/SKILL.md +26 -3
  8. package/.claude/skills/release-check/SKILL.md +124 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +103 -29
  10. package/.claude/skills/verify-dungeon/references/alignment-contract.md +84 -0
  11. package/.claude/skills/verify-dungeon/references/counting-semantics.md +41 -16
  12. package/.claude/skills/verify-dungeon/references/report-format.md +41 -10
  13. package/.claude/skills/verify-dungeon/references/sql-recipes.md +171 -226
  14. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  15. package/.claude/skills/warehouse-metrics/SKILL.md +111 -0
  16. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  17. package/.claude/skills/write-hooks/SKILL.md +94 -51
  18. package/CHANGELOG.md +183 -0
  19. package/HOOKS.md +165 -18
  20. package/README.md +265 -1
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/docs/guides/1.8.1-upgrade-guide.md +153 -0
  23. package/dungeons/technical/warehouse.js +187 -0
  24. package/index.js +116 -2
  25. package/lib/core/config-validator.js +21 -0
  26. package/lib/core/dungeon-loader.js +1 -1
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +6 -0
  29. package/lib/generators/funnels.js +15 -0
  30. package/lib/generators/standalone.js +248 -0
  31. package/lib/generators/warehouse.js +828 -0
  32. package/lib/hook-helpers/shape.js +73 -17
  33. package/lib/orchestrators/mixpanel-sender.js +27 -2
  34. package/lib/orchestrators/user-loop.js +83 -15
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/utils.js +37 -12
  37. package/lib/verify/funnel-engine.js +66 -26
  38. package/lib/verify/index.js +1 -0
  39. package/lib/verify/story-runner.js +71 -8
  40. package/lib/verify/warehouse.js +683 -0
  41. package/package.json +4 -2
  42. package/scripts/verify-stories.mjs +150 -44
  43. package/types.d.ts +312 -9
@@ -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);
@@ -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
 
@@ -17,6 +17,8 @@ import { dataLogger as logger } from "../utils/logger.js";
17
17
  // Keys that must never be nulled by data quality gremlins
18
18
  const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
19
19
 
20
+ export const engineIdentity = Symbol('engineIdentity');
21
+
20
22
 
21
23
  /**
22
24
  * Creates a Mixpanel event with a flat shape
@@ -293,6 +295,9 @@ export async function makeEvent(
293
295
 
294
296
  eventTemplate.insert_id = randomUUID();
295
297
 
298
+ const originalIdentity = { user_id: eventTemplate.user_id, device_id: eventTemplate.device_id };
299
+ Object.defineProperty(eventTemplate, engineIdentity, { value: originalIdentity, configurable: true });
300
+
296
301
  // Call hook if configured (hooks override everything — they are the final authority)
297
302
  const { hook } = config;
298
303
  if (hook) {
@@ -305,6 +310,7 @@ export async function makeEvent(
305
310
  });
306
311
  // If hook returns a modified event, use it; otherwise use original
307
312
  if (hookedEvent && typeof hookedEvent === 'object') {
313
+ Object.defineProperty(hookedEvent, engineIdentity, { value: originalIdentity, configurable: true });
308
314
  return hookedEvent;
309
315
  }
310
316
  }
@@ -331,6 +331,21 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
331
331
  dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
332
332
  latestTime: Number.isFinite(featureCtx.latestTime) ? featureCtx.latestTime : undefined,
333
333
  };
334
+ if (attemptInfo.isFirstFunnel && attemptInfo.isBorn && funnelEventsWithTiming.length) {
335
+ const spanMs = Math.max(0, ...funnelEventsWithTiming.map(event => event.relativeTimeMs || 0));
336
+ const latestStart = Math.min(funnelFeatureCtx.latestTime ?? context.FIXED_NOW, context.FIXED_NOW - spanMs / 1000);
337
+ if (firstEventTime > latestStart) {
338
+ context.addWarning({
339
+ key: 'lifecycle.firstFunnelClipped',
340
+ requested: spanMs,
341
+ applied: Math.max(0, (context.FIXED_NOW - firstEventTime) * 1000),
342
+ reason: 'First-funnel timing exceeds the remaining dataset window; emit the in-window prefix and omit future steps without compressing configured TTC.',
343
+ severity: 'warn',
344
+ });
345
+ }
346
+ funnelFeatureCtx.latestTime = Math.max(firstEventTime, latestStart);
347
+ if (featureCtx.pinFirstTime) funnelFeatureCtx.fixedTimeMs = firstEventTime * 1000;
348
+ }
334
349
 
335
350
  // Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
336
351
  // runs, the first event in execution order whose config has `isAuthEvent: true` is
@@ -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
+ }