gamealgo-cli 0.3.8 → 0.4.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.
- package/README.md +18 -1
- package/dist/client.js +23 -2
- package/dist/commands/diagnosis.js +17 -0
- package/dist/commands/experiment.js +1 -1
- package/dist/commands/integration.js +1 -1
- package/dist/commands/keys.js +7 -3
- package/dist/commands/report.js +191 -54
- package/dist/compatibility.js +1 -1
- package/dist/docs.js +6 -6
- package/dist/main.js +5 -0
- package/dist/report-summary.js +14 -2
- package/dist/report-v2/expression.js +130 -0
- package/dist/report-v2/result.js +209 -0
- package/dist/report-v2/schema.js +18 -0
- package/dist/report-v2/summary.js +110 -0
- package/dist/report-v2/view-executor.js +198 -0
- package/dist/utils.js +4 -4
- package/package.json +1 -1
- package/skills/gamealgo/SKILL.md +20 -9
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export function parseReportFormula(source) {
|
|
2
|
+
const tokens = tokenize(String(source ?? ""));
|
|
3
|
+
let cursor = 0;
|
|
4
|
+
const peek = (value) => tokens[cursor]?.value === value;
|
|
5
|
+
const take = (value) => {
|
|
6
|
+
if (!peek(value))
|
|
7
|
+
throw new Error(`expected ${value || "expression"}`);
|
|
8
|
+
return tokens[cursor++];
|
|
9
|
+
};
|
|
10
|
+
const parsePrimary = () => {
|
|
11
|
+
const token = tokens[cursor];
|
|
12
|
+
if (!token)
|
|
13
|
+
throw new Error("unexpected end of formula");
|
|
14
|
+
if (token.type === "number") {
|
|
15
|
+
cursor += 1;
|
|
16
|
+
return { type: "number", value: Number(token.value) };
|
|
17
|
+
}
|
|
18
|
+
if (token.type === "identifier") {
|
|
19
|
+
cursor += 1;
|
|
20
|
+
return { type: "identifier", name: token.value };
|
|
21
|
+
}
|
|
22
|
+
if (token.value === "(") {
|
|
23
|
+
cursor += 1;
|
|
24
|
+
const expression = parseAdditive();
|
|
25
|
+
take(")");
|
|
26
|
+
return expression;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`unexpected token ${token.value}`);
|
|
29
|
+
};
|
|
30
|
+
const parseUnary = () => {
|
|
31
|
+
if (peek("+") || peek("-")) {
|
|
32
|
+
const operator = tokens[cursor++].value;
|
|
33
|
+
return { type: "unary", operator, argument: parseUnary() };
|
|
34
|
+
}
|
|
35
|
+
return parsePrimary();
|
|
36
|
+
};
|
|
37
|
+
const parseMultiplicative = () => {
|
|
38
|
+
let expression = parseUnary();
|
|
39
|
+
while (peek("*") || peek("/")) {
|
|
40
|
+
const operator = tokens[cursor++].value;
|
|
41
|
+
expression = { type: "binary", operator, left: expression, right: parseUnary() };
|
|
42
|
+
}
|
|
43
|
+
return expression;
|
|
44
|
+
};
|
|
45
|
+
const parseAdditive = () => {
|
|
46
|
+
let expression = parseMultiplicative();
|
|
47
|
+
while (peek("+") || peek("-")) {
|
|
48
|
+
const operator = tokens[cursor++].value;
|
|
49
|
+
expression = { type: "binary", operator, left: expression, right: parseMultiplicative() };
|
|
50
|
+
}
|
|
51
|
+
return expression;
|
|
52
|
+
};
|
|
53
|
+
if (!tokens.length)
|
|
54
|
+
throw new Error("formula must not be empty");
|
|
55
|
+
const expression = parseAdditive();
|
|
56
|
+
if (cursor !== tokens.length)
|
|
57
|
+
throw new Error(`unexpected token ${tokens[cursor].value}`);
|
|
58
|
+
return expression;
|
|
59
|
+
}
|
|
60
|
+
export function reportFormulaReferences(source) {
|
|
61
|
+
const references = new Set();
|
|
62
|
+
visit(parseReportFormula(source), (node) => {
|
|
63
|
+
if (node.type === "identifier")
|
|
64
|
+
references.add(node.name);
|
|
65
|
+
});
|
|
66
|
+
return [...references].sort();
|
|
67
|
+
}
|
|
68
|
+
export function evaluateReportFormula(expression, values) {
|
|
69
|
+
if (expression.type === "number")
|
|
70
|
+
return expression.value;
|
|
71
|
+
if (expression.type === "identifier")
|
|
72
|
+
return finiteNumber(values[expression.name]);
|
|
73
|
+
if (expression.type === "unary") {
|
|
74
|
+
const value = evaluateReportFormula(expression.argument, values);
|
|
75
|
+
if (value === null)
|
|
76
|
+
return null;
|
|
77
|
+
return expression.operator === "-" ? -value : value;
|
|
78
|
+
}
|
|
79
|
+
const left = evaluateReportFormula(expression.left, values);
|
|
80
|
+
const right = evaluateReportFormula(expression.right, values);
|
|
81
|
+
if (left === null || right === null)
|
|
82
|
+
return null;
|
|
83
|
+
const result = expression.operator === "+" ? left + right
|
|
84
|
+
: expression.operator === "-" ? left - right
|
|
85
|
+
: expression.operator === "*" ? left * right
|
|
86
|
+
: right === 0 ? null : left / right;
|
|
87
|
+
return typeof result === "number" && Number.isFinite(result) ? result : null;
|
|
88
|
+
}
|
|
89
|
+
function tokenize(source) {
|
|
90
|
+
const tokens = [];
|
|
91
|
+
let cursor = 0;
|
|
92
|
+
while (cursor < source.length) {
|
|
93
|
+
const char = source[cursor];
|
|
94
|
+
if (/\s/.test(char)) {
|
|
95
|
+
cursor += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if ("+-*/()".includes(char)) {
|
|
99
|
+
tokens.push({ type: "operator", value: char });
|
|
100
|
+
cursor += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const number = source.slice(cursor).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
|
|
104
|
+
if (number) {
|
|
105
|
+
tokens.push({ type: "number", value: number[0] });
|
|
106
|
+
cursor += number[0].length;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const identifier = source.slice(cursor).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
110
|
+
if (identifier) {
|
|
111
|
+
tokens.push({ type: "identifier", value: identifier[0] });
|
|
112
|
+
cursor += identifier[0].length;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`unsupported formula token at position ${cursor}`);
|
|
116
|
+
}
|
|
117
|
+
return tokens;
|
|
118
|
+
}
|
|
119
|
+
function visit(node, callback) {
|
|
120
|
+
callback(node);
|
|
121
|
+
if (node.type === "unary")
|
|
122
|
+
visit(node.argument, callback);
|
|
123
|
+
if (node.type === "binary") {
|
|
124
|
+
visit(node.left, callback);
|
|
125
|
+
visit(node.right, callback);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function finiteNumber(value) {
|
|
129
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { executeReportPackV2View } from "./view-executor.js";
|
|
2
|
+
export async function queryReportPackV2Result(client, manifestResponse, options) {
|
|
3
|
+
const root = requiredObject(manifestResponse, "manifest response");
|
|
4
|
+
const manifest = requiredObject(root.manifest, "manifest");
|
|
5
|
+
if (manifest.schemaVersion !== 2)
|
|
6
|
+
throw new Error("Report Pack v2 manifest is required");
|
|
7
|
+
const selected = selectResultItems(manifest, options);
|
|
8
|
+
if (options.refresh && selected.standardCharts.length === 0) {
|
|
9
|
+
throw new Error("--refresh is only supported for standard Groups; custom v2 Views read materialized Dataset snapshots");
|
|
10
|
+
}
|
|
11
|
+
const fingerprints = new Set(selected.views.map(({ view }) => viewDatasetFingerprint(view)));
|
|
12
|
+
const datasets = new Map();
|
|
13
|
+
const standardPromise = selected.standardCharts.length
|
|
14
|
+
? client.queryReportDashboard({
|
|
15
|
+
startDate: options.startDate,
|
|
16
|
+
endDate: options.endDate,
|
|
17
|
+
platform: options.platform,
|
|
18
|
+
selectors: options.selectors,
|
|
19
|
+
refresh: Boolean(options.refresh),
|
|
20
|
+
responseMode: "full",
|
|
21
|
+
...queryScope(options),
|
|
22
|
+
}, { timeoutMs: options.timeoutMs })
|
|
23
|
+
: Promise.resolve(null);
|
|
24
|
+
const [, standardPayload] = await Promise.all([
|
|
25
|
+
Promise.all([...fingerprints].map(async (datasetFingerprint) => {
|
|
26
|
+
const payload = requiredObject(await client.readReportDataset({
|
|
27
|
+
datasetFingerprint,
|
|
28
|
+
platform: options.platform,
|
|
29
|
+
startDate: options.startDate,
|
|
30
|
+
endDate: options.endDate,
|
|
31
|
+
}), `Dataset ${datasetFingerprint}`);
|
|
32
|
+
datasets.set(datasetFingerprint, payload);
|
|
33
|
+
})),
|
|
34
|
+
standardPromise,
|
|
35
|
+
]);
|
|
36
|
+
const viewResults = selected.views.map(({ tab, group, view }) => {
|
|
37
|
+
const fingerprint = viewDatasetFingerprint(view);
|
|
38
|
+
const dataset = datasets.get(fingerprint);
|
|
39
|
+
const status = String(dataset.status || "waiting");
|
|
40
|
+
if (status === "abnormal") {
|
|
41
|
+
return reportResult(tab, group, view, fingerprint, [], [], {
|
|
42
|
+
error: String(dataset.abnormalMessage || dataset.abnormalCode || "Dataset materialization is abnormal"),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const data = requiredObject(view.data, "view.data");
|
|
46
|
+
const rows = status === "waiting"
|
|
47
|
+
? []
|
|
48
|
+
: executeReportPackV2View(view, recordRows(dataset.rows), {
|
|
49
|
+
selectorFilters: selectorFilters(group, data, options.selectors, recordRows(dataset.rows)),
|
|
50
|
+
});
|
|
51
|
+
const columns = [
|
|
52
|
+
...stringArray(data.groupBy, "view.data.groupBy"),
|
|
53
|
+
...Object.keys(requiredObject(data.metrics, "view.data.metrics")),
|
|
54
|
+
];
|
|
55
|
+
return reportResult(tab, group, view, fingerprint, columns, rows, {
|
|
56
|
+
cached: true,
|
|
57
|
+
cachedAt: dataset.updatedAt,
|
|
58
|
+
pending: status === "waiting" || undefined,
|
|
59
|
+
partial: status === "partial" || undefined,
|
|
60
|
+
missingDates: dataset.missingDates,
|
|
61
|
+
snapshotVersion: dataset.snapshotVersion,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const standardResults = standardPayload
|
|
65
|
+
? arrayValues(requiredObject(standardPayload, "standard report result").results)
|
|
66
|
+
: [];
|
|
67
|
+
return {
|
|
68
|
+
reportPack: root.reportPack,
|
|
69
|
+
dateRange: { startDate: options.startDate, endDate: options.endDate, platform: options.platform },
|
|
70
|
+
selectors: options.selectors,
|
|
71
|
+
results: [...standardResults, ...viewResults],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function selectResultItems(manifest, options) {
|
|
75
|
+
const views = [];
|
|
76
|
+
const standardCharts = [];
|
|
77
|
+
const groups = [];
|
|
78
|
+
arrayValues(manifest.tabs).forEach((rawTab) => {
|
|
79
|
+
const tab = requiredObject(rawTab, "manifest tab");
|
|
80
|
+
arrayValues(tab.groups).forEach((rawGroup) => {
|
|
81
|
+
const group = requiredObject(rawGroup, "manifest group");
|
|
82
|
+
groups.push({ tab, group });
|
|
83
|
+
arrayValues(group.views).forEach((rawView) => {
|
|
84
|
+
views.push({ tab, group, view: requiredObject(rawView, "manifest view") });
|
|
85
|
+
});
|
|
86
|
+
arrayValues(group.charts).forEach((rawChart) => {
|
|
87
|
+
standardCharts.push({ tab, group, chart: requiredObject(rawChart, "manifest chart") });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const chart = options.chartId || options.chart;
|
|
92
|
+
if (chart) {
|
|
93
|
+
const matchingViews = views.filter(({ group, view }) => matchesValue(chart, [
|
|
94
|
+
view.id,
|
|
95
|
+
view.title,
|
|
96
|
+
`${group.id}/${view.id}`,
|
|
97
|
+
`${group.id}__${view.id}`,
|
|
98
|
+
]));
|
|
99
|
+
const matchingCharts = standardCharts.filter(({ group, chart: standardChart }) => matchesValue(chart, [
|
|
100
|
+
standardChart.id,
|
|
101
|
+
standardChart.title,
|
|
102
|
+
`${group.id}/${standardChart.id}`,
|
|
103
|
+
`${group.id}__${standardChart.id}`,
|
|
104
|
+
]));
|
|
105
|
+
if (matchingViews.length + matchingCharts.length === 0)
|
|
106
|
+
throw new Error(`chart not found: ${chart}`);
|
|
107
|
+
if (matchingViews.length + matchingCharts.length > 1) {
|
|
108
|
+
const ids = [
|
|
109
|
+
...matchingViews.map(({ group, view }) => `${String(group.id)}/${String(view.id)}`),
|
|
110
|
+
...matchingCharts.map(({ group, chart: item }) => `${String(group.id)}/${String(item.id)}`),
|
|
111
|
+
];
|
|
112
|
+
throw new Error(`ambiguous_chart_id: ${chart}; candidates: ${ids.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
return { views: matchingViews, standardCharts: matchingCharts };
|
|
115
|
+
}
|
|
116
|
+
const group = options.groupId || options.group;
|
|
117
|
+
if (group) {
|
|
118
|
+
const matchingGroups = groups.filter((entry) => matches(group, [entry.group.id, entry.group.title]));
|
|
119
|
+
if (!matchingGroups.length)
|
|
120
|
+
throw new Error(`group not found: ${group}`);
|
|
121
|
+
const selectedGroups = new Set(matchingGroups.map((entry) => entry.group));
|
|
122
|
+
return {
|
|
123
|
+
views: views.filter((entry) => selectedGroups.has(entry.group)),
|
|
124
|
+
standardCharts: standardCharts.filter((entry) => selectedGroups.has(entry.group)),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const tab = options.tabId || options.tab;
|
|
128
|
+
if (tab) {
|
|
129
|
+
const matchingTabs = new Set(groups
|
|
130
|
+
.filter((entry) => matches(tab, [entry.tab.id, entry.tab.title]))
|
|
131
|
+
.map((entry) => entry.tab));
|
|
132
|
+
if (!matchingTabs.size)
|
|
133
|
+
throw new Error(`tab not found: ${tab}`);
|
|
134
|
+
return {
|
|
135
|
+
views: views.filter((entry) => matchingTabs.has(entry.tab)),
|
|
136
|
+
standardCharts: standardCharts.filter((entry) => matchingTabs.has(entry.tab)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw new Error("report result requires one of --tab, --tab-id, --group, --group-id, --chart, or --chart-id");
|
|
140
|
+
}
|
|
141
|
+
function queryScope(options) {
|
|
142
|
+
return Object.fromEntries(Object.entries({
|
|
143
|
+
tab: options.tab,
|
|
144
|
+
tabId: options.tabId,
|
|
145
|
+
group: options.group,
|
|
146
|
+
groupId: options.groupId,
|
|
147
|
+
chart: options.chart,
|
|
148
|
+
chartId: options.chartId,
|
|
149
|
+
}).filter((entry) => typeof entry[1] === "string"));
|
|
150
|
+
}
|
|
151
|
+
function matches(query, candidates) {
|
|
152
|
+
return matchesValue(query, candidates);
|
|
153
|
+
}
|
|
154
|
+
function matchesValue(query, candidates) {
|
|
155
|
+
return candidates.some((candidate) => typeof candidate === "string" && candidate.toLocaleLowerCase() === query.toLocaleLowerCase());
|
|
156
|
+
}
|
|
157
|
+
function selectorFilters(group, data, values, rows) {
|
|
158
|
+
const bindings = objectValue(data.selectorBindings) ?? {};
|
|
159
|
+
const selectors = arrayValues(group.selectors).map((value) => requiredObject(value, "selector"));
|
|
160
|
+
return Object.entries(bindings).flatMap(([selectorId, rawField]) => {
|
|
161
|
+
const selector = selectors.find((item) => item.id === selectorId);
|
|
162
|
+
const value = values[selectorId] ?? (typeof selector?.defaultValue === "string" ? selector.defaultValue : "all");
|
|
163
|
+
if (!value || value === "all")
|
|
164
|
+
return [];
|
|
165
|
+
const field = String(rawField);
|
|
166
|
+
const sample = rows.find((row) => row[field] !== undefined)?.[field];
|
|
167
|
+
const typed = typeof sample === "number" && Number.isFinite(Number(value))
|
|
168
|
+
? Number(value)
|
|
169
|
+
: typeof sample === "boolean" ? value === "true" : value;
|
|
170
|
+
return [{ field, op: "eq", value: typed }];
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function reportResult(tab, group, view, fingerprint, columns, rows, extra) {
|
|
174
|
+
return {
|
|
175
|
+
tab: { id: tab.id, title: tab.title },
|
|
176
|
+
group: { id: group.id, title: group.title },
|
|
177
|
+
chart: view,
|
|
178
|
+
reportId: `dataset:${fingerprint}`,
|
|
179
|
+
columns,
|
|
180
|
+
rows,
|
|
181
|
+
...extra,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function viewDatasetFingerprint(view) {
|
|
185
|
+
const fingerprint = requiredObject(view.data, "view.data").datasetFingerprint;
|
|
186
|
+
if (typeof fingerprint !== "string" || !fingerprint)
|
|
187
|
+
throw new Error("v2 View is missing datasetFingerprint");
|
|
188
|
+
return fingerprint;
|
|
189
|
+
}
|
|
190
|
+
function recordRows(value) {
|
|
191
|
+
return arrayValues(value).map((row) => requiredObject(row, "Dataset row"));
|
|
192
|
+
}
|
|
193
|
+
function requiredObject(value, path) {
|
|
194
|
+
const object = objectValue(value);
|
|
195
|
+
if (!object)
|
|
196
|
+
throw new Error(`${path} must be an object`);
|
|
197
|
+
return object;
|
|
198
|
+
}
|
|
199
|
+
function objectValue(value) {
|
|
200
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
201
|
+
}
|
|
202
|
+
function arrayValues(value) {
|
|
203
|
+
return Array.isArray(value) ? value : [];
|
|
204
|
+
}
|
|
205
|
+
function stringArray(value, path) {
|
|
206
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
|
|
207
|
+
throw new Error(`${path} must be an array of strings`);
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function reportPackSchemaVersion(value) {
|
|
2
|
+
const pack = objectValue(value);
|
|
3
|
+
if (!pack)
|
|
4
|
+
throw new Error("Report Pack must be a JSON object");
|
|
5
|
+
if (pack.schemaVersion === undefined)
|
|
6
|
+
return 1;
|
|
7
|
+
if (pack.schemaVersion === 2)
|
|
8
|
+
return 2;
|
|
9
|
+
throw new Error(`unsupported Report Pack schemaVersion: ${String(pack.schemaVersion)}`);
|
|
10
|
+
}
|
|
11
|
+
export function assertWritableReportPackV2(value, command) {
|
|
12
|
+
if (reportPackSchemaVersion(value) !== 2) {
|
|
13
|
+
throw new Error(`${command} only accepts Report Pack schemaVersion 2; pull the current Pack and migrate it before writing`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function objectValue(value) {
|
|
17
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { REPORT_SUMMARY_MAX_ITEMS, REPORT_SUMMARY_SCHEMA_VERSION } from "../report-summary.js";
|
|
2
|
+
export function summarizeReportPackV2Manifest(value) {
|
|
3
|
+
const root = objectValue(value);
|
|
4
|
+
const manifest = objectValue(root?.manifest);
|
|
5
|
+
if (!manifest || manifest.schemaVersion !== 2) {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: REPORT_SUMMARY_SCHEMA_VERSION,
|
|
8
|
+
command: "report.manifest",
|
|
9
|
+
ok: false,
|
|
10
|
+
summary: {},
|
|
11
|
+
errors: [{ scope: "manifest", message: "Report Pack v2 manifest is missing" }],
|
|
12
|
+
warnings: [],
|
|
13
|
+
items: [],
|
|
14
|
+
truncated: false,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const tabs = arrayValues(manifest.tabs);
|
|
18
|
+
const advancedQueries = arrayValues(manifest.advancedQueries);
|
|
19
|
+
const allItems = [];
|
|
20
|
+
let groupCount = 0;
|
|
21
|
+
let viewCount = 0;
|
|
22
|
+
let standardChartCount = 0;
|
|
23
|
+
for (const [scope, containers] of [["dashboard", tabs], ["advanced", advancedQueries]]) {
|
|
24
|
+
for (const rawContainer of containers) {
|
|
25
|
+
const container = objectValue(rawContainer);
|
|
26
|
+
for (const rawGroup of arrayValues(container?.groups)) {
|
|
27
|
+
const group = objectValue(rawGroup);
|
|
28
|
+
groupCount += 1;
|
|
29
|
+
for (const rawView of arrayValues(group?.views)) {
|
|
30
|
+
const view = objectValue(rawView);
|
|
31
|
+
const data = objectValue(view?.data);
|
|
32
|
+
viewCount += 1;
|
|
33
|
+
allItems.push(compact({
|
|
34
|
+
kind: "view",
|
|
35
|
+
scope,
|
|
36
|
+
containerId: text(container?.id),
|
|
37
|
+
containerTitle: text(container?.title),
|
|
38
|
+
groupId: text(group?.id),
|
|
39
|
+
groupTitle: text(group?.title),
|
|
40
|
+
viewId: text(view?.id),
|
|
41
|
+
viewTitle: text(view?.title),
|
|
42
|
+
viewType: text(view?.type),
|
|
43
|
+
datasetId: text(data?.dataset),
|
|
44
|
+
datasetFingerprint: text(data?.datasetFingerprint),
|
|
45
|
+
selectorIds: arrayValues(group?.selectors).flatMap((rawSelector) => {
|
|
46
|
+
const id = text(objectValue(rawSelector)?.id);
|
|
47
|
+
return id ? [id] : [];
|
|
48
|
+
}),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
if (scope === "dashboard") {
|
|
52
|
+
for (const rawChart of arrayValues(group?.charts)) {
|
|
53
|
+
const chart = objectValue(rawChart);
|
|
54
|
+
standardChartCount += 1;
|
|
55
|
+
allItems.push(compact({
|
|
56
|
+
kind: "standard_chart",
|
|
57
|
+
scope,
|
|
58
|
+
containerId: text(container?.id),
|
|
59
|
+
containerTitle: text(container?.title),
|
|
60
|
+
groupId: text(group?.id),
|
|
61
|
+
groupTitle: text(group?.title),
|
|
62
|
+
standardRef: text(group?.standardRef),
|
|
63
|
+
chartId: text(chart?.id),
|
|
64
|
+
chartTitle: text(chart?.title),
|
|
65
|
+
chartType: text(chart?.type),
|
|
66
|
+
reportId: text(chart?.report),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const datasets = objectValue(manifest.datasets) ?? {};
|
|
74
|
+
const items = allItems.slice(0, REPORT_SUMMARY_MAX_ITEMS);
|
|
75
|
+
return {
|
|
76
|
+
schemaVersion: REPORT_SUMMARY_SCHEMA_VERSION,
|
|
77
|
+
command: "report.manifest",
|
|
78
|
+
ok: true,
|
|
79
|
+
summary: compact({
|
|
80
|
+
reportPack: root?.reportPack,
|
|
81
|
+
reportPackSchemaVersion: 2,
|
|
82
|
+
title: text(manifest.title),
|
|
83
|
+
packFingerprint: text(manifest.packFingerprint),
|
|
84
|
+
tabCount: tabs.length,
|
|
85
|
+
advancedQueryCount: advancedQueries.length,
|
|
86
|
+
groupCount,
|
|
87
|
+
viewCount,
|
|
88
|
+
standardChartCount,
|
|
89
|
+
datasetCount: Object.keys(datasets).length,
|
|
90
|
+
datasetIds: Object.keys(datasets),
|
|
91
|
+
returnedItemCount: items.length,
|
|
92
|
+
}),
|
|
93
|
+
errors: [],
|
|
94
|
+
warnings: [],
|
|
95
|
+
items,
|
|
96
|
+
truncated: allItems.length > items.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function objectValue(value) {
|
|
100
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
101
|
+
}
|
|
102
|
+
function arrayValues(value) {
|
|
103
|
+
return Array.isArray(value) ? value : [];
|
|
104
|
+
}
|
|
105
|
+
function text(value) {
|
|
106
|
+
return typeof value === "string" && value ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
function compact(value) {
|
|
109
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
110
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { evaluateReportFormula, parseReportFormula, reportFormulaReferences } from "./expression.js";
|
|
2
|
+
const BASE_AGGREGATIONS = new Set(["sum", "min", "max", "count"]);
|
|
3
|
+
/** Pure v2 evaluator. It intentionally has no dependency on legacy reports. */
|
|
4
|
+
export function executeReportPackV2View(rawView, datasetRows, options = {}) {
|
|
5
|
+
const view = requiredObject(rawView, "view");
|
|
6
|
+
const data = requiredObject(view.data, "view.data");
|
|
7
|
+
const groupBy = stringArray(data.groupBy, "view.data.groupBy");
|
|
8
|
+
const metrics = requiredObject(data.metrics, "view.data.metrics");
|
|
9
|
+
if (!Object.keys(metrics).length)
|
|
10
|
+
throw new Error("view.data.metrics must not be empty");
|
|
11
|
+
const selectorFilters = Array.isArray(options.selectorFilters) ? options.selectorFilters : [];
|
|
12
|
+
const filteredRows = datasetRows.filter((row) => (matchesFilter(row, data.filter)
|
|
13
|
+
&& selectorFilters.every((filter) => matchesFilter(row, filter))));
|
|
14
|
+
const groups = groupRows(filteredRows, groupBy);
|
|
15
|
+
const formulas = prepareFormulas(metrics);
|
|
16
|
+
const rows = [];
|
|
17
|
+
for (const group of groups.values()) {
|
|
18
|
+
const output = { ...group.dimensions };
|
|
19
|
+
for (const [metricId, rawDefinition] of Object.entries(metrics)) {
|
|
20
|
+
const definition = objectValue(rawDefinition);
|
|
21
|
+
if (definition?.agg)
|
|
22
|
+
output[metricId] = aggregateMetric(group.rows, metricId, definition);
|
|
23
|
+
}
|
|
24
|
+
evaluateFormulas(output, formulas);
|
|
25
|
+
rows.push(output);
|
|
26
|
+
}
|
|
27
|
+
if (!filteredRows.length && !groupBy.length) {
|
|
28
|
+
const output = {};
|
|
29
|
+
for (const [metricId, rawDefinition] of Object.entries(metrics)) {
|
|
30
|
+
const definition = objectValue(rawDefinition);
|
|
31
|
+
if (definition?.agg)
|
|
32
|
+
output[metricId] = aggregateMetric([], metricId, definition);
|
|
33
|
+
}
|
|
34
|
+
evaluateFormulas(output, formulas);
|
|
35
|
+
rows.push(output);
|
|
36
|
+
}
|
|
37
|
+
const orderBy = normalizeOrderBy(view.orderBy);
|
|
38
|
+
if (orderBy.length) {
|
|
39
|
+
const stable = [
|
|
40
|
+
...orderBy,
|
|
41
|
+
...groupBy.filter((field) => !orderBy.some((order) => order.field === field)).map((field) => ({ field, direction: "asc" })),
|
|
42
|
+
];
|
|
43
|
+
rows.sort((left, right) => compareRows(left, right, stable));
|
|
44
|
+
}
|
|
45
|
+
const limit = view.limit === undefined ? undefined : positiveInteger(view.limit, "view.limit");
|
|
46
|
+
return limit === undefined ? rows : rows.slice(0, limit);
|
|
47
|
+
}
|
|
48
|
+
function groupRows(rows, groupBy) {
|
|
49
|
+
const groups = new Map();
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const dimensions = Object.fromEntries(groupBy.map((field) => [field, row[field] ?? null]));
|
|
52
|
+
const key = JSON.stringify(groupBy.map((field) => [typeof row[field], row[field] ?? null]));
|
|
53
|
+
const existing = groups.get(key);
|
|
54
|
+
if (existing)
|
|
55
|
+
existing.rows.push(row);
|
|
56
|
+
else
|
|
57
|
+
groups.set(key, { dimensions, rows: [row] });
|
|
58
|
+
}
|
|
59
|
+
return groups;
|
|
60
|
+
}
|
|
61
|
+
function aggregateMetric(rows, metricId, definition) {
|
|
62
|
+
const aggregation = String(definition.agg || "");
|
|
63
|
+
if (!BASE_AGGREGATIONS.has(aggregation))
|
|
64
|
+
throw new Error(`unsupported aggregation for ${metricId}: ${aggregation}`);
|
|
65
|
+
if (aggregation === "count")
|
|
66
|
+
return rows.length;
|
|
67
|
+
const field = String(definition.field || "");
|
|
68
|
+
if (!field)
|
|
69
|
+
throw new Error(`view.data.metrics.${metricId}.field is required`);
|
|
70
|
+
const values = rows.flatMap((row) => typeof row[field] === "number" && Number.isFinite(row[field]) ? [row[field]] : []);
|
|
71
|
+
if (aggregation === "sum")
|
|
72
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
73
|
+
if (!values.length)
|
|
74
|
+
return null;
|
|
75
|
+
return aggregation === "min" ? Math.min(...values) : Math.max(...values);
|
|
76
|
+
}
|
|
77
|
+
function prepareFormulas(metrics) {
|
|
78
|
+
const formulas = new Map();
|
|
79
|
+
for (const [metricId, rawDefinition] of Object.entries(metrics)) {
|
|
80
|
+
const definition = objectValue(rawDefinition);
|
|
81
|
+
if (typeof definition?.formula !== "string")
|
|
82
|
+
continue;
|
|
83
|
+
formulas.set(metricId, {
|
|
84
|
+
expression: parseReportFormula(definition.formula),
|
|
85
|
+
references: reportFormulaReferences(definition.formula),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return formulas;
|
|
89
|
+
}
|
|
90
|
+
function evaluateFormulas(output, formulas) {
|
|
91
|
+
const pending = new Map(formulas);
|
|
92
|
+
while (pending.size) {
|
|
93
|
+
let progressed = false;
|
|
94
|
+
for (const [metricId, formula] of pending) {
|
|
95
|
+
if (formula.references.some((reference) => pending.has(reference)))
|
|
96
|
+
continue;
|
|
97
|
+
output[metricId] = evaluateReportFormula(formula.expression, output);
|
|
98
|
+
pending.delete(metricId);
|
|
99
|
+
progressed = true;
|
|
100
|
+
}
|
|
101
|
+
if (!progressed)
|
|
102
|
+
throw new Error(`formula dependency cycle: ${[...pending.keys()].sort().join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function matchesFilter(row, rawFilter) {
|
|
106
|
+
if (rawFilter === undefined || rawFilter === null)
|
|
107
|
+
return true;
|
|
108
|
+
const filter = requiredObject(rawFilter, "filter");
|
|
109
|
+
const op = String(filter.op || "");
|
|
110
|
+
if (op === "and" || op === "or") {
|
|
111
|
+
if (!Array.isArray(filter.filters))
|
|
112
|
+
throw new Error(`filter ${op} requires filters`);
|
|
113
|
+
return op === "and"
|
|
114
|
+
? filter.filters.every((child) => matchesFilter(row, child))
|
|
115
|
+
: filter.filters.some((child) => matchesFilter(row, child));
|
|
116
|
+
}
|
|
117
|
+
if (op === "not")
|
|
118
|
+
return !matchesFilter(row, filter.filter);
|
|
119
|
+
const field = String(filter.field || "");
|
|
120
|
+
if (!field)
|
|
121
|
+
throw new Error("filter.field is required");
|
|
122
|
+
const actual = row[field] ?? null;
|
|
123
|
+
if (op === "eq")
|
|
124
|
+
return Object.is(actual, filter.value ?? null);
|
|
125
|
+
if (op === "ne")
|
|
126
|
+
return !Object.is(actual, filter.value ?? null);
|
|
127
|
+
if (["gt", "gte", "lt", "lte"].includes(op) && (actual === null || filter.value === null || filter.value === undefined))
|
|
128
|
+
return false;
|
|
129
|
+
if (op === "gt")
|
|
130
|
+
return compareValues(actual, filter.value) > 0;
|
|
131
|
+
if (op === "gte")
|
|
132
|
+
return compareValues(actual, filter.value) >= 0;
|
|
133
|
+
if (op === "lt")
|
|
134
|
+
return compareValues(actual, filter.value) < 0;
|
|
135
|
+
if (op === "lte")
|
|
136
|
+
return compareValues(actual, filter.value) <= 0;
|
|
137
|
+
if (op === "in" || op === "not_in") {
|
|
138
|
+
if (!Array.isArray(filter.values))
|
|
139
|
+
throw new Error(`filter ${op} requires values`);
|
|
140
|
+
const included = filter.values.some((value) => Object.is(actual, value));
|
|
141
|
+
return op === "in" ? included : !included;
|
|
142
|
+
}
|
|
143
|
+
if (op === "is_null")
|
|
144
|
+
return actual === null;
|
|
145
|
+
if (op === "is_not_null")
|
|
146
|
+
return actual !== null;
|
|
147
|
+
throw new Error(`unsupported filter operation: ${op}`);
|
|
148
|
+
}
|
|
149
|
+
function normalizeOrderBy(value) {
|
|
150
|
+
if (value === undefined)
|
|
151
|
+
return [];
|
|
152
|
+
return (Array.isArray(value) ? value : [value]).map((raw, index) => {
|
|
153
|
+
const item = requiredObject(raw, `view.orderBy[${index}]`);
|
|
154
|
+
const field = String(item.field || "");
|
|
155
|
+
const direction = String(item.direction || "asc");
|
|
156
|
+
if (!field || (direction !== "asc" && direction !== "desc"))
|
|
157
|
+
throw new Error(`invalid view.orderBy[${index}]`);
|
|
158
|
+
return { field, direction };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function compareRows(left, right, orders) {
|
|
162
|
+
for (const order of orders) {
|
|
163
|
+
const result = compareValues(left[order.field], right[order.field]);
|
|
164
|
+
if (result)
|
|
165
|
+
return order.direction === "desc" ? -result : result;
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
function compareValues(left, right) {
|
|
170
|
+
if (Object.is(left, right))
|
|
171
|
+
return 0;
|
|
172
|
+
if (left === null || left === undefined)
|
|
173
|
+
return 1;
|
|
174
|
+
if (right === null || right === undefined)
|
|
175
|
+
return -1;
|
|
176
|
+
if (typeof left === "number" && typeof right === "number")
|
|
177
|
+
return left - right;
|
|
178
|
+
return String(left).localeCompare(String(right));
|
|
179
|
+
}
|
|
180
|
+
function requiredObject(value, path) {
|
|
181
|
+
const object = objectValue(value);
|
|
182
|
+
if (!object)
|
|
183
|
+
throw new Error(`${path} must be an object`);
|
|
184
|
+
return object;
|
|
185
|
+
}
|
|
186
|
+
function objectValue(value) {
|
|
187
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
188
|
+
}
|
|
189
|
+
function stringArray(value, path) {
|
|
190
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item))
|
|
191
|
+
throw new Error(`${path} must be an array of strings`);
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function positiveInteger(value, path) {
|
|
195
|
+
if (!Number.isInteger(value) || Number(value) <= 0)
|
|
196
|
+
throw new Error(`${path} must be a positive integer`);
|
|
197
|
+
return Number(value);
|
|
198
|
+
}
|
package/dist/utils.js
CHANGED
|
@@ -10,15 +10,15 @@ export function integrationEnvironment(value) {
|
|
|
10
10
|
}
|
|
11
11
|
export function integrationPlatform(value) {
|
|
12
12
|
const platform = optionalString(value);
|
|
13
|
-
if (platform === "ios" || platform === "android" || platform === "maker" || platform === "
|
|
13
|
+
if (platform === "ios" || platform === "android" || platform === "maker" || platform === "web")
|
|
14
14
|
return platform;
|
|
15
|
-
throw new Error("--platform must be ios, android, maker, or
|
|
15
|
+
throw new Error("--platform must be ios, android, maker, or web");
|
|
16
16
|
}
|
|
17
17
|
export function experimentPlatform(value) {
|
|
18
18
|
const platform = optionalString(value);
|
|
19
|
-
if (platform === "ios" || platform === "android" || platform === "
|
|
19
|
+
if (platform === "ios" || platform === "android" || platform === "maker" || platform === "web")
|
|
20
20
|
return platform;
|
|
21
|
-
throw new Error("--platform must be ios, android, or
|
|
21
|
+
throw new Error("--platform must be ios, android, maker, or web for experiments");
|
|
22
22
|
}
|
|
23
23
|
export function optionalString(value) {
|
|
24
24
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|