@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# 1.8.0 Upgrade Guide
|
|
2
|
+
|
|
3
|
+
**TL;DR: 1.8.0 adds two new metric-table surfaces and one CSV serialization fix.**
|
|
4
|
+
`standaloneEvents` is the identity-less event stream shipped earlier on this
|
|
5
|
+
branch. `warehouseMetrics` is new in this release: warehouse source tables plus a
|
|
6
|
+
manifest, derived from the run's own events. The one behavior change is that CSV
|
|
7
|
+
output now preserves falsy cells, so `0` and `false` stop turning into empty
|
|
8
|
+
strings.
|
|
9
|
+
|
|
10
|
+
## What changed
|
|
11
|
+
|
|
12
|
+
### 1. `standaloneEvents` is the general identity-less stream
|
|
13
|
+
|
|
14
|
+
If you already adopted the branch version of `standaloneEvents`, 1.8.0 is the
|
|
15
|
+
release that ships it. These rows describe a system, not a person. They carry no
|
|
16
|
+
`user_id` and no `device_id`, but they do import to Mixpanel as normal events.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
standaloneEvents: [{
|
|
20
|
+
event: 'cdn_egress',
|
|
21
|
+
cadence: 'day',
|
|
22
|
+
dimensions: { region: ['us-east', 'us-west', 'eu'] },
|
|
23
|
+
distinctIdFrom: 'region',
|
|
24
|
+
properties: {
|
|
25
|
+
gb_out: (ctx) => 400 + ctx.tickIndex * 3,
|
|
26
|
+
cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
|
|
27
|
+
},
|
|
28
|
+
}]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use it for infra, finance, and ops telemetry. If the thing should show up in
|
|
32
|
+
Mixpanel as an event stream, use `standaloneEvents`.
|
|
33
|
+
|
|
34
|
+
### 2. `warehouseMetrics` materializes local warehouse tables
|
|
35
|
+
|
|
36
|
+
If the thing should become a warehouse metric source table, use
|
|
37
|
+
`warehouseMetrics` instead. This pass runs after event generation, reads the
|
|
38
|
+
run's final event stream, and emits local CSV/JSON tables plus a manifest.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
warehouseMetrics: [
|
|
42
|
+
{
|
|
43
|
+
name: 'daily_new_bookings',
|
|
44
|
+
source: { event: 'new_booking', measure: 'sum', property: 'booking_value' },
|
|
45
|
+
timeColumn: 'date',
|
|
46
|
+
valueColumn: 'bookings',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'daily_active_subscriptions',
|
|
50
|
+
type: 'point-in-time',
|
|
51
|
+
source: { event: 'subscription_started', minus: 'subscription_cancelled', measure: 'count' },
|
|
52
|
+
baseline: 40,
|
|
53
|
+
timeColumn: 'date',
|
|
54
|
+
valueColumn: 'active_subscriptions',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'monthly_arr_snapshot',
|
|
58
|
+
type: 'point-in-time',
|
|
59
|
+
grain: 'month',
|
|
60
|
+
sparse: true,
|
|
61
|
+
history: 18,
|
|
62
|
+
source: {
|
|
63
|
+
event: 'subscription_started',
|
|
64
|
+
minus: 'subscription_cancelled',
|
|
65
|
+
measure: 'sum',
|
|
66
|
+
property: 'monthly_value',
|
|
67
|
+
},
|
|
68
|
+
baseline: 24000,
|
|
69
|
+
scale: 12,
|
|
70
|
+
timeColumn: 'month',
|
|
71
|
+
valueColumn: 'arr_usd',
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Key rules:
|
|
77
|
+
|
|
78
|
+
- `type`: `'additive'` or `'point-in-time'`.
|
|
79
|
+
- `grain`: `'day'`, `'week'`, `'month'`.
|
|
80
|
+
- `source.measure`: `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`.
|
|
81
|
+
- `sum` and `avg` require `source.property`.
|
|
82
|
+
- `point-in-time` forbids `avg` and `dau`.
|
|
83
|
+
- `source.groupBy` allows up to two declared keys.
|
|
84
|
+
- `history` prepends synthetic buckets before the live window.
|
|
85
|
+
- `sparse: true` is only valid on point-in-time metrics and emits the first row
|
|
86
|
+
plus changed values.
|
|
87
|
+
|
|
88
|
+
Artifacts:
|
|
89
|
+
|
|
90
|
+
- `result.warehouseMetricData`
|
|
91
|
+
- `result.warehouseManifest`
|
|
92
|
+
- `<name>-WAREHOUSE-<table>.csv|json`
|
|
93
|
+
- `<name>-WAREHOUSE-MANIFEST.json`
|
|
94
|
+
|
|
95
|
+
`token` does not import these tables. They stay local until you deploy them.
|
|
96
|
+
|
|
97
|
+
### 3. Warehouse deploy is a separate, confirm-before-live step
|
|
98
|
+
|
|
99
|
+
The shipped path is `/warehouse-metrics` or:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node .claude/skills/warehouse-metrics/deploy.mjs <dungeon-path> --data-prefix <verified-prefix> --dry-run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Review the SQL files and `warehouse/GAPS.md` first. Live mode uses
|
|
106
|
+
`bq load --replace`, so it overwrites destination tables. The direct live script
|
|
107
|
+
does not prompt, so the operator or agent must obtain explicit consent before
|
|
108
|
+
running the live command.
|
|
109
|
+
|
|
110
|
+
If the warehouse metric CRUD docs route is unavailable (`GET
|
|
111
|
+
/crud/getWarehouseMetrics` returns 404), deploy still loads the tables and
|
|
112
|
+
connects the source, then writes `warehouse/GAPS.md` for the manual create flow.
|
|
113
|
+
|
|
114
|
+
Two real gotchas are now documented in the shipped flow:
|
|
115
|
+
|
|
116
|
+
- Manifest `recommendedAggregation: 'last value'` becomes API
|
|
117
|
+
`aggregation: 'last_value'`.
|
|
118
|
+
- `previewWarehouseMetric` blocks raw substrings like `CREATE` and `UPDATE`, so
|
|
119
|
+
identifiers like `created_at` and `updated_at` fail preview. A fake alias does
|
|
120
|
+
not help if the blocked text still appears anywhere in the SQL.
|
|
121
|
+
|
|
122
|
+
### 4. CSV falsy cells stop collapsing to empty strings
|
|
123
|
+
|
|
124
|
+
Before 1.8.0, CSV serialization wrote `0` and `false` as blank cells. That made
|
|
125
|
+
some downstream previews and ad hoc SQL look fine until a real warehouse load or
|
|
126
|
+
verification run compared them to JSON or in-memory output.
|
|
127
|
+
|
|
128
|
+
Now:
|
|
129
|
+
|
|
130
|
+
- `0` stays `0`
|
|
131
|
+
- `false` stays `false`
|
|
132
|
+
- only missing values stay empty
|
|
133
|
+
|
|
134
|
+
If you have downstream logic that treated `''` as a stand-in for zero or false,
|
|
135
|
+
fix that logic.
|
|
136
|
+
|
|
137
|
+
## Migration checklist
|
|
138
|
+
|
|
139
|
+
1. Keep `standaloneEvents` when you want Mixpanel events without a person.
|
|
140
|
+
2. Add `warehouseMetrics` when you want local warehouse tables and a manifest.
|
|
141
|
+
3. Run the dungeon once before deploy so the warehouse artifacts exist.
|
|
142
|
+
4. Use `/warehouse-metrics` in dry-run mode, then obtain explicit operator
|
|
143
|
+
consent before the live `bq load --replace` step.
|
|
144
|
+
5. Recheck any CSV consumers that depended on blank falsy cells.
|
|
145
|
+
|
|
146
|
+
## Notes on row counts
|
|
147
|
+
|
|
148
|
+
The shipped warehouse fixture is a good shape reference, not a row-count
|
|
149
|
+
contract. It uses a 60-day live window and `history: 18` on the monthly ARR
|
|
150
|
+
table. `grain`, `history`, `sparse`, and `groupBy` all change row counts, so
|
|
151
|
+
sample numbers in review output are illustrative.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
/** @typedef {import('../../types').Dungeon} Config */
|
|
3
|
+
|
|
4
|
+
// ── OVERVIEW ──
|
|
5
|
+
/*
|
|
6
|
+
* NAME: warehouse
|
|
7
|
+
* PURPOSE: Minimal warehouse-metrics fixture covering the three canonical table shapes.
|
|
8
|
+
* SCALE: 200 users, 120 events, 60 days
|
|
9
|
+
* EVENTS (4): page_view (14) > subscription_started (2) > new_booking (1) > subscription_cancelled (1)
|
|
10
|
+
* FUNNELS: none
|
|
11
|
+
* USER PROPS: none
|
|
12
|
+
* SUPER PROPS: none
|
|
13
|
+
* GROUPS: none
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── SCALE ──
|
|
17
|
+
const SEED = 'warehouse-fixture';
|
|
18
|
+
const DATASET_START = '2025-01-01T00:00:00Z';
|
|
19
|
+
const DATASET_END = '2025-03-01T23:59:59Z';
|
|
20
|
+
|
|
21
|
+
// ── CONFIG ──
|
|
22
|
+
/** @type {Config} */
|
|
23
|
+
const config = {
|
|
24
|
+
name: 'warehouse',
|
|
25
|
+
seed: SEED,
|
|
26
|
+
datasetStart: DATASET_START,
|
|
27
|
+
datasetEnd: DATASET_END,
|
|
28
|
+
numUsers: 200,
|
|
29
|
+
numEvents: 120,
|
|
30
|
+
format: 'csv',
|
|
31
|
+
writeToDisk: false,
|
|
32
|
+
verbose: false,
|
|
33
|
+
concurrency: 1,
|
|
34
|
+
credentials: {
|
|
35
|
+
token: '',
|
|
36
|
+
region: 'US',
|
|
37
|
+
},
|
|
38
|
+
switches: {
|
|
39
|
+
hasSessionIds: false,
|
|
40
|
+
hasAdSpend: false,
|
|
41
|
+
hasLocation: false,
|
|
42
|
+
hasAndroidDevices: false,
|
|
43
|
+
hasIOSDevices: false,
|
|
44
|
+
hasDesktopDevices: false,
|
|
45
|
+
hasBrowser: false,
|
|
46
|
+
hasCampaigns: false,
|
|
47
|
+
isAnonymous: false,
|
|
48
|
+
alsoInferFunnels: false,
|
|
49
|
+
},
|
|
50
|
+
events: [
|
|
51
|
+
{
|
|
52
|
+
event: 'page_view',
|
|
53
|
+
weight: 14,
|
|
54
|
+
isStrictEvent: false,
|
|
55
|
+
properties: {
|
|
56
|
+
page: ['/', '/pricing', '/reports', '/billing'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
event: 'new_booking',
|
|
61
|
+
weight: 1,
|
|
62
|
+
isStrictEvent: false,
|
|
63
|
+
properties: {
|
|
64
|
+
booking_value: [1200, 1800, 2400, 3600],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
event: 'subscription_started',
|
|
69
|
+
weight: 2,
|
|
70
|
+
isStrictEvent: false,
|
|
71
|
+
properties: {
|
|
72
|
+
monthly_value: [100, 250, 500],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
event: 'subscription_cancelled',
|
|
77
|
+
weight: 1,
|
|
78
|
+
isStrictEvent: false,
|
|
79
|
+
properties: {
|
|
80
|
+
monthly_value: [100, 250, 500],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
warehouseMetrics: [
|
|
85
|
+
{
|
|
86
|
+
name: 'daily_new_bookings',
|
|
87
|
+
type: 'additive',
|
|
88
|
+
grain: 'day',
|
|
89
|
+
source: {
|
|
90
|
+
event: 'new_booking',
|
|
91
|
+
measure: 'sum',
|
|
92
|
+
property: 'booking_value',
|
|
93
|
+
},
|
|
94
|
+
timeColumn: 'date',
|
|
95
|
+
valueColumn: 'bookings',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'daily_active_subscriptions',
|
|
99
|
+
type: 'point-in-time',
|
|
100
|
+
grain: 'day',
|
|
101
|
+
source: {
|
|
102
|
+
event: 'subscription_started',
|
|
103
|
+
minus: 'subscription_cancelled',
|
|
104
|
+
measure: 'count',
|
|
105
|
+
},
|
|
106
|
+
baseline: 40,
|
|
107
|
+
timeColumn: 'date',
|
|
108
|
+
valueColumn: 'active_subscriptions',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'monthly_arr_snapshot',
|
|
112
|
+
type: 'point-in-time',
|
|
113
|
+
grain: 'month',
|
|
114
|
+
sparse: true,
|
|
115
|
+
history: 18,
|
|
116
|
+
source: {
|
|
117
|
+
event: 'subscription_started',
|
|
118
|
+
minus: 'subscription_cancelled',
|
|
119
|
+
measure: 'sum',
|
|
120
|
+
property: 'monthly_value',
|
|
121
|
+
},
|
|
122
|
+
baseline: 24000,
|
|
123
|
+
scale: 12,
|
|
124
|
+
timeColumn: 'month',
|
|
125
|
+
valueColumn: 'arr_usd',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export default config;
|
|
131
|
+
|
|
132
|
+
export const stories = [
|
|
133
|
+
{
|
|
134
|
+
id: 'H1-bookings-corr',
|
|
135
|
+
hook: 'H1',
|
|
136
|
+
archetype: 'temporal-inflection',
|
|
137
|
+
narrative: 'The additive warehouse bookings table should track the generated booking revenue closely enough for a warehouse metric demo.',
|
|
138
|
+
assertions: [
|
|
139
|
+
{
|
|
140
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_new_bookings' },
|
|
141
|
+
select: { s: { where: {} } },
|
|
142
|
+
expect: { metric: 's.corr', op: '>=', target: 0.9, floor: 0.7 },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'H2-active-subs-shape',
|
|
148
|
+
hook: 'H2',
|
|
149
|
+
archetype: 'session-shape',
|
|
150
|
+
narrative: 'The dense active subscription snapshot should stay fully ordered, gap-free, and numerically populated across the full dataset window.',
|
|
151
|
+
assertions: [
|
|
152
|
+
{
|
|
153
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
|
|
154
|
+
select: { s: { where: {} } },
|
|
155
|
+
expect: { metric: 's.gaps', op: '<=', target: 0 },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
|
|
159
|
+
select: { s: { where: {} } },
|
|
160
|
+
expect: { metric: 's.emptyNumericCells', op: '<=', target: 0 },
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
|
|
164
|
+
select: { s: { where: {} } },
|
|
165
|
+
expect: { metric: 's.nonMonotonicTime', op: '<=', target: 0 },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'H3-arr-history',
|
|
171
|
+
hook: 'H3',
|
|
172
|
+
archetype: 'composition-drift',
|
|
173
|
+
narrative: 'The sparse ARR snapshot should carry meaningful monthly history before the event window without a large seam jump into the live months.',
|
|
174
|
+
assertions: [
|
|
175
|
+
{
|
|
176
|
+
breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
|
|
177
|
+
select: { s: { where: {} } },
|
|
178
|
+
expect: { metric: 's.buckets', op: '>=', target: 18 },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
|
|
182
|
+
select: { s: { where: {} } },
|
|
183
|
+
expect: { metric: 's.seamJumpPct', op: '<=', target: 30 },
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
];
|
package/index.js
CHANGED
|
@@ -23,14 +23,18 @@ import { userLoop } from './lib/orchestrators/user-loop.js';
|
|
|
23
23
|
import { sendToMixpanel, collectWrittenFiles, releaseConnections } from './lib/orchestrators/mixpanel-sender.js';
|
|
24
24
|
// Generators
|
|
25
25
|
import { makeAdSpend } from './lib/generators/adspend.js';
|
|
26
|
+
import { makeStandaloneEvents } from './lib/generators/standalone.js';
|
|
26
27
|
import { makeMirror } from './lib/generators/mirror.js';
|
|
27
28
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
29
|
+
import { WarehouseAccumulator, materializeWarehouseMetrics, buildManifest } from './lib/generators/warehouse.js';
|
|
28
30
|
|
|
29
31
|
// Utilities
|
|
30
|
-
import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
32
|
+
import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile, getChance } from './lib/utils/utils.js';
|
|
31
33
|
import { runWithDataset } from './lib/utils/dataset-context.js';
|
|
32
34
|
|
|
33
35
|
// External dependencies
|
|
36
|
+
import { writeFile } from 'node:fs/promises';
|
|
37
|
+
import path from 'node:path';
|
|
34
38
|
import dayjs from "dayjs";
|
|
35
39
|
import utc from "dayjs/plugin/utc.js";
|
|
36
40
|
import { timer } from 'ak-tools';
|
|
@@ -184,6 +188,13 @@ async function runDungeon(config) {
|
|
|
184
188
|
storage = await storageManager.initializeContainers();
|
|
185
189
|
updateContextWithStorage(context, storage);
|
|
186
190
|
|
|
191
|
+
if (validatedConfig.warehouseMetrics?.length > 0) {
|
|
192
|
+
context.warehouseAccumulator = new WarehouseAccumulator(validatedConfig.warehouseMetrics, {
|
|
193
|
+
FIXED_BEGIN: context.FIXED_BEGIN,
|
|
194
|
+
FIXED_NOW: context.FIXED_NOW,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
// ! DATA GENERATION STARTS HERE
|
|
188
199
|
|
|
189
200
|
// Step 4: Generate ad spend data (if enabled)
|
|
@@ -194,6 +205,14 @@ async function runDungeon(config) {
|
|
|
194
205
|
context.reportProgress({ phase: "step", step: "adspend", status: "complete", duration: Date.now() - _t4 });
|
|
195
206
|
}
|
|
196
207
|
|
|
208
|
+
// Step 4b: Generate standalone identity-less metric snapshots (if configured) — v1.8.0
|
|
209
|
+
if (validatedConfig.standaloneEvents?.length > 0) {
|
|
210
|
+
context.reportProgress({ phase: "step", step: "standalone", status: "start" });
|
|
211
|
+
const _t4b = Date.now();
|
|
212
|
+
await generateStandaloneData(context);
|
|
213
|
+
context.reportProgress({ phase: "step", step: "standalone", status: "complete", duration: Date.now() - _t4b });
|
|
214
|
+
}
|
|
215
|
+
|
|
197
216
|
if (context.config.verbose) logger.info('Starting user and event generation...');
|
|
198
217
|
// Step 5: Main user and event generation
|
|
199
218
|
context.reportProgress({ phase: "step", step: "users", status: "start" });
|
|
@@ -233,6 +252,13 @@ async function runDungeon(config) {
|
|
|
233
252
|
context.reportProgress({ phase: "step", step: "mirrors", status: "complete", duration: Date.now() - _t9 });
|
|
234
253
|
}
|
|
235
254
|
|
|
255
|
+
if (validatedConfig.warehouseMetrics?.length > 0) {
|
|
256
|
+
context.reportProgress({ phase: "step", step: "warehouse", status: "start" });
|
|
257
|
+
const _t9b = Date.now();
|
|
258
|
+
await generateWarehouseData(context);
|
|
259
|
+
context.reportProgress({ phase: "step", step: "warehouse", status: "complete", duration: Date.now() - _t9b });
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
if (context.config.verbose) logger.info('Data generation completed successfully');
|
|
237
263
|
|
|
238
264
|
// ! DATA GENERATION ENDS HERE
|
|
@@ -293,11 +319,17 @@ async function runDungeon(config) {
|
|
|
293
319
|
// users matching no funnel, …). Always present, even when empty.
|
|
294
320
|
const warnings = [
|
|
295
321
|
...(Array.isArray(validatedConfig._warnings) ? validatedConfig._warnings : []),
|
|
322
|
+
...(Array.isArray(context.warehouseAccumulator?.warnings) ? context.warehouseAccumulator.warnings.map((reason) => ({
|
|
323
|
+
key: 'warehouseMetrics',
|
|
324
|
+
reason,
|
|
325
|
+
severity: 'warn',
|
|
326
|
+
})) : []),
|
|
296
327
|
...context.getWarnings(),
|
|
297
328
|
];
|
|
298
329
|
|
|
299
330
|
return {
|
|
300
331
|
...extractedData,
|
|
332
|
+
warehouseManifest: context.warehouseManifest,
|
|
301
333
|
importResults,
|
|
302
334
|
warnings,
|
|
303
335
|
files: extractFileInfo(storage),
|
|
@@ -356,6 +388,79 @@ async function generateAdSpendData(context) {
|
|
|
356
388
|
}
|
|
357
389
|
}
|
|
358
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Generate standalone identity-less metric snapshots — v1.8.0.
|
|
393
|
+
*
|
|
394
|
+
* One record per cadence tick per dimension cross-product row. Records carry no
|
|
395
|
+
* `user_id` and no `device_id`; they describe a system, not a person.
|
|
396
|
+
*
|
|
397
|
+
* @param {Context} context - Context object
|
|
398
|
+
*/
|
|
399
|
+
async function generateStandaloneData(context) {
|
|
400
|
+
const { config, storage } = context;
|
|
401
|
+
const specs = /** @type {import('./types').ResolvedStandaloneEventConfig[]} */ (config.standaloneEvents);
|
|
402
|
+
|
|
403
|
+
for (const spec of specs) {
|
|
404
|
+
const records = makeStandaloneEvents(context, spec);
|
|
405
|
+
for (const record of records) {
|
|
406
|
+
// The `standalone` hook fires on push, like ad-spend. Meta carries the
|
|
407
|
+
// stream's resolved spec so a hook can tell the streams apart.
|
|
408
|
+
// `datasetStart`/`datasetEnd` are added by hookPush itself.
|
|
409
|
+
await storage.standaloneEventData.hookPush(
|
|
410
|
+
/** @type {import('./types').EventSchema} */ (record),
|
|
411
|
+
{ spec, config }
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Materialize configured warehouse metric tables after the user loop completes.
|
|
419
|
+
*
|
|
420
|
+
* The accumulator taps the final per-user event stream during Step 5. This step
|
|
421
|
+
* runs afterward so seeded noise and column callbacks cannot perturb event generation.
|
|
422
|
+
*
|
|
423
|
+
* @param {Context} context - Context object
|
|
424
|
+
*/
|
|
425
|
+
async function generateWarehouseData(context) {
|
|
426
|
+
const { config, storage } = context;
|
|
427
|
+
const specs = /** @type {import('./types').ResolvedWarehouseMetricConfig[]} */ (config.warehouseMetrics);
|
|
428
|
+
const accumulator = context.warehouseAccumulator;
|
|
429
|
+
if (!Array.isArray(specs) || specs.length === 0 || !accumulator) return;
|
|
430
|
+
|
|
431
|
+
const materialized = materializeWarehouseMetrics({
|
|
432
|
+
specs,
|
|
433
|
+
accumulator,
|
|
434
|
+
chance: getChance(),
|
|
435
|
+
FIXED_BEGIN: context.FIXED_BEGIN,
|
|
436
|
+
FIXED_NOW: context.FIXED_NOW,
|
|
437
|
+
configName: config.name,
|
|
438
|
+
config,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
for (let index = 0; index < materialized.length; index += 1) {
|
|
442
|
+
const entry = materialized[index];
|
|
443
|
+
const container = storage.warehouseMetricData?.[index];
|
|
444
|
+
if (!container) continue;
|
|
445
|
+
|
|
446
|
+
for (let rowIndex = 0; rowIndex < entry.rows.length; rowIndex += 1) {
|
|
447
|
+
await container.hookPush(entry.rows[rowIndex], entry.metas[rowIndex]);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const postHookMaterialized = specs.map((spec, index) => ({
|
|
452
|
+
spec,
|
|
453
|
+
rows: Array.from(storage.warehouseMetricData?.[index] || []),
|
|
454
|
+
}));
|
|
455
|
+
context.warehouseManifest = buildManifest(specs, postHookMaterialized, config.name);
|
|
456
|
+
|
|
457
|
+
if (config.writeToDisk && storage.warehouseMetricData?.[0]?.getWriteDir) {
|
|
458
|
+
const manifestPath = path.join(storage.warehouseMetricData[0].getWriteDir(), `${config.name}-WAREHOUSE-MANIFEST.json`);
|
|
459
|
+
await writeFile(manifestPath, JSON.stringify(context.warehouseManifest, null, 2));
|
|
460
|
+
storage.warehouseManifestFile = manifestPath;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
359
464
|
/**
|
|
360
465
|
* Generate group profiles for all configured group keys
|
|
361
466
|
* @param {Context} context - Context object
|
|
@@ -567,11 +672,12 @@ async function flushStorageToDisk(storage, config) {
|
|
|
567
672
|
if (storage.eventData?.flush) flushPromises.push(storage.eventData.flush());
|
|
568
673
|
if (storage.userProfilesData?.flush) flushPromises.push(storage.userProfilesData.flush());
|
|
569
674
|
if (storage.adSpendData?.flush) flushPromises.push(storage.adSpendData.flush());
|
|
675
|
+
if (storage.standaloneEventData?.flush) flushPromises.push(storage.standaloneEventData.flush());
|
|
570
676
|
if (storage.mirrorEventData?.flush) flushPromises.push(storage.mirrorEventData.flush());
|
|
571
677
|
if (storage.groupEventData?.flush) flushPromises.push(storage.groupEventData.flush());
|
|
572
678
|
|
|
573
679
|
// Flush arrays of HookedArrays (excluding lookup tables which are handled separately)
|
|
574
|
-
[storage.scdTableData, storage.groupProfilesData].forEach(arrayOfContainers => {
|
|
680
|
+
[storage.scdTableData, storage.groupProfilesData, storage.warehouseMetricData].forEach(arrayOfContainers => {
|
|
575
681
|
if (Array.isArray(arrayOfContainers)) {
|
|
576
682
|
arrayOfContainers.forEach(container => {
|
|
577
683
|
if (container?.flush) flushPromises.push(container.flush());
|
|
@@ -635,11 +741,19 @@ function countProfilesPushed(profilesContainer) {
|
|
|
635
741
|
* @returns {object} Extracted data in Result format
|
|
636
742
|
*/
|
|
637
743
|
function extractStorageData(storage) {
|
|
744
|
+
const warehouseMetricData = {};
|
|
745
|
+
for (const container of storage.warehouseMetricData || []) {
|
|
746
|
+
if (!container?.metricName) continue;
|
|
747
|
+
warehouseMetricData[container.metricName] = Array.from(container);
|
|
748
|
+
}
|
|
749
|
+
|
|
638
750
|
return {
|
|
639
751
|
eventData: storage.eventData || [],
|
|
640
752
|
mirrorEventData: storage.mirrorEventData || [],
|
|
641
753
|
userProfilesData: storage.userProfilesData || [],
|
|
642
754
|
adSpendData: storage.adSpendData || [],
|
|
755
|
+
standaloneEventData: storage.standaloneEventData || [],
|
|
756
|
+
warehouseMetricData,
|
|
643
757
|
// Keep arrays of HookedArrays as separate arrays (don't flatten)
|
|
644
758
|
scdTableData: storage.scdTableData || [],
|
|
645
759
|
groupProfilesData: storage.groupProfilesData || [],
|
|
@@ -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);
|