@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
|
@@ -71,6 +71,7 @@ async function _sendToMixpanel(context) {
|
|
|
71
71
|
const { config, storage } = context;
|
|
72
72
|
const {
|
|
73
73
|
adSpendData,
|
|
74
|
+
standaloneEventData,
|
|
74
75
|
eventData,
|
|
75
76
|
groupProfilesData,
|
|
76
77
|
scdTableData,
|
|
@@ -208,6 +209,29 @@ async function _sendToMixpanel(context) {
|
|
|
208
209
|
importResults.adSpend = imported;
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
// Import standalone (identity-less) metric snapshots — v1.8.0.
|
|
213
|
+
// Same shape as the ad-spend path: its own stream, imported as events.
|
|
214
|
+
// Gated on the config so a batch-mode run without `standaloneEvents` does
|
|
215
|
+
// not attempt an empty file read.
|
|
216
|
+
const hasStandaloneConfig = Array.isArray(config.standaloneEvents) && config.standaloneEvents.length > 0;
|
|
217
|
+
if (hasStandaloneConfig && (standaloneEventData?.length > 0 || isBATCH_MODE)) {
|
|
218
|
+
log(` Standalone Events`);
|
|
219
|
+
let standaloneToImport = u.deepClone(standaloneEventData);
|
|
220
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && standaloneEventData && standaloneEventData.length === 0);
|
|
221
|
+
if (shouldReadFromFiles && standaloneEventData?.getWrittenFiles) {
|
|
222
|
+
const files = standaloneEventData.getWrittenFiles();
|
|
223
|
+
if (files.length > 0) standaloneToImport = files;
|
|
224
|
+
}
|
|
225
|
+
const standaloneTotal = Array.isArray(standaloneToImport) ? standaloneToImport.length : 0;
|
|
226
|
+
const imported = await mp(creds, standaloneToImport, {
|
|
227
|
+
recordType: "event",
|
|
228
|
+
...commonOpts,
|
|
229
|
+
progressCallback: makeProgressCallback(standaloneTotal),
|
|
230
|
+
});
|
|
231
|
+
log(` -> ${comma(imported.success)} standalone events sent\n`);
|
|
232
|
+
importResults.standalone = imported;
|
|
233
|
+
}
|
|
234
|
+
|
|
211
235
|
// Import group profiles
|
|
212
236
|
if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
|
|
213
237
|
for (const groupEntity of groupProfilesData) {
|
|
@@ -403,11 +427,12 @@ function logProblems(problems) {
|
|
|
403
427
|
*/
|
|
404
428
|
function collectWrittenFiles(storage) {
|
|
405
429
|
const files = [];
|
|
430
|
+
if (storage.warehouseManifestFile) files.push(storage.warehouseManifestFile);
|
|
406
431
|
for (const container of [storage.eventData, storage.userProfilesData, storage.adSpendData,
|
|
407
|
-
storage.mirrorEventData, storage.groupEventData]) {
|
|
432
|
+
storage.standaloneEventData, storage.mirrorEventData, storage.groupEventData]) {
|
|
408
433
|
if (container?.getWrittenFiles) files.push(...container.getWrittenFiles());
|
|
409
434
|
}
|
|
410
|
-
for (const arr of [storage.groupProfilesData, storage.scdTableData, storage.lookupTableData]) {
|
|
435
|
+
for (const arr of [storage.groupProfilesData, storage.scdTableData, storage.lookupTableData, storage.warehouseMetricData]) {
|
|
411
436
|
if (Array.isArray(arr)) {
|
|
412
437
|
for (const c of arr) {
|
|
413
438
|
if (c?.getWrittenFiles) files.push(...c.getWrittenFiles());
|
|
@@ -85,7 +85,6 @@
|
|
|
85
85
|
"properties": {
|
|
86
86
|
"where": {
|
|
87
87
|
"type": "object",
|
|
88
|
-
"minProperties": 1,
|
|
89
88
|
"description": "Column → value (equality) or { op, value } comparison. All clauses must match (AND).",
|
|
90
89
|
"additionalProperties": {
|
|
91
90
|
"oneOf": [
|
|
@@ -123,23 +122,49 @@
|
|
|
123
122
|
"properties": {
|
|
124
123
|
"type": { "type": "string", "minLength": 1 }
|
|
125
124
|
},
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
125
|
+
"allOf": [
|
|
126
|
+
{
|
|
127
|
+
"if": {
|
|
128
|
+
"properties": { "type": { "const": "duckdb" } }
|
|
129
|
+
},
|
|
130
|
+
"then": {
|
|
131
|
+
"required": ["type", "sql"],
|
|
132
|
+
"properties": {
|
|
133
|
+
"type": { "const": "duckdb" },
|
|
134
|
+
"sql": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"minLength": 1,
|
|
137
|
+
"description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"if": {
|
|
144
|
+
"properties": { "type": { "const": "warehouse" } }
|
|
145
|
+
},
|
|
146
|
+
"then": {
|
|
147
|
+
"required": ["type", "table"],
|
|
148
|
+
"properties": {
|
|
149
|
+
"type": { "const": "warehouse" },
|
|
150
|
+
"table": { "type": "string", "minLength": 1 }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"if": {
|
|
156
|
+
"properties": { "type": { "const": "warehouse-stats" } }
|
|
157
|
+
},
|
|
158
|
+
"then": {
|
|
159
|
+
"required": ["type", "table"],
|
|
160
|
+
"properties": {
|
|
161
|
+
"type": { "const": "warehouse-stats" },
|
|
162
|
+
"table": { "type": "string", "minLength": 1 }
|
|
163
|
+
}
|
|
137
164
|
}
|
|
138
165
|
}
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
"description": "Anything other than 'duckdb' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
|
|
142
|
-
}
|
|
166
|
+
],
|
|
167
|
+
"description": "Anything other than 'duckdb', 'warehouse', or 'warehouse-stats' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
|
|
143
168
|
},
|
|
144
169
|
"expect": {
|
|
145
170
|
"type": "object",
|
package/lib/utils/utils.js
CHANGED
|
@@ -815,10 +815,26 @@ function streamJSON(filePath, data, options = {}) {
|
|
|
815
815
|
});
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
function csvRow(item, columns) {
|
|
819
|
+
return columns.map(col => {
|
|
820
|
+
const value = item[col];
|
|
821
|
+
|
|
822
|
+
if (value === null || value === undefined) {
|
|
823
|
+
return '';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const serialized = typeof value === 'object'
|
|
827
|
+
? JSON.stringify(value)
|
|
828
|
+
: value.toString();
|
|
829
|
+
|
|
830
|
+
return `"${serialized.replace(/"/g, '""')}"`;
|
|
831
|
+
}).join(',');
|
|
832
|
+
}
|
|
833
|
+
|
|
818
834
|
function streamCSV(filePath, data, options = {}) {
|
|
819
835
|
return new Promise((resolve, reject) => {
|
|
820
836
|
let writeStream;
|
|
821
|
-
const { gzip = false } = options;
|
|
837
|
+
const { gzip = false, fixedColumns } = options;
|
|
822
838
|
|
|
823
839
|
if (filePath?.startsWith('gs://')) {
|
|
824
840
|
const { uri, bucket, file } = parseGCSUri(filePath);
|
|
@@ -842,20 +858,12 @@ function streamCSV(filePath, data, options = {}) {
|
|
|
842
858
|
}
|
|
843
859
|
}
|
|
844
860
|
|
|
845
|
-
|
|
846
|
-
const columns = getUniqueKeys(data); // Assuming getUniqueKeys properly retrieves all keys
|
|
861
|
+
const columns = Array.isArray(fixedColumns) ? fixedColumns : getUniqueKeys(data);
|
|
847
862
|
|
|
848
|
-
// Stream the header
|
|
849
863
|
writeStream.write(columns.join(',') + '\n');
|
|
850
864
|
|
|
851
|
-
// Stream each data row
|
|
852
865
|
data.forEach(item => {
|
|
853
|
-
|
|
854
|
-
// Ensure all nested objects are properly stringified
|
|
855
|
-
if (typeof item[key] === "object") item[key] = JSON.stringify(item[key]);
|
|
856
|
-
}
|
|
857
|
-
const row = columns.map(col => item[col] ? `"${item[col].toString().replace(/"/g, '""')}"` : "").join(',');
|
|
858
|
-
writeStream.write(row + '\n');
|
|
866
|
+
writeStream.write(csvRow(item, columns) + '\n');
|
|
859
867
|
});
|
|
860
868
|
|
|
861
869
|
writeStream.end();
|
|
@@ -1370,7 +1378,7 @@ META
|
|
|
1370
1378
|
* @param {Config} config
|
|
1371
1379
|
*/
|
|
1372
1380
|
function buildFileNames(config) {
|
|
1373
|
-
const { format = "csv", groupKeys = [], lookupTables = [] } = config;
|
|
1381
|
+
const { format = "csv", groupKeys = [], lookupTables = [], warehouseMetrics = [] } = config;
|
|
1374
1382
|
let extension = "";
|
|
1375
1383
|
extension = format === "csv" ? "csv" : "json";
|
|
1376
1384
|
// const current = dayjs.utc().format("MM-DD-HH");
|
|
@@ -1388,10 +1396,12 @@ function buildFileNames(config) {
|
|
|
1388
1396
|
eventFiles: [path.join(writeDir, `${simName}-EVENTS.${extension}`)],
|
|
1389
1397
|
userFiles: [path.join(writeDir, `${simName}-USERS.${extension}`)],
|
|
1390
1398
|
adSpendFiles: [],
|
|
1399
|
+
standaloneFiles: [],
|
|
1391
1400
|
scdFiles: [],
|
|
1392
1401
|
mirrorFiles: [],
|
|
1393
1402
|
groupFiles: [],
|
|
1394
1403
|
lookupFiles: [],
|
|
1404
|
+
warehouseFiles: [],
|
|
1395
1405
|
folder: writeDir,
|
|
1396
1406
|
};
|
|
1397
1407
|
//add ad spend files
|
|
@@ -1399,6 +1409,11 @@ function buildFileNames(config) {
|
|
|
1399
1409
|
writePaths.adSpendFiles.push(path.join(writeDir, `${simName}-AD-SPEND.${extension}`));
|
|
1400
1410
|
}
|
|
1401
1411
|
|
|
1412
|
+
//add standalone (identity-less snapshot) files — v1.8.0
|
|
1413
|
+
if (Array.isArray(config?.standaloneEvents) && config.standaloneEvents.length > 0) {
|
|
1414
|
+
writePaths.standaloneFiles.push(path.join(writeDir, `${simName}-STANDALONE.${extension}`));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1402
1417
|
//add SCD files
|
|
1403
1418
|
const scdKeys = Object.keys(config?.scdProps || {});
|
|
1404
1419
|
for (const key of scdKeys) {
|
|
@@ -1425,6 +1440,15 @@ function buildFileNames(config) {
|
|
|
1425
1440
|
);
|
|
1426
1441
|
}
|
|
1427
1442
|
|
|
1443
|
+
for (const warehouseMetric of warehouseMetrics) {
|
|
1444
|
+
const metricName = warehouseMetric?.name;
|
|
1445
|
+
if (typeof metricName !== 'string') continue;
|
|
1446
|
+
const metricFormat = warehouseMetric?.format || format || 'csv';
|
|
1447
|
+
writePaths.warehouseFiles.push(
|
|
1448
|
+
path.join(writeDir, `${simName}-WAREHOUSE-${metricName}.${metricFormat}`)
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1428
1452
|
//add mirror files
|
|
1429
1453
|
const mirrorProps = config?.mirrorProps || {};
|
|
1430
1454
|
if (Object.keys(mirrorProps).length) {
|
|
@@ -1948,6 +1972,7 @@ export {
|
|
|
1948
1972
|
generateUser,
|
|
1949
1973
|
optimizedBoxMuller,
|
|
1950
1974
|
buildFileNames,
|
|
1975
|
+
csvRow,
|
|
1951
1976
|
streamJSON,
|
|
1952
1977
|
streamCSV,
|
|
1953
1978
|
streamParquet,
|
package/lib/verify/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { verifyDungeon } from './verify-dungeon.js';
|
|
|
17
17
|
// `evaluateStories` / `applyFunnelDefaults`. Use the RETURN value; it does not
|
|
18
18
|
// enrich the config you pass in.
|
|
19
19
|
export { validateDungeonConfig } from '../core/config-validator.js';
|
|
20
|
+
export { pearson, computeWarehouseStats, computeWarehouseSourceRows, auditWarehouseRows } from './warehouse.js';
|
|
20
21
|
export { deriveExpectedSchema, validateSchema } from './schema-validator.js';
|
|
21
22
|
export {
|
|
22
23
|
evaluateFunnel,
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
29
29
|
import { applyFunnelDefaults } from './verify-dungeon.js';
|
|
30
|
+
import { computeWarehouseSourceRows, computeWarehouseStats } from './warehouse.js';
|
|
30
31
|
|
|
31
32
|
/** Closed archetype enum — MUST match lib/templates/story-spec.schema.json (unit-tested). */
|
|
32
33
|
export const STORY_ARCHETYPES = [
|
|
@@ -322,6 +323,9 @@ export function validateStories(stories) {
|
|
|
322
323
|
err(ap, 'breakdown: required object with a string `type`');
|
|
323
324
|
} else if (a.breakdown.type === 'duckdb' && (typeof a.breakdown.sql !== 'string' || !a.breakdown.sql)) {
|
|
324
325
|
err(ap, 'breakdown: type "duckdb" requires a non-empty `sql`');
|
|
326
|
+
} else if ((a.breakdown.type === 'warehouse' || a.breakdown.type === 'warehouse-stats')
|
|
327
|
+
&& (typeof a.breakdown.table !== 'string' || !a.breakdown.table.trim())) {
|
|
328
|
+
err(ap, `breakdown: type "${a.breakdown.type}" requires a non-empty \`table\``);
|
|
325
329
|
}
|
|
326
330
|
if (a.expect === undefined && typeof a.assert !== 'function') {
|
|
327
331
|
err(ap, 'requires `expect` or a function-valued `assert`');
|
|
@@ -339,8 +343,8 @@ export function validateStories(stories) {
|
|
|
339
343
|
for (const [name, spec] of Object.entries(a.select)) {
|
|
340
344
|
if (!NAME_RE.test(name)) err(ap, `select: name "${name}" must be an identifier`);
|
|
341
345
|
selectNames.add(name);
|
|
342
|
-
if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object'
|
|
343
|
-
err(ap, `select.${name}: requires a
|
|
346
|
+
if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object') {
|
|
347
|
+
err(ap, `select.${name}: requires a \`where\` object`);
|
|
344
348
|
continue;
|
|
345
349
|
}
|
|
346
350
|
for (const [col, cond] of Object.entries(spec.where)) {
|
|
@@ -406,8 +410,8 @@ export function storiesToChecks(stories) {
|
|
|
406
410
|
for (const story of stories) {
|
|
407
411
|
story.assertions.forEach((assertion, i) => {
|
|
408
412
|
const name = `${story.id}[${i}]`;
|
|
409
|
-
if (assertion.breakdown.type === 'duckdb') {
|
|
410
|
-
console.warn(`[dungeon-master] ${name}:
|
|
413
|
+
if (assertion.breakdown.type === 'duckdb' || assertion.breakdown.type === 'warehouse' || assertion.breakdown.type === 'warehouse-stats') {
|
|
414
|
+
console.warn(`[dungeon-master] ${name}: ${assertion.breakdown.type} assertions only run in disk mode via scripts/verify-stories.mjs — skipped in verifyDungeon in-memory checks.`);
|
|
411
415
|
return;
|
|
412
416
|
}
|
|
413
417
|
checks.push({
|
|
@@ -441,11 +445,34 @@ export function storiesToChecks(stories) {
|
|
|
441
445
|
* auto-build over large profile sets is wasteful.
|
|
442
446
|
* @param {(sql: string) => Promise<Array<Object>>} [opts.runSql] - duckdb
|
|
443
447
|
* executor (provided by the CLI in disk mode). Absent → duckdb assertions
|
|
444
|
-
* report NONE
|
|
448
|
+
* report NONE unless skipDiskOnlyDuckdb is set.
|
|
449
|
+
* @param {Record<string, Array<Object>>} [opts.warehouseRows] - Loaded
|
|
450
|
+
* warehouse rows keyed by table name.
|
|
451
|
+
* @param {Record<string, Object>} [opts.warehouseSpecs] - Resolved warehouse
|
|
452
|
+
* metric specs keyed by table name.
|
|
453
|
+
* @param {string|number} [opts.datasetStart] - Dataset window start for
|
|
454
|
+
* in-window warehouse stats comparisons.
|
|
455
|
+
* @param {string|number} [opts.datasetEnd] - Dataset window end for in-window
|
|
456
|
+
* warehouse stats comparisons.
|
|
457
|
+
* @param {boolean} [opts.skipDiskOnlyDuckdb] - Mark duckdb assertions skipped
|
|
458
|
+
* instead of NONE when no SQL executor is available.
|
|
459
|
+
* @param {(message: string) => void} [opts.onWarning] - Warning sink for
|
|
460
|
+
* skipped disk-only assertions.
|
|
445
461
|
* @returns {Promise<Array<{ id: string, hook: string, archetype: string, verdict: string, assertions: Array<{ name: string, verdict: string, observed: number|null, detail: string }> }>>}
|
|
446
462
|
*/
|
|
447
463
|
export async function evaluateStories(stories, events, opts) {
|
|
448
|
-
const {
|
|
464
|
+
const {
|
|
465
|
+
profiles,
|
|
466
|
+
funnels,
|
|
467
|
+
runSql,
|
|
468
|
+
identityMap,
|
|
469
|
+
warehouseRows,
|
|
470
|
+
warehouseSpecs,
|
|
471
|
+
datasetStart,
|
|
472
|
+
datasetEnd,
|
|
473
|
+
skipDiskOnlyDuckdb,
|
|
474
|
+
onWarning,
|
|
475
|
+
} = opts || {};
|
|
449
476
|
const v = validateStories(stories);
|
|
450
477
|
if (!v.valid) {
|
|
451
478
|
throw new Error(`evaluateStories: invalid stories:\n ${v.errors.join('\n ')}`);
|
|
@@ -460,10 +487,21 @@ export async function evaluateStories(stories, events, opts) {
|
|
|
460
487
|
try {
|
|
461
488
|
if (assertion.breakdown.type === 'duckdb') {
|
|
462
489
|
if (!runSql) {
|
|
463
|
-
|
|
490
|
+
if (skipDiskOnlyDuckdb) {
|
|
491
|
+
onWarning?.(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
|
|
492
|
+
results.push({ name, verdict: 'SKIPPED', observed: null, detail: 'duckdb assertion requires disk mode (skipped in-memory)' });
|
|
493
|
+
} else {
|
|
494
|
+
results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
|
|
495
|
+
}
|
|
464
496
|
continue;
|
|
465
497
|
}
|
|
466
498
|
rows = await runSql(assertion.breakdown.sql);
|
|
499
|
+
} else if (assertion.breakdown.type === 'warehouse') {
|
|
500
|
+
rows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
|
|
501
|
+
} else if (assertion.breakdown.type === 'warehouse-stats') {
|
|
502
|
+
const spec = resolveWarehouseSpec(assertion.breakdown.table, warehouseSpecs);
|
|
503
|
+
const tableRows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
|
|
504
|
+
rows = [computeWarehouseStats(tableRows, spec, computeWarehouseSourceRows(events, spec), { datasetStart, datasetEnd })];
|
|
467
505
|
} else {
|
|
468
506
|
const bArgs = applyFunnelDefaults(assertion.breakdown, funnels, profiles);
|
|
469
507
|
if (identityMap && bArgs.identityMap === undefined) bArgs.identityMap = identityMap;
|
|
@@ -476,8 +514,33 @@ export async function evaluateStories(stories, events, opts) {
|
|
|
476
514
|
const res = evaluateAssertion(rows, assertion, { events, profiles });
|
|
477
515
|
results.push({ name, ...res });
|
|
478
516
|
}
|
|
479
|
-
const
|
|
517
|
+
const counted = results.filter((result) => result.verdict !== 'SKIPPED');
|
|
518
|
+
const worst = counted.length
|
|
519
|
+
? counted.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED')
|
|
520
|
+
: 'SKIPPED';
|
|
480
521
|
out.push({ id: story.id, hook: story.hook, archetype: story.archetype, verdict: worst, assertions: results });
|
|
481
522
|
}
|
|
482
523
|
return out;
|
|
483
524
|
}
|
|
525
|
+
|
|
526
|
+
function resolveWarehouseSpec(table, warehouseSpecs) {
|
|
527
|
+
const spec = warehouseSpecs?.[table];
|
|
528
|
+
if (!spec) throw new Error(`warehouse table "${table}" is not available`);
|
|
529
|
+
return spec;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveWarehouseRows(table, warehouseRows, warehouseSpecs) {
|
|
533
|
+
const spec = resolveWarehouseSpec(table, warehouseSpecs);
|
|
534
|
+
const rows = warehouseRows?.[table];
|
|
535
|
+
if (!Array.isArray(rows)) throw new Error(`warehouse rows for table "${table}" are not available`);
|
|
536
|
+
return rows.map((row) => withWarehouseTimestamp(row, spec));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function withWarehouseTimestamp(row, spec) {
|
|
540
|
+
if (Number.isFinite(row?.__t)) return row;
|
|
541
|
+
const raw = row?.[spec.timeColumn];
|
|
542
|
+
const parsed = typeof raw === 'string'
|
|
543
|
+
? Date.parse(/T/.test(raw) ? raw : `${raw}T00:00:00Z`)
|
|
544
|
+
: NaN;
|
|
545
|
+
return { ...row, __t: Number.isFinite(parsed) ? parsed / 1000 : null };
|
|
546
|
+
}
|