gamealgo-cli 0.3.9 → 0.4.2
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 +14 -0
- package/dist/client.js +20 -2
- 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/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 +15 -5
package/README.md
CHANGED
|
@@ -35,6 +35,20 @@ gamealgo listapi
|
|
|
35
35
|
gamealgo docs api GameAlgo.Init --platform lua
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
Report Pack v2 uses materialized ordinary Datasets and inline Views. Common report commands are:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
gamealgo report pull --out gamealgo-report-pack.json
|
|
42
|
+
gamealgo report validate gamealgo-report-pack.json
|
|
43
|
+
gamealgo report preview --pack gamealgo-report-pack.json
|
|
44
|
+
gamealgo report publish gamealgo-report-pack.json
|
|
45
|
+
gamealgo report result --from 2026-09-04 --to 2026-09-17 --platform maker --tab-id overview
|
|
46
|
+
gamealgo report advanced run --query-id battle_analysis --from 2026-09-04 --to 2026-09-17 --platform maker --wait
|
|
47
|
+
gamealgo report advanced list
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All Report Packs use `schemaVersion: 2`, with custom `groups[].views` or platform-owned `groups[].standard`. Advanced query runs are explicit, are never preloaded, and remain available as immutable reports for 30 days.
|
|
51
|
+
|
|
38
52
|
Help and documentation are fetched from the configured Server on every invocation. The CLI intentionally does not keep a documentation cache.
|
|
39
53
|
|
|
40
54
|
## Agent skill
|
package/dist/client.js
CHANGED
|
@@ -168,6 +168,24 @@ export class GameAlgoAdminClient {
|
|
|
168
168
|
const query = params.size ? `?${params.toString()}` : "";
|
|
169
169
|
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/manifest${query}`);
|
|
170
170
|
}
|
|
171
|
+
async readReportDataset(input) {
|
|
172
|
+
const params = new URLSearchParams({
|
|
173
|
+
platform: input.platform,
|
|
174
|
+
startDate: input.startDate,
|
|
175
|
+
endDate: input.endDate,
|
|
176
|
+
});
|
|
177
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/report-datasets/${encodeURIComponent(input.datasetFingerprint)}/results?${params.toString()}`);
|
|
178
|
+
}
|
|
179
|
+
async createAdvancedReportRun(body, options = {}) {
|
|
180
|
+
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/advanced-runs`, body, options);
|
|
181
|
+
}
|
|
182
|
+
async getAdvancedReportRun(runId, includeResult = true) {
|
|
183
|
+
const params = new URLSearchParams({ includeResult: includeResult ? "true" : "false" });
|
|
184
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/advanced-runs/${encodeURIComponent(runId)}?${params.toString()}`);
|
|
185
|
+
}
|
|
186
|
+
async listAdvancedReportRuns(limit = 50) {
|
|
187
|
+
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/advanced-runs?limit=${encodeURIComponent(String(limit))}`);
|
|
188
|
+
}
|
|
171
189
|
async queryReportDashboard(body, options = {}) {
|
|
172
190
|
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/reports/query`, body, options);
|
|
173
191
|
}
|
|
@@ -231,8 +249,8 @@ export class GameAlgoAdminClient {
|
|
|
231
249
|
async listGameKeys() {
|
|
232
250
|
return await this.get(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys`);
|
|
233
251
|
}
|
|
234
|
-
async createGameKey(name) {
|
|
235
|
-
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys`, { name });
|
|
252
|
+
async createGameKey(name, platform) {
|
|
253
|
+
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys`, { name, ...(platform ? { platform } : {}) });
|
|
236
254
|
}
|
|
237
255
|
async revealGameKey(selector) {
|
|
238
256
|
return await this.post(`/admin/v1/games/${encodeURIComponent(await this.gameId())}/keys/reveal`, selector);
|
|
@@ -129,7 +129,7 @@ export async function handleExperimentStrategy(client, args, global) {
|
|
|
129
129
|
const platform = flags.platform === undefined ? undefined : experimentPlatform(flags.platform);
|
|
130
130
|
const message = requiredMessage(flags, "experiment strategy rollback");
|
|
131
131
|
if (!strategyKey || !versionId) {
|
|
132
|
-
throw new Error("usage: gamealgo experiment strategy rollback <strategyKey> --version-id <versionId> [--platform ios|android|
|
|
132
|
+
throw new Error("usage: gamealgo experiment strategy rollback <strategyKey> --version-id <versionId> [--platform ios|android|maker|web] --message <reason> --yes");
|
|
133
133
|
}
|
|
134
134
|
await requireYes(flags, global, "experiment strategy rollback");
|
|
135
135
|
await printResult(await client.rollbackExperimentV2Strategy(strategyKey, versionId, message, platform), global);
|
|
@@ -16,7 +16,7 @@ export async function handleIntegration(client, args, global) {
|
|
|
16
16
|
await writeIntegrationOutput(response, flags, global);
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
|
-
throw new Error("usage: gamealgo integration get-plan --environment <domestic|overseas> --platform <ios|android|maker|
|
|
19
|
+
throw new Error("usage: gamealgo integration get-plan --environment <domestic|overseas> --platform <ios|android|maker|web>");
|
|
20
20
|
}
|
|
21
21
|
export async function writeIntegrationOutput(value, flags, global) {
|
|
22
22
|
if (flags.out) {
|
package/dist/commands/keys.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseFlags } from "../args.js";
|
|
2
2
|
import { confirm, isInteractive, printResult } from "../output.js";
|
|
3
3
|
import { announceTargetGame } from "../target.js";
|
|
4
|
-
import { optionalString } from "../utils.js";
|
|
4
|
+
import { integrationPlatform, optionalString } from "../utils.js";
|
|
5
5
|
export async function handleGameKey(client, args, global) {
|
|
6
6
|
const sub = args.shift();
|
|
7
7
|
if (sub === "list") {
|
|
@@ -15,8 +15,9 @@ export async function handleGameKey(client, args, global) {
|
|
|
15
15
|
const flags = parseFlags(args);
|
|
16
16
|
const name = optionalString(flags.name) || optionalString(args.shift());
|
|
17
17
|
if (!name)
|
|
18
|
-
throw new Error("usage: gamealgo key create --name <key-name>");
|
|
19
|
-
const
|
|
18
|
+
throw new Error("usage: gamealgo key create --name <key-name> [--platform ios|android|maker|web]");
|
|
19
|
+
const platform = flags.platform === undefined ? undefined : integrationPlatform(flags.platform);
|
|
20
|
+
const response = await client.createGameKey(name, platform);
|
|
20
21
|
await printResult({
|
|
21
22
|
ok: true,
|
|
22
23
|
created: response.created !== false,
|
|
@@ -25,6 +26,7 @@ export async function handleGameKey(client, args, global) {
|
|
|
25
26
|
prefix: response.key.keyPrefix,
|
|
26
27
|
keyPrefix: response.key.keyPrefix,
|
|
27
28
|
status: response.key.status,
|
|
29
|
+
platform: response.key.platform,
|
|
28
30
|
createdAt: response.key.createdAt,
|
|
29
31
|
}, global);
|
|
30
32
|
return;
|
|
@@ -40,6 +42,7 @@ export async function handleGameKey(client, args, global) {
|
|
|
40
42
|
prefix: response.key.keyPrefix,
|
|
41
43
|
keyPrefix: response.key.keyPrefix,
|
|
42
44
|
status: response.key.status,
|
|
45
|
+
platform: response.key.platform,
|
|
43
46
|
createdAt: response.key.createdAt,
|
|
44
47
|
}, global);
|
|
45
48
|
return;
|
|
@@ -72,6 +75,7 @@ export function publicGameKeyForCli(key) {
|
|
|
72
75
|
prefix: key.keyPrefix,
|
|
73
76
|
keyPrefix: key.keyPrefix,
|
|
74
77
|
status: key.status,
|
|
78
|
+
platform: key.platform,
|
|
75
79
|
createdAt: key.createdAt,
|
|
76
80
|
};
|
|
77
81
|
}
|
package/dist/commands/report.js
CHANGED
|
@@ -4,16 +4,24 @@ import { formatDuration, markCommandFailed, printResult, reportTimeoutMs, startP
|
|
|
4
4
|
import { reportDashboardCsv, reportDashboardCsvExports } from "../report-csv.js";
|
|
5
5
|
import { normalizeReportSampleRows, summarizeReportDashboard, summarizeReportManifest, summarizeReportValidation, } from "../report-summary.js";
|
|
6
6
|
import { objectValue, readJsonFile, writeTextFile } from "../resources.js";
|
|
7
|
+
import { queryReportPackV2Result } from "../report-v2/result.js";
|
|
8
|
+
import { assertWritableReportPackV2, reportPackSchemaVersion } from "../report-v2/schema.js";
|
|
9
|
+
import { summarizeReportPackV2Manifest } from "../report-v2/summary.js";
|
|
7
10
|
import { announceTargetGame } from "../target.js";
|
|
8
11
|
import { optionalString } from "../utils.js";
|
|
9
12
|
export async function handleReport(client, args, global) {
|
|
10
13
|
const sub = args.shift();
|
|
14
|
+
if (sub === "advanced") {
|
|
15
|
+
await handleAdvancedReport(client, args, global);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
11
18
|
if (sub === "pull") {
|
|
12
19
|
const flags = parseFlags(args);
|
|
13
20
|
const pack = await client.getReportPack();
|
|
14
|
-
const
|
|
21
|
+
const schemaVersion = reportPackSchemaVersion(pack.reportPack.content);
|
|
22
|
+
const out = String(flags.out || `gamealgo-report-pack-v${schemaVersion}.json`);
|
|
15
23
|
await writeTextFile(out, JSON.stringify(pack.reportPack.content, null, 2) + "\n");
|
|
16
|
-
await printResult({ ok: true, out }, global);
|
|
24
|
+
await printResult({ ok: true, out, schemaVersion }, global);
|
|
17
25
|
return;
|
|
18
26
|
}
|
|
19
27
|
if (sub === "validate") {
|
|
@@ -23,6 +31,7 @@ export async function handleReport(client, args, global) {
|
|
|
23
31
|
const flags = parseFlags(args);
|
|
24
32
|
const full = reportFullOutput(flags);
|
|
25
33
|
const content = await readJsonFile(filePath);
|
|
34
|
+
assertWritableReportPackV2(content, "report validate");
|
|
26
35
|
const response = await client.previewReportPack(content, { responseMode: full ? "full" : "summary" });
|
|
27
36
|
const summary = summarizeReportValidation(response);
|
|
28
37
|
await printResult(full ? response : summary, global);
|
|
@@ -36,6 +45,7 @@ export async function handleReport(client, args, global) {
|
|
|
36
45
|
throw new Error("usage: gamealgo report publish <report-pack.json>");
|
|
37
46
|
parseFlags(args);
|
|
38
47
|
const content = await readJsonFile(filePath);
|
|
48
|
+
assertWritableReportPackV2(content, "report publish");
|
|
39
49
|
await announceTargetGame(client);
|
|
40
50
|
const response = await client.putReportPack(content);
|
|
41
51
|
await printResult({ ok: true, validation: response.reportPack.validation }, global);
|
|
@@ -45,7 +55,10 @@ export async function handleReport(client, args, global) {
|
|
|
45
55
|
const flags = parseFlags(args);
|
|
46
56
|
const full = reportFullOutput(flags);
|
|
47
57
|
const response = await client.reportManifest({ responseMode: full ? "full" : "summary" });
|
|
48
|
-
|
|
58
|
+
const manifest = objectValue(objectValue(response)?.manifest);
|
|
59
|
+
await printResult(full
|
|
60
|
+
? response
|
|
61
|
+
: manifest?.schemaVersion === 2 ? summarizeReportPackV2Manifest(response) : summarizeReportManifest(response), global);
|
|
49
62
|
return;
|
|
50
63
|
}
|
|
51
64
|
if (sub === "result") {
|
|
@@ -54,32 +67,50 @@ export async function handleReport(client, args, global) {
|
|
|
54
67
|
const startDate = String(flags.from || flags.start || flags.startDate || "");
|
|
55
68
|
const endDate = String(flags.to || flags.end || flags.endDate || "");
|
|
56
69
|
if (!startDate || !endDate)
|
|
57
|
-
throw new Error("usage: gamealgo report result --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|
|
|
70
|
+
throw new Error("usage: gamealgo report result --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|maker|web] [--tab name] [--group name] [--chart name] [--selector k=v] [--sample-rows 0..20] [--full] [--format json|csv] [--out file-or-directory]");
|
|
58
71
|
requireReportQueryScope(flags, "report result");
|
|
59
72
|
const timeoutMs = reportTimeoutMs(flags);
|
|
60
73
|
const format = reportOutputFormat(flags.format);
|
|
61
74
|
const full = reportFullOutput(flags) || format === "csv";
|
|
62
75
|
const sampleRows = reportSampleRows(flags, full);
|
|
63
76
|
const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
|
|
77
|
+
const manifestResponse = await client.reportManifest({ responseMode: "full" });
|
|
78
|
+
const manifest = objectValue(objectValue(manifestResponse)?.manifest);
|
|
79
|
+
const schemaVersion = manifest?.schemaVersion === 2 ? 2 : 1;
|
|
64
80
|
const startedAt = Date.now();
|
|
65
|
-
const stopProgress = startProgress("Querying report results", timeoutMs);
|
|
81
|
+
const stopProgress = startProgress(schemaVersion === 2 ? "Reading report results" : "Querying report results", timeoutMs);
|
|
66
82
|
let response;
|
|
67
83
|
try {
|
|
68
|
-
response =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
response = schemaVersion === 2
|
|
85
|
+
? await queryReportPackV2Result(client, manifestResponse, {
|
|
86
|
+
startDate,
|
|
87
|
+
endDate,
|
|
88
|
+
tab: optionalString(flags.tab),
|
|
89
|
+
tabId: optionalString(flags["tab-id"]),
|
|
90
|
+
group: optionalString(flags.group),
|
|
91
|
+
groupId: optionalString(flags["group-id"]),
|
|
92
|
+
chart: optionalString(flags.chart),
|
|
93
|
+
chartId: optionalString(flags["chart-id"]),
|
|
94
|
+
platform,
|
|
95
|
+
selectors,
|
|
96
|
+
refresh: Boolean(flags.refresh),
|
|
97
|
+
timeoutMs,
|
|
98
|
+
})
|
|
99
|
+
: await client.queryReportDashboard({
|
|
100
|
+
startDate,
|
|
101
|
+
endDate,
|
|
102
|
+
tab: optionalString(flags.tab),
|
|
103
|
+
tabId: optionalString(flags["tab-id"]),
|
|
104
|
+
group: optionalString(flags.group),
|
|
105
|
+
groupId: optionalString(flags["group-id"]),
|
|
106
|
+
chart: optionalString(flags.chart),
|
|
107
|
+
chartId: optionalString(flags["chart-id"]),
|
|
108
|
+
platform,
|
|
109
|
+
selectors,
|
|
110
|
+
refresh: Boolean(flags.refresh),
|
|
111
|
+
responseMode: full ? "full" : "summary",
|
|
112
|
+
sampleRows,
|
|
113
|
+
}, { timeoutMs });
|
|
83
114
|
}
|
|
84
115
|
finally {
|
|
85
116
|
stopProgress();
|
|
@@ -93,53 +124,159 @@ export async function handleReport(client, args, global) {
|
|
|
93
124
|
return;
|
|
94
125
|
}
|
|
95
126
|
if (sub === "preview") {
|
|
96
|
-
const selectors = parseSelectorFlags(args);
|
|
97
127
|
const flags = parseFlags(args);
|
|
98
128
|
const filePath = String(flags.pack || flags.file || args.shift() || "");
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!filePath || !startDate || !endDate) {
|
|
102
|
-
throw new Error("usage: gamealgo report preview --pack report-pack.json --from YYYY-MM-DD --to YYYY-MM-DD [--platform ios|android|rest] [--tab name] [--group name] [--chart name] [--selector k=v] [--sample-rows 0..20] [--full] [--format json|csv] [--out file-or-directory]");
|
|
103
|
-
}
|
|
104
|
-
requireReportQueryScope(flags, "report preview");
|
|
129
|
+
if (!filePath)
|
|
130
|
+
throw new Error("usage: gamealgo report preview --pack report-pack.json [--full]");
|
|
105
131
|
const content = await readJsonFile(filePath);
|
|
132
|
+
assertWritableReportPackV2(content, "report preview");
|
|
106
133
|
const timeoutMs = reportTimeoutMs(flags);
|
|
107
|
-
const
|
|
108
|
-
const full = reportFullOutput(flags) || format === "csv";
|
|
109
|
-
const sampleRows = reportSampleRows(flags, full);
|
|
110
|
-
const platform = await resolveCliReportPlatform(client, flags.platform, selectors.platform, startDate, endDate);
|
|
134
|
+
const full = reportFullOutput(flags);
|
|
111
135
|
const startedAt = Date.now();
|
|
112
|
-
const stopProgress = startProgress("
|
|
136
|
+
const stopProgress = startProgress("Validating Report Pack v2 preview", timeoutMs);
|
|
113
137
|
let response;
|
|
114
138
|
try {
|
|
115
|
-
response = await client.
|
|
116
|
-
content,
|
|
117
|
-
startDate,
|
|
118
|
-
endDate,
|
|
119
|
-
tab: optionalString(flags.tab),
|
|
120
|
-
tabId: optionalString(flags["tab-id"]),
|
|
121
|
-
group: optionalString(flags.group),
|
|
122
|
-
groupId: optionalString(flags["group-id"]),
|
|
123
|
-
chart: optionalString(flags.chart),
|
|
124
|
-
chartId: optionalString(flags["chart-id"]),
|
|
125
|
-
platform,
|
|
126
|
-
selectors,
|
|
127
|
-
responseMode: full ? "full" : "summary",
|
|
128
|
-
sampleRows,
|
|
129
|
-
}, { timeoutMs });
|
|
139
|
+
response = await client.previewReportPack(content, { responseMode: full ? "full" : "summary" });
|
|
130
140
|
}
|
|
131
141
|
finally {
|
|
132
142
|
stopProgress();
|
|
133
143
|
}
|
|
134
144
|
const elapsedMs = Date.now() - startedAt;
|
|
135
145
|
const rawOutputValue = withCliMeta(response, { elapsedMs, timeoutMs });
|
|
136
|
-
const statusSummary =
|
|
146
|
+
const statusSummary = summarizeReportValidation(rawOutputValue, "report.preview");
|
|
137
147
|
const outputValue = full ? rawOutputValue : statusSummary;
|
|
138
|
-
process.stderr.write(`Report preview finished in ${formatDuration(elapsedMs)}.\n`);
|
|
139
|
-
|
|
148
|
+
process.stderr.write(`Report Pack preview finished in ${formatDuration(elapsedMs)}.\n`);
|
|
149
|
+
const out = optionalString(flags.out);
|
|
150
|
+
if (flags.out === true)
|
|
151
|
+
throw new Error("--out requires a file path");
|
|
152
|
+
if (out) {
|
|
153
|
+
await writeTextFile(out, JSON.stringify(outputValue, null, 2) + "\n");
|
|
154
|
+
await printResult({ ok: statusSummary.ok, out, elapsedMs }, global);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
await printResult(outputValue, global);
|
|
158
|
+
}
|
|
159
|
+
if (!statusSummary.ok)
|
|
160
|
+
markCommandFailed();
|
|
140
161
|
return;
|
|
141
162
|
}
|
|
142
|
-
throw new Error("usage: gamealgo report <pull|validate|publish|manifest|result|preview>");
|
|
163
|
+
throw new Error("usage: gamealgo report <pull|validate|publish|manifest|result|preview|advanced>");
|
|
164
|
+
}
|
|
165
|
+
async function handleAdvancedReport(client, args, global) {
|
|
166
|
+
const action = args.shift();
|
|
167
|
+
if (action === "run") {
|
|
168
|
+
const flags = parseFlags(args);
|
|
169
|
+
const queryId = requiredTextFlag(flags["query-id"], "--query-id");
|
|
170
|
+
const startDate = requiredTextFlag(flags.from || flags.start || flags.startDate, "--from");
|
|
171
|
+
const endDate = requiredTextFlag(flags.to || flags.end || flags.endDate, "--to");
|
|
172
|
+
const platform = optionalCliReportPlatform(flags.platform, "--platform");
|
|
173
|
+
if (!platform)
|
|
174
|
+
throw new Error("--platform is required for report advanced run");
|
|
175
|
+
if (flags.format !== undefined && flags.format !== "json")
|
|
176
|
+
throw new Error("report advanced currently supports only --format json");
|
|
177
|
+
const packPath = optionalString(flags.pack);
|
|
178
|
+
if (flags.pack === true)
|
|
179
|
+
throw new Error("--pack requires a file path");
|
|
180
|
+
const pack = packPath ? await readJsonFile(packPath) : undefined;
|
|
181
|
+
if (pack !== undefined)
|
|
182
|
+
assertWritableReportPackV2(pack, "report advanced run");
|
|
183
|
+
const wait = booleanFlag(flags.wait, "--wait");
|
|
184
|
+
const timeoutMs = reportTimeoutMs(flags) ?? 15 * 60 * 1000;
|
|
185
|
+
await announceTargetGame(client);
|
|
186
|
+
const created = await client.createAdvancedReportRun({
|
|
187
|
+
queryId,
|
|
188
|
+
startDate,
|
|
189
|
+
endDate,
|
|
190
|
+
platform,
|
|
191
|
+
...(pack !== undefined ? { pack } : {}),
|
|
192
|
+
}, { timeoutMs: Math.min(timeoutMs, 30_000) });
|
|
193
|
+
if (!wait) {
|
|
194
|
+
await writeAdvancedOutput(created, flags, global);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const runId = requiredRunId(created.run?.id);
|
|
198
|
+
const startedAt = Date.now();
|
|
199
|
+
const stopProgress = startProgress(`Waiting for advanced report ${runId}`, timeoutMs);
|
|
200
|
+
let response;
|
|
201
|
+
try {
|
|
202
|
+
response = await waitForAdvancedReport(client, runId, timeoutMs, startedAt);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
stopProgress();
|
|
206
|
+
}
|
|
207
|
+
await writeAdvancedOutput(withCliMeta(response, { elapsedMs: Date.now() - startedAt, timeoutMs }), flags, global);
|
|
208
|
+
if (response.run.status === "failed")
|
|
209
|
+
markCommandFailed();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (action === "get") {
|
|
213
|
+
const runId = args.shift();
|
|
214
|
+
if (!runId)
|
|
215
|
+
throw new Error("usage: gamealgo report advanced get <run-id> [--metadata-only] [--out file]");
|
|
216
|
+
const flags = parseFlags(args);
|
|
217
|
+
const metadataOnly = booleanFlag(flags["metadata-only"], "--metadata-only");
|
|
218
|
+
const response = await client.getAdvancedReportRun(runId, !metadataOnly);
|
|
219
|
+
await writeAdvancedOutput(response, flags, global);
|
|
220
|
+
if (response.run.status === "failed")
|
|
221
|
+
markCommandFailed();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (action === "list") {
|
|
225
|
+
const flags = parseFlags(args);
|
|
226
|
+
const limit = positiveIntegerFlag(flags.limit, "--limit", 50);
|
|
227
|
+
await writeAdvancedOutput(await client.listAdvancedReportRuns(limit), flags, global);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
throw new Error("usage: gamealgo report advanced <run|get|list>");
|
|
231
|
+
}
|
|
232
|
+
async function waitForAdvancedReport(client, runId, timeoutMs, startedAt) {
|
|
233
|
+
while (true) {
|
|
234
|
+
const response = await client.getAdvancedReportRun(runId, true);
|
|
235
|
+
if (response.run.status === "succeeded" || response.run.status === "failed")
|
|
236
|
+
return response;
|
|
237
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
238
|
+
throw new Error(`advanced report ${runId} did not finish within ${formatDuration(timeoutMs)}; use report advanced get ${runId} later`);
|
|
239
|
+
}
|
|
240
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function writeAdvancedOutput(value, flags, global) {
|
|
244
|
+
if (flags.format !== undefined && flags.format !== "json")
|
|
245
|
+
throw new Error("report advanced currently supports only --format json");
|
|
246
|
+
const out = optionalString(flags.out);
|
|
247
|
+
if (flags.out === true)
|
|
248
|
+
throw new Error("--out requires a file path");
|
|
249
|
+
if (!out) {
|
|
250
|
+
await printResult(value, global);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
await writeTextFile(out, JSON.stringify(value, null, 2) + "\n");
|
|
254
|
+
await printResult({ ok: true, out }, global);
|
|
255
|
+
}
|
|
256
|
+
function requiredTextFlag(value, flag) {
|
|
257
|
+
if (typeof value !== "string" || !value.trim())
|
|
258
|
+
throw new Error(`${flag} is required`);
|
|
259
|
+
return value.trim();
|
|
260
|
+
}
|
|
261
|
+
function requiredRunId(value) {
|
|
262
|
+
if (typeof value !== "string" || !value)
|
|
263
|
+
throw new Error("advanced report create response is missing run.id");
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
function booleanFlag(value, flag) {
|
|
267
|
+
if (value === undefined)
|
|
268
|
+
return false;
|
|
269
|
+
if (value !== true)
|
|
270
|
+
throw new Error(`${flag} does not accept a value`);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
function positiveIntegerFlag(value, flag, fallback) {
|
|
274
|
+
if (value === undefined)
|
|
275
|
+
return fallback;
|
|
276
|
+
if (typeof value !== "string" || !/^\d+$/.test(value) || Number(value) < 1) {
|
|
277
|
+
throw new Error(`${flag} must be a positive integer`);
|
|
278
|
+
}
|
|
279
|
+
return Number(value);
|
|
143
280
|
}
|
|
144
281
|
function reportFullOutput(flags) {
|
|
145
282
|
if (flags.full === undefined)
|
|
@@ -293,7 +430,7 @@ export async function resolveCliReportPlatform(client, flagValue, selectorValue,
|
|
|
293
430
|
const detected = objectValue(await client.countEventPlatforms(startDate, endDate));
|
|
294
431
|
const detectedPlatform = optionalCliReportPlatform(detected?.recommendedPlatform, "detected report platform");
|
|
295
432
|
if (!detectedPlatform) {
|
|
296
|
-
throw new Error("report platform could not be detected for the selected date range; pass --platform ios,
|
|
433
|
+
throw new Error("report platform could not be detected for the selected date range; pass --platform ios, android, maker, or web");
|
|
297
434
|
}
|
|
298
435
|
process.stderr.write(`Report platform: ${detectedPlatform} (detected from event data)\n`);
|
|
299
436
|
return detectedPlatform;
|
|
@@ -301,7 +438,7 @@ export async function resolveCliReportPlatform(client, flagValue, selectorValue,
|
|
|
301
438
|
export function optionalCliReportPlatform(value, field) {
|
|
302
439
|
if (value === undefined || value === null || value === "")
|
|
303
440
|
return undefined;
|
|
304
|
-
if (value === "ios" || value === "android" || value === "
|
|
441
|
+
if (value === "ios" || value === "android" || value === "maker" || value === "web")
|
|
305
442
|
return value;
|
|
306
|
-
throw new Error(`${field} must be ios, android, or
|
|
443
|
+
throw new Error(`${field} must be ios, android, maker, or web`);
|
|
307
444
|
}
|
package/dist/compatibility.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadPackageMetadata } from "./config.js";
|
|
2
2
|
import { requestJson } from "./http.js";
|
|
3
|
-
export const CLI_PROTOCOL_VERSION =
|
|
3
|
+
export const CLI_PROTOCOL_VERSION = 2;
|
|
4
4
|
export async function assertServerCompatibility(host) {
|
|
5
5
|
const metadata = await requestJson(host, "/admin/v1/cli/meta", {
|
|
6
6
|
headers: { "Cache-Control": "no-cache" },
|
package/dist/docs.js
CHANGED
|
@@ -459,12 +459,12 @@ export function parseDocsApiPlatform(value) {
|
|
|
459
459
|
if (value === undefined || value === false || value === "")
|
|
460
460
|
return undefined;
|
|
461
461
|
const platform = String(value).trim().toLowerCase();
|
|
462
|
-
if (platform === "
|
|
463
|
-
return "
|
|
464
|
-
if (platform === "
|
|
462
|
+
if (platform === "lua")
|
|
463
|
+
return "maker";
|
|
464
|
+
if (platform === "maker" || platform === "ios" || platform === "android" || platform === "web") {
|
|
465
465
|
return platform;
|
|
466
466
|
}
|
|
467
|
-
throw new Error("--platform must be
|
|
467
|
+
throw new Error("--platform must be ios, android, maker, or web");
|
|
468
468
|
}
|
|
469
469
|
export async function printDocsApiResult(options) {
|
|
470
470
|
const { query, platform, manifest, topic, document, webUrl, host, global } = options;
|
|
@@ -537,8 +537,8 @@ export function parseDocsApiEntries(content) {
|
|
|
537
537
|
if (!symbols.length)
|
|
538
538
|
continue;
|
|
539
539
|
const platforms = parseDocsApiMetadataList(section, "平台")
|
|
540
|
-
.map((item) => item.toLowerCase())
|
|
541
|
-
.filter((item) => item === "
|
|
540
|
+
.map((item) => item.toLowerCase() === "lua" ? "maker" : item.toLowerCase())
|
|
541
|
+
.filter((item) => item === "maker" || item === "ios" || item === "android" || item === "web");
|
|
542
542
|
const summaryMatch = section.match(/^-\s*用途[::]\s*(.+)$/m);
|
|
543
543
|
entries.push({
|
|
544
544
|
name: stripInlineMarkdown(heading[1]),
|
package/dist/report-summary.js
CHANGED
|
@@ -2,7 +2,7 @@ export const REPORT_SUMMARY_SCHEMA_VERSION = 1;
|
|
|
2
2
|
export const REPORT_SUMMARY_MAX_ITEMS = 200;
|
|
3
3
|
export const REPORT_SUMMARY_MAX_ISSUES = 100;
|
|
4
4
|
export const REPORT_SUMMARY_MAX_SAMPLE_ROWS = 20;
|
|
5
|
-
export function summarizeReportValidation(value) {
|
|
5
|
+
export function summarizeReportValidation(value, command = "report.validate") {
|
|
6
6
|
const root = objectValue(value);
|
|
7
7
|
const validation = objectValue(root?.validation);
|
|
8
8
|
const errorMessages = stringValues(validation?.errors);
|
|
@@ -24,7 +24,7 @@ export function summarizeReportValidation(value) {
|
|
|
24
24
|
const items = statementItems.slice(0, REPORT_SUMMARY_MAX_ITEMS);
|
|
25
25
|
return {
|
|
26
26
|
schemaVersion: REPORT_SUMMARY_SCHEMA_VERSION,
|
|
27
|
-
command
|
|
27
|
+
command,
|
|
28
28
|
ok: validation?.ok === true && errorMessages.length === 0,
|
|
29
29
|
summary: {
|
|
30
30
|
errorCount: errorMessages.length,
|
|
@@ -146,6 +146,13 @@ export function summarizeReportDashboard(value, options) {
|
|
|
146
146
|
if (result?.stale === true) {
|
|
147
147
|
allWarnings.push({ scope, message: "Report result is stale" });
|
|
148
148
|
}
|
|
149
|
+
if (result?.partial === true) {
|
|
150
|
+
const missingDates = stringValues(result.missingDates);
|
|
151
|
+
allWarnings.push({
|
|
152
|
+
scope,
|
|
153
|
+
message: `Report Dataset coverage is partial${missingDates.length ? `; missing dates: ${missingDates.join(", ")}` : ""}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
149
156
|
allItems.push(compactObject({
|
|
150
157
|
kind: "chart-result",
|
|
151
158
|
status,
|
|
@@ -162,6 +169,9 @@ export function summarizeReportDashboard(value, options) {
|
|
|
162
169
|
cached: typeof result?.cached === "boolean" ? result.cached : undefined,
|
|
163
170
|
cachedAt: stringValue(result?.cachedAt),
|
|
164
171
|
stale: typeof result?.stale === "boolean" ? result.stale : undefined,
|
|
172
|
+
partial: typeof result?.partial === "boolean" ? result.partial : undefined,
|
|
173
|
+
missingDates: stringValues(result?.missingDates),
|
|
174
|
+
snapshotVersion: stringValue(result?.snapshotVersion),
|
|
165
175
|
truncated,
|
|
166
176
|
rowLimit: finiteNonNegativeNumber(result?.rowLimit),
|
|
167
177
|
error,
|
|
@@ -243,6 +253,8 @@ function reportResultStatus(result) {
|
|
|
243
253
|
return "retrying";
|
|
244
254
|
if (result?.refreshing === true)
|
|
245
255
|
return "refreshing";
|
|
256
|
+
if (result?.partial === true)
|
|
257
|
+
return "partial";
|
|
246
258
|
if (result?.stale === true)
|
|
247
259
|
return "stale";
|
|
248
260
|
return "ready";
|
|
@@ -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;
|
package/package.json
CHANGED
package/skills/gamealgo/SKILL.md
CHANGED
|
@@ -34,7 +34,7 @@ Classify the requested work by the actions it requires, not only by the user's t
|
|
|
34
34
|
|
|
35
35
|
Use each topic's section list as the router. Read the whole topic only when the task genuinely spans most of it; when that is necessary, load one full topic per tool call. If any tool marks documentation output as truncated, treat it as incomplete, retrieve the missing sections, and do not infer that unseen guidance is absent or switch workflows based on the partial output.
|
|
36
36
|
|
|
37
|
-
- SDK installation, identity, lifecycle, telemetry, attribution, advertising, or purchase work: run `gamealgo integration get-plan --environment <domestic|overseas> --platform <ios|android|maker|
|
|
37
|
+
- SDK installation, identity, lifecycle, telemetry, attribution, advertising, or purchase work: run `gamealgo integration get-plan --environment <domestic|overseas> --platform <ios|android|maker|web> --json`, then list the `sdk-integration` sections and read the relevant shared and platform-specific sections.
|
|
38
38
|
- Calling or changing a concrete public SDK function: additionally run `gamealgo docs api <function> --platform <platform>` for each affected function. Never infer one platform's API from another.
|
|
39
39
|
- Adding or changing events: additionally run `gamealgo events list --days 30 --json` before defining fields or names.
|
|
40
40
|
- Querying an existing dashboard or report: read `gamealgo docs reports --section report-query` plus the section that defines the relevant metric or template. Creating, changing, previewing, or publishing Report Packs, charts, report SQL, or business metrics: list the `reports` sections and read every section affected by the change before interpreting data or editing definitions.
|
|
@@ -47,7 +47,17 @@ For `report validate`, `report manifest`, `report preview`, and JSON `report res
|
|
|
47
47
|
|
|
48
48
|
When changing an existing Report Pack, run `gamealgo report pull --out <working-pack.json>` before editing and use that current published Pack as the baseline. Do not start from a remembered or stale local copy. Run `gamealgo events diff --pack <working-pack.json>` after defining the Pack events; if it reports missing fields, type conflicts, or blocked/deprecated events, inspect the actual game call sites and prepare real business descriptions rather than inventing catalog text from field names. The diff is read-only and does not authorize `events apply` or event activation.
|
|
49
49
|
|
|
50
|
-
Validate with the compact default output, then preview
|
|
50
|
+
Validate with the compact default output, then use `report preview` to inspect v2 validation and generated Dataset SQL without running a warehouse query. After publishing, verify only the affected tab, group, or View with `report result`; do not query unrelated tabs. A report result request must explicitly select a tab, group, or chart/View. A globally unique bare id is accepted; if the Server returns an ambiguity error, use its candidate ids and the local Pack structure to retry without asking the developer to choose an internal id. Before applying event metadata or publishing the Pack, show the exact changes and impact unless the developer already authorized them. After publishing, pull the effective Pack again and verify the expected manifest and materialized result state.
|
|
51
|
+
|
|
52
|
+
### Use Report Pack v2
|
|
53
|
+
|
|
54
|
+
Every Report Pack must declare `schemaVersion: 2`. v2 uses independent `datasets`, inline `groups[].views`, platform-owned `groups[].standard`, `tabs`, and optional `experimentViews` and `advancedQueries`. It has no top-level `reports` or `charts` and no author-managed Pack version. Generate, explain, validate, preview, and publish only this structure; do not expose migration-era formats to the developer.
|
|
55
|
+
|
|
56
|
+
An ordinary v2 Dataset must read only `adn.dwd_gamealgo_events_enriched_di`, include `dt` in its output grain, and be independently computable for each natural day. Before selector filtering, each `datasetFingerprint × platform × dt` must remain at or below 100 rows and 128 KiB. Estimate the output cardinality from every `groupBy` dimension before publishing. If it may exceed either limit, reduce or bucket dimensions, split the business question, or move it into `advancedQueries`; a table View, front-end selector, or `dataset.limit` does not bypass the ordinary limit.
|
|
57
|
+
|
|
58
|
+
Keep ratio and average components as additive Dataset columns and calculate the final value in the View after range aggregation. For example, materialize `wins` and `attempts`, then define `win_rate = wins / attempts` in `groups[].views[].data.metrics`. Each View references exactly one Dataset. A selector switch is a local View recalculation and must not be modeled as a new warehouse request.
|
|
59
|
+
|
|
60
|
+
Use `gamealgo report result` to read an existing ordinary v2 materialization for an explicit date range and scope. It never starts a DW query. Use `gamealgo report advanced run --query-id <id> --platform <platform> --from <date> --to <date>` only when an explicit high-dimensional or non-preloaded query is required. `--pack <file>` supplies an immutable v2 Pack snapshot for that Run without publishing it. `advanced get` and `advanced list` only read the saved Run/history. Advanced results are whole-run bounded to 20,000 rows and 20 MiB and retained for 30 days; never replace this path with local production scans or a hidden query side channel.
|
|
51
61
|
|
|
52
62
|
For initial integration or business dashboard work, standard dashboards are only the baseline. During the initial integration, inspect the actual game loop and define a separate game-specific new-user flow dashboard with meaningful milestones and drop-off diagnostics. Add required milestone telemetry and event catalog fields in the same integration instead of waiting for production volume. Validate the pipeline with test data; label business conclusions as awaiting data when production samples are immature, but do not omit the dashboard design.
|
|
53
63
|
|
|
@@ -79,7 +89,7 @@ Reading documentation does not grant permission to mutate production. CLI and Se
|
|
|
79
89
|
For SDK integration or telemetry changes, determine the actual deployment environment and runtime platform, then run:
|
|
80
90
|
|
|
81
91
|
```bash
|
|
82
|
-
gamealgo integration get-plan --environment <domestic|overseas> --platform <ios|android|maker|
|
|
92
|
+
gamealgo integration get-plan --environment <domestic|overseas> --platform <ios|android|maker|web> --json
|
|
83
93
|
```
|
|
84
94
|
|
|
85
95
|
Inspect the existing project before editing. Reuse existing SDK wrappers, user identity, lifecycle, event, advertising, purchase, and attribution flows.
|
|
@@ -166,11 +176,11 @@ Before delivery, inspect changed client code for Variant or assignment-identity
|
|
|
166
176
|
|
|
167
177
|
## Preserve Experiment Platform Scope
|
|
168
178
|
|
|
169
|
-
A Strategy key is shared by the game and keeps a global fallback default, while every Run belongs to exactly one runtime platform: `ios`, `android`, or `
|
|
179
|
+
A Strategy key is shared by the game and keeps a global fallback default, while every Run belongs to exactly one runtime platform: `ios`, `android`, `maker`, or `web`. A promoted Run updates only its own platform default. Other platform defaults remain unchanged, and a platform without an explicit default continues to use the global fallback.
|
|
170
180
|
|
|
171
181
|
- When a Run includes `variantId: default`, the Server snapshots the effective default for that Run's platform, falling back to the global Strategy default only when that platform has no promoted default.
|
|
172
182
|
- `experiment run promote` needs no platform flag because the Run records its platform.
|
|
173
|
-
- When rolling a platform back to a global historical version, pass `experiment strategy rollback ... --platform <ios|android|
|
|
183
|
+
- When rolling a platform back to a global historical version, pass `experiment strategy rollback ... --platform <ios|android|maker|web>` so the global version is copied into that platform default rather than replacing the global fallback.
|
|
174
184
|
- Treat `experiment strategy publish` and `experiment strategy default` as global-fallback operations; they do not overwrite existing promoted platform defaults.
|
|
175
185
|
|
|
176
186
|
## Require Explicit Approval Before Publishing Experiments
|