@hot-updater/plugin-insights 1.0.0-rc.20
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/LICENSE +33 -0
- package/README.md +24 -0
- package/client/package.json +7 -0
- package/dist/client/eventId.cjs +181 -0
- package/dist/client/eventId.mjs +179 -0
- package/dist/client/index.cjs +234 -0
- package/dist/client/index.d.cts +42 -0
- package/dist/client/index.d.mts +42 -0
- package/dist/client/index.mjs +233 -0
- package/dist/client/sender.cjs +103 -0
- package/dist/client/sender.mjs +103 -0
- package/dist/client/state.cjs +121 -0
- package/dist/client/state.mjs +119 -0
- package/dist/internal.cjs +9 -0
- package/dist/internal.d.cts +5 -0
- package/dist/internal.d.mts +5 -0
- package/dist/internal.mjs +5 -0
- package/dist/server/contract.cjs +193 -0
- package/dist/server/contract.mjs +189 -0
- package/dist/server/domain.d.cts +149 -0
- package/dist/server/domain.d.mts +149 -0
- package/dist/server/errors.cjs +15 -0
- package/dist/server/errors.d.cts +6 -0
- package/dist/server/errors.d.mts +6 -0
- package/dist/server/errors.mjs +14 -0
- package/dist/server/eventInput.cjs +284 -0
- package/dist/server/eventInput.d.cts +6 -0
- package/dist/server/eventInput.d.mts +6 -0
- package/dist/server/eventInput.mjs +282 -0
- package/dist/server/eventRow.cjs +49 -0
- package/dist/server/eventRow.d.cts +64 -0
- package/dist/server/eventRow.d.mts +64 -0
- package/dist/server/eventRow.mjs +47 -0
- package/dist/server/index.cjs +128 -0
- package/dist/server/index.d.cts +394 -0
- package/dist/server/index.d.mts +394 -0
- package/dist/server/index.mjs +121 -0
- package/dist/server/model.cjs +17 -0
- package/dist/server/model.d.cts +10 -0
- package/dist/server/model.d.mts +10 -0
- package/dist/server/model.mjs +17 -0
- package/dist/server/modelTypes.d.cts +176 -0
- package/dist/server/modelTypes.d.mts +176 -0
- package/dist/server/overview.cjs +109 -0
- package/dist/server/overview.d.cts +15 -0
- package/dist/server/overview.d.mts +15 -0
- package/dist/server/overview.mjs +107 -0
- package/dist/server/provider.cjs +375 -0
- package/dist/server/provider.d.cts +6 -0
- package/dist/server/provider.d.mts +6 -0
- package/dist/server/provider.mjs +374 -0
- package/dist/server/queryInput.cjs +93 -0
- package/dist/server/queryInput.mjs +91 -0
- package/dist/server/reads.cjs +681 -0
- package/dist/server/reads.d.cts +60 -0
- package/dist/server/reads.d.mts +60 -0
- package/dist/server/reads.mjs +674 -0
- package/dist/server/recordEvent.cjs +319 -0
- package/dist/server/recordEvent.d.cts +17 -0
- package/dist/server/recordEvent.d.mts +17 -0
- package/dist/server/recordEvent.mjs +316 -0
- package/dist/server/routes.cjs +96 -0
- package/dist/server/routes.d.cts +36 -0
- package/dist/server/routes.d.mts +36 -0
- package/dist/server/routes.mjs +95 -0
- package/dist/server/schema.cjs +489 -0
- package/dist/server/schema.d.cts +706 -0
- package/dist/server/schema.d.mts +706 -0
- package/dist/server/schema.mjs +483 -0
- package/dist/server/testing/eventRow.d.mts +64 -0
- package/dist/server/testing/expectInsightsIndex.mjs +18 -0
- package/dist/server/testing/fixtures.d.mts +8 -0
- package/dist/server/testing/fixtures.mjs +25 -0
- package/dist/server/testing/index.d.mts +24 -0
- package/dist/server/testing/index.mjs +25 -0
- package/dist/server/testing/insightsModelTests.mjs +714 -0
- package/dist/server/testing/modelTypes.d.mts +176 -0
- package/dist/server/testing/setupInsightsHttpTestSuite.d.mts +11 -0
- package/dist/server/testing/setupInsightsHttpTestSuite.mjs +107 -0
- package/dist/server/testing/setupInsightsModelTestSuite.d.mts +11 -0
- package/dist/server/testing/setupInsightsModelTestSuite.mjs +31 -0
- package/dist/server/testing/types.d.mts +26 -0
- package/dist/server/types.d.cts +33 -0
- package/dist/server/types.d.mts +33 -0
- package/package.json +68 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { InsightsBadRequestError, InsightsPayloadTooLargeError } from "./errors.mjs";
|
|
2
|
+
import { createUUIDv7, isUUIDv7 } from "@hot-updater/plugin-core";
|
|
3
|
+
//#region src/server/eventInput.ts
|
|
4
|
+
const MAX_EVENT_STRING_LENGTH = 1024;
|
|
5
|
+
const MAX_IDENTITY_LENGTH = 255;
|
|
6
|
+
const EVENT_BODY_MAX_BYTES = 16384;
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function requireStringField(payload, key) {
|
|
11
|
+
const value = payload[key];
|
|
12
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_EVENT_STRING_LENGTH || new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(value)) !== value) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function requireNullableStringField(payload, key) {
|
|
16
|
+
if (payload[key] === null) return null;
|
|
17
|
+
return requireStringField(payload, key);
|
|
18
|
+
}
|
|
19
|
+
function requireIdentityField(payload, key) {
|
|
20
|
+
const value = requireStringField(payload, key);
|
|
21
|
+
if (value.length > MAX_IDENTITY_LENGTH) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The report's idempotency key, when the client sends one: it creates the
|
|
26
|
+
* UUIDv7 once and repeats it on every retry of that report.
|
|
27
|
+
*/
|
|
28
|
+
function readEventId(payload) {
|
|
29
|
+
const value = payload.eventId;
|
|
30
|
+
if (value === void 0) return void 0;
|
|
31
|
+
if (!isUUIDv7(value)) throw new InsightsBadRequestError("Invalid event field: eventId");
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/** One value of an open set: any other value reads as `unknown`. */
|
|
35
|
+
const oneOf = (values, value) => values.includes(value) ? value : "unknown";
|
|
36
|
+
const STAGES = [
|
|
37
|
+
"check",
|
|
38
|
+
"download",
|
|
39
|
+
"install"
|
|
40
|
+
];
|
|
41
|
+
const REASONS = [
|
|
42
|
+
"network",
|
|
43
|
+
"http",
|
|
44
|
+
"invalid_response",
|
|
45
|
+
"hash_mismatch",
|
|
46
|
+
"signature",
|
|
47
|
+
"patch",
|
|
48
|
+
"extract",
|
|
49
|
+
"storage"
|
|
50
|
+
];
|
|
51
|
+
const RESOURCES = [
|
|
52
|
+
"catalog",
|
|
53
|
+
"artifact",
|
|
54
|
+
"manifest",
|
|
55
|
+
"file",
|
|
56
|
+
"patch",
|
|
57
|
+
"archive"
|
|
58
|
+
];
|
|
59
|
+
const TRANSPORTS = [
|
|
60
|
+
"timeout",
|
|
61
|
+
"dns",
|
|
62
|
+
"tls",
|
|
63
|
+
"connection",
|
|
64
|
+
"offline",
|
|
65
|
+
"cancelled"
|
|
66
|
+
];
|
|
67
|
+
const DELIVERIES = [
|
|
68
|
+
"patch",
|
|
69
|
+
"manifest",
|
|
70
|
+
"archive"
|
|
71
|
+
];
|
|
72
|
+
/** A storage error code or an Android exit reason: letters, digits, and `._-`. */
|
|
73
|
+
const CODE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
74
|
+
const readCode = (value) => typeof value === "string" && CODE.test(value) ? value : void 0;
|
|
75
|
+
const readMetadata = (payload) => isRecord(payload.metadata) ? payload.metadata : {};
|
|
76
|
+
/**
|
|
77
|
+
* What an UPDATE_FAILED report says failed. It never refuses the report: an
|
|
78
|
+
* unknown value of a set reads as `unknown`, a missing or malformed failure
|
|
79
|
+
* as an unknown stage and reason, and a malformed optional field is left out,
|
|
80
|
+
* as is a null one.
|
|
81
|
+
*/
|
|
82
|
+
function readFailure(value) {
|
|
83
|
+
const failure = isRecord(value) ? value : {};
|
|
84
|
+
const httpStatus = failure.httpStatus;
|
|
85
|
+
const originCode = readCode(failure.originCode);
|
|
86
|
+
const previousProcessExit = readCode(failure.previousProcessExit);
|
|
87
|
+
return {
|
|
88
|
+
stage: oneOf(STAGES, failure.stage),
|
|
89
|
+
reason: oneOf(REASONS, failure.reason),
|
|
90
|
+
...failure.resource == null ? {} : { resource: oneOf(RESOURCES, failure.resource) },
|
|
91
|
+
...Number.isSafeInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {},
|
|
92
|
+
...failure.transport == null ? {} : { transport: oneOf(TRANSPORTS, failure.transport) },
|
|
93
|
+
...originCode === void 0 ? {} : { originCode },
|
|
94
|
+
...previousProcessExit === void 0 ? {} : { previousProcessExit }
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function readBoundedText(request) {
|
|
98
|
+
const contentLength = request.headers.get("content-length");
|
|
99
|
+
const declaredByteLength = Number(contentLength);
|
|
100
|
+
if (contentLength !== null && Number.isSafeInteger(declaredByteLength) && declaredByteLength > 16384) throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
101
|
+
if (request.body === null) return "";
|
|
102
|
+
const reader = request.body.getReader();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
let byteLength = 0;
|
|
105
|
+
let text = "";
|
|
106
|
+
while (true) {
|
|
107
|
+
const result = await reader.read();
|
|
108
|
+
if (result.done) break;
|
|
109
|
+
byteLength += result.value.byteLength;
|
|
110
|
+
if (byteLength > 16384) {
|
|
111
|
+
await reader.cancel();
|
|
112
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
113
|
+
}
|
|
114
|
+
text += decoder.decode(result.value, { stream: true });
|
|
115
|
+
}
|
|
116
|
+
return text + decoder.decode();
|
|
117
|
+
}
|
|
118
|
+
async function parseJson(request) {
|
|
119
|
+
const text = await readBoundedText(request);
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(text);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof SyntaxError) throw new InsightsBadRequestError("Invalid event payload");
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function requireEvent(payload) {
|
|
128
|
+
if (!isRecord(payload)) throw new InsightsBadRequestError("Invalid event payload");
|
|
129
|
+
const platform = requireStringField(payload, "platform");
|
|
130
|
+
if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid event field: platform");
|
|
131
|
+
const eventId = readEventId(payload);
|
|
132
|
+
const base = {
|
|
133
|
+
...eventId === void 0 ? {} : { eventId },
|
|
134
|
+
installId: requireIdentityField(payload, "installId"),
|
|
135
|
+
toBundleId: requireStringField(payload, "toBundleId"),
|
|
136
|
+
...payload.userId === void 0 ? {} : { userId: requireIdentityField(payload, "userId") },
|
|
137
|
+
platform,
|
|
138
|
+
appVersion: requireStringField(payload, "appVersion"),
|
|
139
|
+
channel: requireStringField(payload, "channel"),
|
|
140
|
+
cohort: requireStringField(payload, "cohort"),
|
|
141
|
+
fingerprintHash: requireNullableStringField(payload, "fingerprintHash"),
|
|
142
|
+
sdkVersion: payload.sdkVersion === void 0 ? null : requireNullableStringField(payload, "sdkVersion"),
|
|
143
|
+
fromReleaseId: requireNullableStringField(payload, "fromReleaseId"),
|
|
144
|
+
toReleaseId: requireNullableStringField(payload, "toReleaseId")
|
|
145
|
+
};
|
|
146
|
+
const type = requireStringField(payload, "type");
|
|
147
|
+
const movement = () => {
|
|
148
|
+
const updateStrategy = requireStringField(payload, "updateStrategy");
|
|
149
|
+
if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") throw new InsightsBadRequestError("Invalid event field: updateStrategy");
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
fromBundleId: requireStringField(payload, "fromBundleId"),
|
|
153
|
+
updateStrategy
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const metadata = readMetadata(payload);
|
|
157
|
+
switch (type) {
|
|
158
|
+
case "UPDATE_DOWNLOADED": {
|
|
159
|
+
const delivery = metadata.delivery;
|
|
160
|
+
const read = {
|
|
161
|
+
...delivery == null ? {} : { delivery: oneOf(DELIVERIES, delivery) },
|
|
162
|
+
...metadata.patchFallback === true ? { patchFallback: true } : {}
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
...movement(),
|
|
166
|
+
type,
|
|
167
|
+
...Object.keys(read).length === 0 ? {} : { metadata: read }
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case "UPDATE_APPLIED": return {
|
|
171
|
+
...movement(),
|
|
172
|
+
type
|
|
173
|
+
};
|
|
174
|
+
case "RECOVERED": {
|
|
175
|
+
const previousProcessExit = readCode(metadata.previousProcessExit);
|
|
176
|
+
return {
|
|
177
|
+
...movement(),
|
|
178
|
+
type,
|
|
179
|
+
...previousProcessExit === void 0 ? {} : { metadata: { previousProcessExit } }
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
case "UPDATE_FAILED": return {
|
|
183
|
+
...movement(),
|
|
184
|
+
type,
|
|
185
|
+
metadata: { failure: readFailure(metadata.failure) }
|
|
186
|
+
};
|
|
187
|
+
case "UNCHANGED":
|
|
188
|
+
if (payload.fromBundleId !== null || payload.updateStrategy !== null) throw new InsightsBadRequestError("Invalid unchanged event shape");
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
type,
|
|
192
|
+
fromBundleId: null,
|
|
193
|
+
updateStrategy: null
|
|
194
|
+
};
|
|
195
|
+
default: throw new InsightsBadRequestError("Invalid event field: type");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function parseBundleEventRequest(request) {
|
|
199
|
+
return requireEvent(await parseJson(request));
|
|
200
|
+
}
|
|
201
|
+
function createBundleEventRow(input) {
|
|
202
|
+
input = requireEvent(input);
|
|
203
|
+
const base = {
|
|
204
|
+
app_version: input.appVersion,
|
|
205
|
+
channel: input.channel,
|
|
206
|
+
from_release_id: input.fromReleaseId,
|
|
207
|
+
id: input.eventId ?? createUUIDv7(),
|
|
208
|
+
install_id: input.installId,
|
|
209
|
+
platform: input.platform,
|
|
210
|
+
received_at_ms: Date.now(),
|
|
211
|
+
to_bundle_id: input.toBundleId,
|
|
212
|
+
to_release_id: input.toReleaseId,
|
|
213
|
+
user_id: input.userId ?? null
|
|
214
|
+
};
|
|
215
|
+
const metadata = {
|
|
216
|
+
cohort: input.cohort,
|
|
217
|
+
fingerprint_hash: input.fingerprintHash,
|
|
218
|
+
sdk_version: input.sdkVersion ?? null,
|
|
219
|
+
update_strategy: input.updateStrategy
|
|
220
|
+
};
|
|
221
|
+
switch (input.type) {
|
|
222
|
+
case "UPDATE_DOWNLOADED": {
|
|
223
|
+
const { delivery, patchFallback } = input.metadata ?? {};
|
|
224
|
+
return {
|
|
225
|
+
...base,
|
|
226
|
+
from_bundle_id: input.fromBundleId,
|
|
227
|
+
type: input.type,
|
|
228
|
+
metadata: {
|
|
229
|
+
...metadata,
|
|
230
|
+
...delivery === void 0 ? {} : { delivery },
|
|
231
|
+
...patchFallback ? { patch_fallback: true } : {}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
case "UPDATE_APPLIED": return {
|
|
236
|
+
...base,
|
|
237
|
+
from_bundle_id: input.fromBundleId,
|
|
238
|
+
type: input.type,
|
|
239
|
+
metadata
|
|
240
|
+
};
|
|
241
|
+
case "RECOVERED": {
|
|
242
|
+
const exit = input.metadata?.previousProcessExit;
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
from_bundle_id: input.fromBundleId,
|
|
246
|
+
type: input.type,
|
|
247
|
+
metadata: {
|
|
248
|
+
...metadata,
|
|
249
|
+
...exit === void 0 ? {} : { previous_process_exit: exit }
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
case "UPDATE_FAILED": {
|
|
254
|
+
const { failure } = input.metadata;
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
from_bundle_id: input.fromBundleId,
|
|
258
|
+
type: input.type,
|
|
259
|
+
metadata: {
|
|
260
|
+
...metadata,
|
|
261
|
+
failure: {
|
|
262
|
+
stage: failure.stage,
|
|
263
|
+
reason: failure.reason,
|
|
264
|
+
...failure.resource === void 0 ? {} : { resource: failure.resource },
|
|
265
|
+
...failure.httpStatus === void 0 ? {} : { http_status: failure.httpStatus },
|
|
266
|
+
...failure.transport === void 0 ? {} : { transport: failure.transport },
|
|
267
|
+
...failure.originCode === void 0 ? {} : { origin_code: failure.originCode },
|
|
268
|
+
...failure.previousProcessExit === void 0 ? {} : { previous_process_exit: failure.previousProcessExit }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
case "UNCHANGED": return {
|
|
274
|
+
...base,
|
|
275
|
+
from_bundle_id: null,
|
|
276
|
+
type: input.type,
|
|
277
|
+
metadata
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
export { EVENT_BODY_MAX_BYTES, createBundleEventRow, parseBundleEventRequest };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
|
|
2
|
+
let _hot_updater_plugin_core_internal = require("@hot-updater/plugin-core/internal");
|
|
3
|
+
//#region src/server/eventRow.ts
|
|
4
|
+
const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
|
|
5
|
+
const isString = (value) => typeof value === "string";
|
|
6
|
+
/** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
|
|
7
|
+
const isDatabaseBundleEventFailure = (value) => (0, _hot_updater_plugin_core_internal.isDatabaseJsonObject)(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "previous_process_exit", isString);
|
|
8
|
+
const isDatabaseBundleEventMetadata = (value) => (0, _hot_updater_plugin_core_internal.isDatabaseJsonObject)(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "previous_process_exit", isString);
|
|
9
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
|
|
11
|
+
const isTextOrNull = (value) => value === null || typeof value === "string";
|
|
12
|
+
/** Each bundle event field and what it holds. */
|
|
13
|
+
const BUNDLE_EVENT_FIELDS = {
|
|
14
|
+
id: (value) => typeof value === "string",
|
|
15
|
+
type: (value) => value === "UPDATE_DOWNLOADED" || value === "UPDATE_APPLIED" || value === "RECOVERED" || value === "UPDATE_FAILED" || value === "UNCHANGED",
|
|
16
|
+
install_id: isIdentity,
|
|
17
|
+
user_id: (value) => value === null || isIdentity(value),
|
|
18
|
+
from_bundle_id: isTextOrNull,
|
|
19
|
+
from_release_id: isTextOrNull,
|
|
20
|
+
to_release_id: isTextOrNull,
|
|
21
|
+
to_bundle_id: (value) => typeof value === "string",
|
|
22
|
+
platform: (value) => value === "ios" || value === "android",
|
|
23
|
+
app_version: (value) => typeof value === "string",
|
|
24
|
+
channel: (value) => typeof value === "string",
|
|
25
|
+
metadata: isDatabaseBundleEventMetadata,
|
|
26
|
+
received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
27
|
+
};
|
|
28
|
+
const updateStrategyOf = (row) => isRecord(row.metadata) ? row.metadata.update_strategy : void 0;
|
|
29
|
+
/**
|
|
30
|
+
* A movement, or a failed update, names the bundle it left and how the
|
|
31
|
+
* device updates, and a failed update says what failed; an unchanged report
|
|
32
|
+
* names neither.
|
|
33
|
+
*/
|
|
34
|
+
const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord(row.metadata) && isRecord(row.metadata.failure)) || row.type === "UNCHANGED" && row.from_bundle_id === null && updateStrategyOf(row) === null;
|
|
35
|
+
/**
|
|
36
|
+
* Throws unless `row` has exactly the bundle event fields, each of its type,
|
|
37
|
+
* and the invariants of its event type: `invalid-field` for an unknown field,
|
|
38
|
+
* `invalid-data` for anything else.
|
|
39
|
+
*/
|
|
40
|
+
const validateBundleEventFields = (row) => {
|
|
41
|
+
if (!isRecord(row)) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-data");
|
|
42
|
+
for (const field of Object.keys(row)) if (!Object.hasOwn(BUNDLE_EVENT_FIELDS, field)) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-field");
|
|
43
|
+
for (const [field, valid] of Object.entries(BUNDLE_EVENT_FIELDS)) if (!Object.hasOwn(row, field) || !valid(row[field])) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-data");
|
|
44
|
+
if (!hasEventInvariants(row)) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-data");
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
exports.isDatabaseBundleEventMetadata = isDatabaseBundleEventMetadata;
|
|
48
|
+
exports.isRecord = isRecord;
|
|
49
|
+
exports.validateBundleEventFields = validateBundleEventFields;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DatabaseJsonObject, Platform } from "@hot-updater/plugin-core";
|
|
2
|
+
//#region src/server/eventRow.d.ts
|
|
3
|
+
/** Where an update failed, and what failed; open sets, `unknown` for any other value. */
|
|
4
|
+
type BundleEventFailureStage = "check" | "download" | "install" | "unknown";
|
|
5
|
+
type BundleEventFailureReason = "network" | "http" | "invalid_response" | "hash_mismatch" | "signature" | "patch" | "extract" | "storage" | "unknown";
|
|
6
|
+
/** An `UPDATE_FAILED` report's failure, as stored. */
|
|
7
|
+
type BundleEventFailure = DatabaseJsonObject & {
|
|
8
|
+
readonly stage: BundleEventFailureStage;
|
|
9
|
+
readonly reason: BundleEventFailureReason;
|
|
10
|
+
/** What the client was fetching or writing. */
|
|
11
|
+
readonly resource?: "catalog" | "artifact" | "manifest" | "file" | "patch" | "archive" | "unknown";
|
|
12
|
+
readonly http_status?: number;
|
|
13
|
+
/** How the connection failed, when it did. */
|
|
14
|
+
readonly transport?: "timeout" | "dns" | "tls" | "connection" | "offline" | "cancelled" | "unknown";
|
|
15
|
+
/** The storage origin's error code, such as `ExpiredToken`. */
|
|
16
|
+
readonly origin_code?: string;
|
|
17
|
+
/** Android's `ApplicationExitInfo` reason for the process before this one. */
|
|
18
|
+
readonly previous_process_exit?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Ancillary report data; queryable identity and lifecycle fields stay on the row. */
|
|
21
|
+
type DatabaseBundleEventMetadata = DatabaseJsonObject & {
|
|
22
|
+
readonly cohort: string;
|
|
23
|
+
readonly update_strategy: "fingerprint" | "appVersion" | null;
|
|
24
|
+
readonly fingerprint_hash: string | null;
|
|
25
|
+
readonly sdk_version: string | null;
|
|
26
|
+
/** `UPDATE_FAILED`: what failed. */
|
|
27
|
+
readonly failure?: BundleEventFailure;
|
|
28
|
+
/** `UPDATE_DOWNLOADED`: how the bundle arrived. */
|
|
29
|
+
readonly delivery?: "patch" | "manifest" | "archive" | "unknown";
|
|
30
|
+
/** `UPDATE_DOWNLOADED`: a patch failed and the full files came instead. */
|
|
31
|
+
readonly patch_fallback?: boolean;
|
|
32
|
+
/** `RECOVERED`: Android's `ApplicationExitInfo` reason for the crashed process. */
|
|
33
|
+
readonly previous_process_exit?: string;
|
|
34
|
+
};
|
|
35
|
+
type BundleEventRowBase = {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly install_id: string;
|
|
38
|
+
readonly user_id: string | null;
|
|
39
|
+
readonly from_release_id: string | null;
|
|
40
|
+
readonly to_release_id: string | null;
|
|
41
|
+
readonly to_bundle_id: string;
|
|
42
|
+
readonly platform: Platform;
|
|
43
|
+
readonly app_version: string;
|
|
44
|
+
readonly channel: string;
|
|
45
|
+
readonly metadata: DatabaseBundleEventMetadata;
|
|
46
|
+
readonly received_at_ms: number;
|
|
47
|
+
};
|
|
48
|
+
type BundleEventRow = BundleEventRowBase & ({
|
|
49
|
+
readonly type: "UPDATE_DOWNLOADED";
|
|
50
|
+
readonly from_bundle_id: string;
|
|
51
|
+
} | {
|
|
52
|
+
readonly type: "UPDATE_APPLIED" | "RECOVERED";
|
|
53
|
+
readonly from_bundle_id: string;
|
|
54
|
+
} |
|
|
55
|
+
/** An update that failed: `from` is the running bundle, `to` the target (for a check, the running bundle). */
|
|
56
|
+
{
|
|
57
|
+
readonly type: "UPDATE_FAILED";
|
|
58
|
+
readonly from_bundle_id: string;
|
|
59
|
+
} | {
|
|
60
|
+
readonly type: "UNCHANGED";
|
|
61
|
+
readonly from_bundle_id: null;
|
|
62
|
+
});
|
|
63
|
+
//#endregion
|
|
64
|
+
export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DatabaseJsonObject, Platform } from "@hot-updater/plugin-core";
|
|
2
|
+
//#region src/server/eventRow.d.ts
|
|
3
|
+
/** Where an update failed, and what failed; open sets, `unknown` for any other value. */
|
|
4
|
+
type BundleEventFailureStage = "check" | "download" | "install" | "unknown";
|
|
5
|
+
type BundleEventFailureReason = "network" | "http" | "invalid_response" | "hash_mismatch" | "signature" | "patch" | "extract" | "storage" | "unknown";
|
|
6
|
+
/** An `UPDATE_FAILED` report's failure, as stored. */
|
|
7
|
+
type BundleEventFailure = DatabaseJsonObject & {
|
|
8
|
+
readonly stage: BundleEventFailureStage;
|
|
9
|
+
readonly reason: BundleEventFailureReason;
|
|
10
|
+
/** What the client was fetching or writing. */
|
|
11
|
+
readonly resource?: "catalog" | "artifact" | "manifest" | "file" | "patch" | "archive" | "unknown";
|
|
12
|
+
readonly http_status?: number;
|
|
13
|
+
/** How the connection failed, when it did. */
|
|
14
|
+
readonly transport?: "timeout" | "dns" | "tls" | "connection" | "offline" | "cancelled" | "unknown";
|
|
15
|
+
/** The storage origin's error code, such as `ExpiredToken`. */
|
|
16
|
+
readonly origin_code?: string;
|
|
17
|
+
/** Android's `ApplicationExitInfo` reason for the process before this one. */
|
|
18
|
+
readonly previous_process_exit?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Ancillary report data; queryable identity and lifecycle fields stay on the row. */
|
|
21
|
+
type DatabaseBundleEventMetadata = DatabaseJsonObject & {
|
|
22
|
+
readonly cohort: string;
|
|
23
|
+
readonly update_strategy: "fingerprint" | "appVersion" | null;
|
|
24
|
+
readonly fingerprint_hash: string | null;
|
|
25
|
+
readonly sdk_version: string | null;
|
|
26
|
+
/** `UPDATE_FAILED`: what failed. */
|
|
27
|
+
readonly failure?: BundleEventFailure;
|
|
28
|
+
/** `UPDATE_DOWNLOADED`: how the bundle arrived. */
|
|
29
|
+
readonly delivery?: "patch" | "manifest" | "archive" | "unknown";
|
|
30
|
+
/** `UPDATE_DOWNLOADED`: a patch failed and the full files came instead. */
|
|
31
|
+
readonly patch_fallback?: boolean;
|
|
32
|
+
/** `RECOVERED`: Android's `ApplicationExitInfo` reason for the crashed process. */
|
|
33
|
+
readonly previous_process_exit?: string;
|
|
34
|
+
};
|
|
35
|
+
type BundleEventRowBase = {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly install_id: string;
|
|
38
|
+
readonly user_id: string | null;
|
|
39
|
+
readonly from_release_id: string | null;
|
|
40
|
+
readonly to_release_id: string | null;
|
|
41
|
+
readonly to_bundle_id: string;
|
|
42
|
+
readonly platform: Platform;
|
|
43
|
+
readonly app_version: string;
|
|
44
|
+
readonly channel: string;
|
|
45
|
+
readonly metadata: DatabaseBundleEventMetadata;
|
|
46
|
+
readonly received_at_ms: number;
|
|
47
|
+
};
|
|
48
|
+
type BundleEventRow = BundleEventRowBase & ({
|
|
49
|
+
readonly type: "UPDATE_DOWNLOADED";
|
|
50
|
+
readonly from_bundle_id: string;
|
|
51
|
+
} | {
|
|
52
|
+
readonly type: "UPDATE_APPLIED" | "RECOVERED";
|
|
53
|
+
readonly from_bundle_id: string;
|
|
54
|
+
} |
|
|
55
|
+
/** An update that failed: `from` is the running bundle, `to` the target (for a check, the running bundle). */
|
|
56
|
+
{
|
|
57
|
+
readonly type: "UPDATE_FAILED";
|
|
58
|
+
readonly from_bundle_id: string;
|
|
59
|
+
} | {
|
|
60
|
+
readonly type: "UNCHANGED";
|
|
61
|
+
readonly from_bundle_id: null;
|
|
62
|
+
});
|
|
63
|
+
//#endregion
|
|
64
|
+
export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { DatabaseAdapterInputError } from "@hot-updater/plugin-core";
|
|
2
|
+
import { isDatabaseJsonObject } from "@hot-updater/plugin-core/internal";
|
|
3
|
+
//#region src/server/eventRow.ts
|
|
4
|
+
const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
|
|
5
|
+
const isString = (value) => typeof value === "string";
|
|
6
|
+
/** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
|
|
7
|
+
const isDatabaseBundleEventFailure = (value) => isDatabaseJsonObject(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "previous_process_exit", isString);
|
|
8
|
+
const isDatabaseBundleEventMetadata = (value) => isDatabaseJsonObject(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "previous_process_exit", isString);
|
|
9
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
|
|
11
|
+
const isTextOrNull = (value) => value === null || typeof value === "string";
|
|
12
|
+
/** Each bundle event field and what it holds. */
|
|
13
|
+
const BUNDLE_EVENT_FIELDS = {
|
|
14
|
+
id: (value) => typeof value === "string",
|
|
15
|
+
type: (value) => value === "UPDATE_DOWNLOADED" || value === "UPDATE_APPLIED" || value === "RECOVERED" || value === "UPDATE_FAILED" || value === "UNCHANGED",
|
|
16
|
+
install_id: isIdentity,
|
|
17
|
+
user_id: (value) => value === null || isIdentity(value),
|
|
18
|
+
from_bundle_id: isTextOrNull,
|
|
19
|
+
from_release_id: isTextOrNull,
|
|
20
|
+
to_release_id: isTextOrNull,
|
|
21
|
+
to_bundle_id: (value) => typeof value === "string",
|
|
22
|
+
platform: (value) => value === "ios" || value === "android",
|
|
23
|
+
app_version: (value) => typeof value === "string",
|
|
24
|
+
channel: (value) => typeof value === "string",
|
|
25
|
+
metadata: isDatabaseBundleEventMetadata,
|
|
26
|
+
received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
27
|
+
};
|
|
28
|
+
const updateStrategyOf = (row) => isRecord(row.metadata) ? row.metadata.update_strategy : void 0;
|
|
29
|
+
/**
|
|
30
|
+
* A movement, or a failed update, names the bundle it left and how the
|
|
31
|
+
* device updates, and a failed update says what failed; an unchanged report
|
|
32
|
+
* names neither.
|
|
33
|
+
*/
|
|
34
|
+
const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord(row.metadata) && isRecord(row.metadata.failure)) || row.type === "UNCHANGED" && row.from_bundle_id === null && updateStrategyOf(row) === null;
|
|
35
|
+
/**
|
|
36
|
+
* Throws unless `row` has exactly the bundle event fields, each of its type,
|
|
37
|
+
* and the invariants of its event type: `invalid-field` for an unknown field,
|
|
38
|
+
* `invalid-data` for anything else.
|
|
39
|
+
*/
|
|
40
|
+
const validateBundleEventFields = (row) => {
|
|
41
|
+
if (!isRecord(row)) throw new DatabaseAdapterInputError("invalid-data");
|
|
42
|
+
for (const field of Object.keys(row)) if (!Object.hasOwn(BUNDLE_EVENT_FIELDS, field)) throw new DatabaseAdapterInputError("invalid-field");
|
|
43
|
+
for (const [field, valid] of Object.entries(BUNDLE_EVENT_FIELDS)) if (!Object.hasOwn(row, field) || !valid(row[field])) throw new DatabaseAdapterInputError("invalid-data");
|
|
44
|
+
if (!hasEventInvariants(row)) throw new DatabaseAdapterInputError("invalid-data");
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { isDatabaseBundleEventMetadata, isRecord, validateBundleEventFields };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_model = require("./model.cjs");
|
|
3
|
+
const require_provider = require("./provider.cjs");
|
|
4
|
+
const require_schema = require("./schema.cjs");
|
|
5
|
+
const require_recordEvent = require("./recordEvent.cjs");
|
|
6
|
+
const require_reads = require("./reads.cjs");
|
|
7
|
+
const require_routes = require("./routes.cjs");
|
|
8
|
+
let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
|
|
9
|
+
let _hot_updater_plugin_core_internal = require("@hot-updater/plugin-core/internal");
|
|
10
|
+
let _hot_updater_plugin_core_server_plugin = require("@hot-updater/plugin-core/server-plugin");
|
|
11
|
+
//#region src/server/index.ts
|
|
12
|
+
const createInsightsApi = (db, now, retention) => ({
|
|
13
|
+
/** How long this plugin keeps its rows. */
|
|
14
|
+
retention,
|
|
15
|
+
/** Records one validated event row; a repeated id changes nothing. */
|
|
16
|
+
recordEvent: (event) => require_recordEvent.recordEvent(db, event),
|
|
17
|
+
listEvents: (input) => require_reads.listEvents(db, input),
|
|
18
|
+
findLatestEvents: (input) => require_reads.findLatestEvents(db, input),
|
|
19
|
+
countLatestEvents: (input) => require_reads.countLatestEvents(db, input),
|
|
20
|
+
countEvents: (input) => require_reads.countEvents(db, input),
|
|
21
|
+
getReleaseActivity: (input) => require_reads.getReleaseActivity(db, input, now, retention),
|
|
22
|
+
getAppUsage: (input) => require_reads.getAppUsage(db, input, now, retention),
|
|
23
|
+
/** A release's or a channel's update failures, and their breakdown over a time range. */
|
|
24
|
+
getUpdateFailures: (input) => require_reads.getUpdateFailures(db, input, now, retention)
|
|
25
|
+
});
|
|
26
|
+
/** `GET /failures`'s query: a scope, an optional release, and a time range or none. */
|
|
27
|
+
const readFailuresQuery = (url) => {
|
|
28
|
+
const query = url.searchParams;
|
|
29
|
+
const time = (name) => {
|
|
30
|
+
const value = query.get(name);
|
|
31
|
+
return value === null ? void 0 : Number(value);
|
|
32
|
+
};
|
|
33
|
+
const [start, end] = [time("start"), time("end")];
|
|
34
|
+
const platform = query.get("platform");
|
|
35
|
+
const releaseId = query.get("releaseId");
|
|
36
|
+
if (platform !== "ios" && platform !== "android" || start === void 0 !== (end === void 0)) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-query");
|
|
37
|
+
return {
|
|
38
|
+
platform,
|
|
39
|
+
channel: query.get("channel") ?? "",
|
|
40
|
+
...releaseId === null ? {} : { releaseId },
|
|
41
|
+
...start === void 0 ? {} : { timeRange: {
|
|
42
|
+
start,
|
|
43
|
+
end
|
|
44
|
+
} }
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The admin route of the update failures read: a release's (`releaseId`)
|
|
49
|
+
* since its first report, or over `start`–`end`, or a channel's over a range,
|
|
50
|
+
* which covers at most 30 days.
|
|
51
|
+
*/
|
|
52
|
+
const failuresEndpoint = (api) => ({
|
|
53
|
+
access: "admin",
|
|
54
|
+
method: "GET",
|
|
55
|
+
path: "/failures",
|
|
56
|
+
handler: async (request) => {
|
|
57
|
+
try {
|
|
58
|
+
return Response.json(await api.getUpdateFailures(readFailuresQuery(new URL(request.url))), { headers: { "cache-control": "private, no-store" } });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof _hot_updater_plugin_core.DatabaseAdapterInputError) return Response.json({ error: "Send platform and channel, and releaseId or start and end in epoch milliseconds, at most 30 days apart." }, { status: 400 });
|
|
61
|
+
if (!(0, _hot_updater_plugin_core_server_plugin.isDatabaseBusyError)(error)) throw error;
|
|
62
|
+
return Response.json({ error: "Service unavailable" }, {
|
|
63
|
+
status: 503,
|
|
64
|
+
headers: { "retry-after": "5" }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
const retentionOf = ({ retention }) => {
|
|
70
|
+
const rawDays = retention?.rawDays ?? 90;
|
|
71
|
+
const dailyDays = retention?.dailyDays ?? 400;
|
|
72
|
+
const whole = (days) => Number.isSafeInteger(days) && days >= 1;
|
|
73
|
+
if (!whole(rawDays) || !whole(dailyDays) || dailyDays < rawDays) throw new _hot_updater_plugin_core_server_plugin.HotUpdaterConfigError(`insights({ retention }) takes whole days of at least 1, with dailyDays at least rawDays; got rawDays ${rawDays} and dailyDays ${dailyDays}.`);
|
|
74
|
+
return {
|
|
75
|
+
rawDays,
|
|
76
|
+
dailyDays
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Insights: bundle lifecycle events and update failures, each
|
|
81
|
+
* installation's latest event, and the counters, gauges, and sketches its
|
|
82
|
+
* reads come from, each kept for its retention. It serves `POST /events` to
|
|
83
|
+
* clients, and the Insights reads, update failures, and retention to admins.
|
|
84
|
+
*/
|
|
85
|
+
const insights = (options = {}) => {
|
|
86
|
+
const retention = retentionOf(options);
|
|
87
|
+
const plugin = (0, _hot_updater_plugin_core_server_plugin.definePlugin)({
|
|
88
|
+
id: "insights",
|
|
89
|
+
namespace: false,
|
|
90
|
+
schemaVersion: "1.2.0",
|
|
91
|
+
schema: require_schema.createInsightsSchema(retention),
|
|
92
|
+
init: ({ db, now }) => {
|
|
93
|
+
const api = createInsightsApi(db, now, retention);
|
|
94
|
+
const routes = require_routes.createInsightsRouteHandlers(require_provider.createInsightsProvider(require_model.createInsightsModel(api)));
|
|
95
|
+
return {
|
|
96
|
+
api,
|
|
97
|
+
endpoints: [
|
|
98
|
+
...require_routes.INSIGHTS_ROUTES.map(({ access, method, path, handler }) => ({
|
|
99
|
+
access,
|
|
100
|
+
method,
|
|
101
|
+
path,
|
|
102
|
+
handler: (request, params) => routes[handler](params, request)
|
|
103
|
+
})),
|
|
104
|
+
failuresEndpoint(api),
|
|
105
|
+
{
|
|
106
|
+
access: "admin",
|
|
107
|
+
method: "GET",
|
|
108
|
+
path: "/retention",
|
|
109
|
+
handler: async () => Response.json(retention)
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
cli: { clientPlugin: {
|
|
115
|
+
module: "@hot-updater/react-native/plugins/insights",
|
|
116
|
+
name: "insights"
|
|
117
|
+
} }
|
|
118
|
+
});
|
|
119
|
+
return (0, _hot_updater_plugin_core_internal.markOfficial)(plugin);
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
exports.DEFAULT_INSIGHTS_RETENTION = require_schema.DEFAULT_INSIGHTS_RETENTION;
|
|
123
|
+
exports.createInsightsModel = require_model.createInsightsModel;
|
|
124
|
+
exports.createInsightsProvider = require_provider.createInsightsProvider;
|
|
125
|
+
exports.createInsightsSchema = require_schema.createInsightsSchema;
|
|
126
|
+
exports.insights = insights;
|
|
127
|
+
exports.insightsIdentity = require_recordEvent.insightsIdentity;
|
|
128
|
+
exports.insightsSchema = require_schema.insightsSchema;
|