@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.
Files changed (85) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +24 -0
  3. package/client/package.json +7 -0
  4. package/dist/client/eventId.cjs +181 -0
  5. package/dist/client/eventId.mjs +179 -0
  6. package/dist/client/index.cjs +234 -0
  7. package/dist/client/index.d.cts +42 -0
  8. package/dist/client/index.d.mts +42 -0
  9. package/dist/client/index.mjs +233 -0
  10. package/dist/client/sender.cjs +103 -0
  11. package/dist/client/sender.mjs +103 -0
  12. package/dist/client/state.cjs +121 -0
  13. package/dist/client/state.mjs +119 -0
  14. package/dist/internal.cjs +9 -0
  15. package/dist/internal.d.cts +5 -0
  16. package/dist/internal.d.mts +5 -0
  17. package/dist/internal.mjs +5 -0
  18. package/dist/server/contract.cjs +193 -0
  19. package/dist/server/contract.mjs +189 -0
  20. package/dist/server/domain.d.cts +149 -0
  21. package/dist/server/domain.d.mts +149 -0
  22. package/dist/server/errors.cjs +15 -0
  23. package/dist/server/errors.d.cts +6 -0
  24. package/dist/server/errors.d.mts +6 -0
  25. package/dist/server/errors.mjs +14 -0
  26. package/dist/server/eventInput.cjs +284 -0
  27. package/dist/server/eventInput.d.cts +6 -0
  28. package/dist/server/eventInput.d.mts +6 -0
  29. package/dist/server/eventInput.mjs +282 -0
  30. package/dist/server/eventRow.cjs +49 -0
  31. package/dist/server/eventRow.d.cts +64 -0
  32. package/dist/server/eventRow.d.mts +64 -0
  33. package/dist/server/eventRow.mjs +47 -0
  34. package/dist/server/index.cjs +128 -0
  35. package/dist/server/index.d.cts +394 -0
  36. package/dist/server/index.d.mts +394 -0
  37. package/dist/server/index.mjs +121 -0
  38. package/dist/server/model.cjs +17 -0
  39. package/dist/server/model.d.cts +10 -0
  40. package/dist/server/model.d.mts +10 -0
  41. package/dist/server/model.mjs +17 -0
  42. package/dist/server/modelTypes.d.cts +176 -0
  43. package/dist/server/modelTypes.d.mts +176 -0
  44. package/dist/server/overview.cjs +109 -0
  45. package/dist/server/overview.d.cts +15 -0
  46. package/dist/server/overview.d.mts +15 -0
  47. package/dist/server/overview.mjs +107 -0
  48. package/dist/server/provider.cjs +375 -0
  49. package/dist/server/provider.d.cts +6 -0
  50. package/dist/server/provider.d.mts +6 -0
  51. package/dist/server/provider.mjs +374 -0
  52. package/dist/server/queryInput.cjs +93 -0
  53. package/dist/server/queryInput.mjs +91 -0
  54. package/dist/server/reads.cjs +681 -0
  55. package/dist/server/reads.d.cts +60 -0
  56. package/dist/server/reads.d.mts +60 -0
  57. package/dist/server/reads.mjs +674 -0
  58. package/dist/server/recordEvent.cjs +319 -0
  59. package/dist/server/recordEvent.d.cts +17 -0
  60. package/dist/server/recordEvent.d.mts +17 -0
  61. package/dist/server/recordEvent.mjs +316 -0
  62. package/dist/server/routes.cjs +96 -0
  63. package/dist/server/routes.d.cts +36 -0
  64. package/dist/server/routes.d.mts +36 -0
  65. package/dist/server/routes.mjs +95 -0
  66. package/dist/server/schema.cjs +489 -0
  67. package/dist/server/schema.d.cts +706 -0
  68. package/dist/server/schema.d.mts +706 -0
  69. package/dist/server/schema.mjs +483 -0
  70. package/dist/server/testing/eventRow.d.mts +64 -0
  71. package/dist/server/testing/expectInsightsIndex.mjs +18 -0
  72. package/dist/server/testing/fixtures.d.mts +8 -0
  73. package/dist/server/testing/fixtures.mjs +25 -0
  74. package/dist/server/testing/index.d.mts +24 -0
  75. package/dist/server/testing/index.mjs +25 -0
  76. package/dist/server/testing/insightsModelTests.mjs +714 -0
  77. package/dist/server/testing/modelTypes.d.mts +176 -0
  78. package/dist/server/testing/setupInsightsHttpTestSuite.d.mts +11 -0
  79. package/dist/server/testing/setupInsightsHttpTestSuite.mjs +107 -0
  80. package/dist/server/testing/setupInsightsModelTestSuite.d.mts +11 -0
  81. package/dist/server/testing/setupInsightsModelTestSuite.mjs +31 -0
  82. package/dist/server/testing/types.d.mts +26 -0
  83. package/dist/server/types.d.cts +33 -0
  84. package/dist/server/types.d.mts +33 -0
  85. package/package.json +68 -0
@@ -0,0 +1,119 @@
1
+ //#region src/client/state.ts
2
+ const DAY_MS = 864e5;
3
+ /** The UTC day of a time, as `YYYY-MM-DD`. */
4
+ const utcDay = (time) => new Date(time).toISOString().slice(0, 10);
5
+ /** The start of a time's UTC day, in milliseconds. */
6
+ const utcDayStart = (time) => Math.floor(time / DAY_MS) * DAY_MS;
7
+ const sameReport = (left, right) => left.day === right.day && left.channel === right.channel && left.appVersion === right.appVersion && left.bundleId === right.bundleId && left.releaseId === right.releaseId && left.userId === right.userId;
8
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ const isNullableString = (value) => value === null || typeof value === "string";
10
+ const parseReport = (value) => {
11
+ if (value === null) return null;
12
+ try {
13
+ const report = JSON.parse(value);
14
+ if (isRecord(report) && typeof report.day === "string" && typeof report.channel === "string" && typeof report.appVersion === "string" && typeof report.bundleId === "string" && isNullableString(report.releaseId) && isNullableString(report.userId)) return {
15
+ day: report.day,
16
+ channel: report.channel,
17
+ appVersion: report.appVersion,
18
+ bundleId: report.bundleId,
19
+ releaseId: report.releaseId,
20
+ userId: report.userId
21
+ };
22
+ } catch {}
23
+ return null;
24
+ };
25
+ const parseFailures = (value) => {
26
+ if (value === null) return null;
27
+ try {
28
+ const failures = JSON.parse(value);
29
+ if (isRecord(failures) && typeof failures.day === "string" && Array.isArray(failures.keys) && failures.keys.every((key) => typeof key === "string")) return {
30
+ day: failures.day,
31
+ keys: failures.keys
32
+ };
33
+ } catch {}
34
+ return null;
35
+ };
36
+ /**
37
+ * The Insights plugin's memory across launches, in its plugin storage. Every
38
+ * read tolerates a missing or unreadable value, and a storage failure only
39
+ * costs a report that would otherwise have been skipped.
40
+ */
41
+ const createInsightsState = (storage) => {
42
+ /** The server said Insights is off during this runtime. */
43
+ let pausedThisRuntime = false;
44
+ const read = (key) => {
45
+ try {
46
+ return storage.get(key);
47
+ } catch {
48
+ return null;
49
+ }
50
+ };
51
+ const write = (key, value) => {
52
+ try {
53
+ storage.set(key, value);
54
+ } catch {}
55
+ };
56
+ return {
57
+ /**
58
+ * Whether reporting pauses: the server answered that Insights is off
59
+ * during this runtime or in the 24 hours before `now`. A pause that
60
+ * starts after `now`, from a clock that went back, has ended.
61
+ */
62
+ isPaused(now) {
63
+ if (pausedThisRuntime) return true;
64
+ const pausedAt = Number(read("pausedAt"));
65
+ return Number.isFinite(pausedAt) && pausedAt > 0 && pausedAt <= now && now < pausedAt + DAY_MS;
66
+ },
67
+ pause(now) {
68
+ pausedThisRuntime = true;
69
+ write("pausedAt", String(now));
70
+ },
71
+ /** The server stored a report, so Insights is on. */
72
+ resume() {
73
+ if (read("pausedAt") !== null) write("pausedAt", null);
74
+ },
75
+ /** Whether a launch report repeats what the installation reported that day. */
76
+ repeatsReport(report) {
77
+ const last = parseReport(read("report"));
78
+ return last !== null && sameReport(last, report);
79
+ },
80
+ /** Records a delivered report that says what runs. */
81
+ recordReport(report) {
82
+ write("report", JSON.stringify(report));
83
+ },
84
+ /**
85
+ * Forgets the last report after a delivered download or failure, so the
86
+ * next launch reports again, as the server records it after one.
87
+ */
88
+ forgetReport() {
89
+ if (read("report") !== null) write("report", null);
90
+ },
91
+ /** Whether a failure was already reported, or refused, that day. */
92
+ hasFailure(day, key) {
93
+ const failures = parseFailures(read("failures"));
94
+ return failures?.day === day && failures.keys.includes(key);
95
+ },
96
+ recordFailure(day, key) {
97
+ const failures = parseFailures(read("failures"));
98
+ const keys = failures?.day === day ? failures.keys : [];
99
+ if (keys.includes(key)) return;
100
+ write("failures", JSON.stringify({
101
+ day,
102
+ keys: [...keys, key].slice(-32)
103
+ }));
104
+ },
105
+ readUserId() {
106
+ try {
107
+ const user = JSON.parse(read("user") ?? "null");
108
+ return isRecord(user) && typeof user.userId === "string" ? user.userId : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ },
113
+ writeUserId(userId) {
114
+ write("user", userId === null ? null : JSON.stringify({ userId }));
115
+ }
116
+ };
117
+ };
118
+ //#endregion
119
+ export { createInsightsState, utcDay, utcDayStart };
@@ -0,0 +1,9 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_errors = require("./server/errors.cjs");
3
+ const require_eventInput = require("./server/eventInput.cjs");
4
+ const require_schema = require("./server/schema.cjs");
5
+ const require_routes = require("./server/routes.cjs");
6
+ exports.DAILY_EVENTS = require_schema.DAILY_EVENTS;
7
+ exports.EVENT_BODY_MAX_BYTES = require_eventInput.EVENT_BODY_MAX_BYTES;
8
+ exports.INSIGHTS_ROUTES = require_routes.INSIGHTS_ROUTES;
9
+ exports.InsightsBadRequestError = require_errors.InsightsBadRequestError;
@@ -0,0 +1,5 @@
1
+ import { InsightsBadRequestError } from "./server/errors.cjs";
2
+ import { EVENT_BODY_MAX_BYTES } from "./server/eventInput.cjs";
3
+ import { INSIGHTS_ROUTES } from "./server/routes.cjs";
4
+ import { DAILY_EVENTS } from "./server/schema.cjs";
5
+ export { DAILY_EVENTS, EVENT_BODY_MAX_BYTES, INSIGHTS_ROUTES, InsightsBadRequestError };
@@ -0,0 +1,5 @@
1
+ import { InsightsBadRequestError } from "./server/errors.mjs";
2
+ import { EVENT_BODY_MAX_BYTES } from "./server/eventInput.mjs";
3
+ import { INSIGHTS_ROUTES } from "./server/routes.mjs";
4
+ import { DAILY_EVENTS } from "./server/schema.mjs";
5
+ export { DAILY_EVENTS, EVENT_BODY_MAX_BYTES, INSIGHTS_ROUTES, InsightsBadRequestError };
@@ -0,0 +1,5 @@
1
+ import { InsightsBadRequestError } from "./server/errors.mjs";
2
+ import { EVENT_BODY_MAX_BYTES } from "./server/eventInput.mjs";
3
+ import { DAILY_EVENTS } from "./server/schema.mjs";
4
+ import { INSIGHTS_ROUTES } from "./server/routes.mjs";
5
+ export { DAILY_EVENTS, EVENT_BODY_MAX_BYTES, INSIGHTS_ROUTES, InsightsBadRequestError };
@@ -0,0 +1,193 @@
1
+ const require_eventRow = require("./eventRow.cjs");
2
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
3
+ //#region src/server/contract.ts
4
+ const encoder = new TextEncoder();
5
+ /** Exact UTF-8 byte ordering, without case folding or Unicode normalization. */
6
+ const compareInsightsText = (left, right) => {
7
+ const a = encoder.encode(left);
8
+ const b = encoder.encode(right);
9
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) if (a[index] !== b[index]) return a[index] - b[index];
10
+ return a.length - b.length;
11
+ };
12
+ const isWellFormedText = (value) => {
13
+ for (const character of value) {
14
+ const point = character.codePointAt(0);
15
+ if (point >= 55296 && point <= 57343) return false;
16
+ }
17
+ return true;
18
+ };
19
+ const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255 && isWellFormedText(value);
20
+ const isText = (value) => typeof value === "string" && value.length > 0 && isWellFormedText(value);
21
+ const isTimestamp = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
22
+ const isLimit = (value) => isTimestamp(value) && value >= 1 && value <= 101;
23
+ const hasOnlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
24
+ const hasScope = (value) => (value.platform === "ios" || value.platform === "android") && isText(value.channel);
25
+ const isBundleFilter = (value, withKind = false) => {
26
+ if (!require_eventRow.isRecord(value) || !hasScope(value)) return false;
27
+ const keys = [
28
+ "platform",
29
+ "channel",
30
+ "type",
31
+ ...withKind ? ["kind"] : []
32
+ ];
33
+ return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED") && isText(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
34
+ };
35
+ const isEventFilter = (value) => {
36
+ if (!require_eventRow.isRecord(value)) return false;
37
+ if (value.kind === "all") return hasOnlyKeys(value, ["kind"]);
38
+ if (value.kind === "installationMovement") return isIdentity(value.installId) && hasOnlyKeys(value, ["kind", "installId"]);
39
+ return value.kind === "bundle" && isBundleFilter(value, true);
40
+ };
41
+ const validateRow = (model, row, result = false) => {
42
+ try {
43
+ require_eventRow.validateBundleEventFields(row);
44
+ if (!require_eventRow.isRecord(row) || typeof row.id !== "string" || !(0, _hot_updater_plugin_core.isUUIDv7)(row.id) || [...Object.values(row), ...require_eventRow.isRecord(row.metadata) ? Object.values(row.metadata) : []].some((value) => typeof value === "string" && !isWellFormedText(value))) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-data");
45
+ } catch (error) {
46
+ if (result) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-result");
47
+ throw error;
48
+ }
49
+ };
50
+ /** Throws `invalid-data` unless `row` is a complete, well-formed bundle event row. */
51
+ const assertBundleEventRow = (row) => validateRow("bundle_events", row);
52
+ /** Downloads, applies, recoveries, and failed updates belong in installation history. */
53
+ const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED";
54
+ const matchesInsightsEventFilter = (event, filter) => {
55
+ if (filter.kind === "all") return true;
56
+ if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
57
+ return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && (filter.type === "RECOVERED" ? event.from_bundle_id === filter.fromBundleId : event.to_bundle_id === filter.toBundleId && event.metadata.failure?.stage !== "check");
58
+ };
59
+ const invalidQuery = () => {
60
+ throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-query");
61
+ };
62
+ const invalidResult = () => {
63
+ throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-result");
64
+ };
65
+ const validateCount = (count) => isTimestamp(count) ? count : invalidResult();
66
+ const isTimeRange = (value) => require_eventRow.isRecord(value) && hasOnlyKeys(value, ["start", "end"]) && isTimestamp(value.start) && isTimestamp(value.end) && value.start < value.end;
67
+ const isReleaseReference = (value) => require_eventRow.isRecord(value) && hasOnlyKeys(value, [
68
+ "releaseId",
69
+ "platform",
70
+ "channel"
71
+ ]) && isIdentity(value.releaseId) && hasScope(value);
72
+ const validateAggregateResult = (value) => {
73
+ const result = require_eventRow.isRecord(value) ? value : invalidResult();
74
+ if (!isTimestamp(result.measuredAtMs)) invalidResult();
75
+ const coverage = result.coverage;
76
+ if (!require_eventRow.isRecord(coverage) || coverage.kind !== "complete" && coverage.kind !== "partial" || coverage.sinceMs !== null && !isTimestamp(coverage.sinceMs)) invalidResult();
77
+ };
78
+ /** Validate custom and bundled providers at the same public boundary. */
79
+ const createValidatedInsightsModel = (model) => ({
80
+ async recordEvent(input) {
81
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, ["event"])) throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-data");
82
+ validateRow("bundle_events", input.event);
83
+ await model.recordEvent(input);
84
+ },
85
+ async listEvents(input) {
86
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
87
+ "filter",
88
+ "sinceMs",
89
+ "beforeReceivedAtMs",
90
+ "after",
91
+ "limit"
92
+ ]) || !isEventFilter(input.filter) || !isLimit(input.limit) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs !== void 0 && !isTimestamp(input.sinceMs)) invalidQuery();
93
+ const sinceMs = input.sinceMs ?? 0;
94
+ if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!require_eventRow.isRecord(input.after) || !hasOnlyKeys(input.after, ["receivedAtMs", "id"]) || !isTimestamp(input.after.receivedAtMs) || typeof input.after.id !== "string" || !(0, _hot_updater_plugin_core.isUUIDv7)(input.after.id) || input.after.receivedAtMs < sinceMs || input.after.receivedAtMs >= input.beforeReceivedAtMs)) invalidQuery();
95
+ const rows = await model.listEvents(input);
96
+ if (!Array.isArray(rows) || rows.length > input.limit) invalidResult();
97
+ let previous = input.after;
98
+ for (const row of rows) {
99
+ validateRow("bundle_events", row, true);
100
+ if (row.received_at_ms < sinceMs || row.received_at_ms >= input.beforeReceivedAtMs || !matchesInsightsEventFilter(row, input.filter) || previous !== void 0 && (row.received_at_ms > previous.receivedAtMs || row.received_at_ms === previous.receivedAtMs && row.id >= previous.id)) invalidResult();
101
+ previous = {
102
+ receivedAtMs: row.received_at_ms,
103
+ id: row.id
104
+ };
105
+ }
106
+ return rows;
107
+ },
108
+ async findLatestEvents(input) {
109
+ if (!require_eventRow.isRecord(input)) invalidQuery();
110
+ if ("installId" in input) {
111
+ if (!hasOnlyKeys(input, ["installId"]) || !isIdentity(input.installId)) invalidQuery();
112
+ } else if (!hasOnlyKeys(input, [
113
+ "userId",
114
+ "afterInstallId",
115
+ "limit"
116
+ ]) || !isIdentity(input.userId) || !isLimit(input.limit) || input.afterInstallId !== void 0 && !isIdentity(input.afterInstallId)) invalidQuery();
117
+ const rows = await model.findLatestEvents(input);
118
+ if (!Array.isArray(rows) || rows.length > ("installId" in input ? 1 : input.limit)) invalidResult();
119
+ let previous = "installId" in input ? void 0 : input.afterInstallId;
120
+ for (const row of rows) {
121
+ validateRow("bundle_events", row, true);
122
+ if ("installId" in input ? row.install_id !== input.installId : row.user_id !== input.userId || previous !== void 0 && compareInsightsText(row.install_id, previous) <= 0) invalidResult();
123
+ previous = row.install_id;
124
+ }
125
+ return rows;
126
+ },
127
+ async countLatestEvents(input) {
128
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
129
+ "platform",
130
+ "channel",
131
+ "sinceMs",
132
+ "bundle"
133
+ ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundle !== void 0 && (!Array.isArray(input.bundle) || input.bundle.length < 1 || input.bundle.length > 2 || input.bundle.some((bundle) => !require_eventRow.isRecord(bundle) || !hasOnlyKeys(bundle, [
134
+ "field",
135
+ "value",
136
+ "types"
137
+ ]) || bundle.field !== "from_bundle_id" && bundle.field !== "to_bundle_id" || !isText(bundle.value) || !Array.isArray(bundle.types) || bundle.types.length === 0 || bundle.types.some((type) => ![
138
+ "UNCHANGED",
139
+ "UPDATE_DOWNLOADED",
140
+ "UPDATE_APPLIED",
141
+ "RECOVERED"
142
+ ].includes(type))))) invalidQuery();
143
+ return validateCount(await model.countLatestEvents(input));
144
+ },
145
+ async countEvents(input) {
146
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
147
+ "filter",
148
+ "sinceMs",
149
+ "beforeReceivedAtMs"
150
+ ]) || !isBundleFilter(input.filter) || !isTimestamp(input.sinceMs) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs > input.beforeReceivedAtMs) invalidQuery();
151
+ return validateCount(await model.countEvents(input));
152
+ },
153
+ async getReleaseActivity(input) {
154
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
155
+ "releases",
156
+ "scope",
157
+ "timeRange"
158
+ ])) invalidQuery();
159
+ const releases = input.releases;
160
+ const scope = input.scope;
161
+ const timeRange = input.timeRange;
162
+ const releaseQuery = Array.isArray(releases) && releases.length > 0 && releases.length <= 100 && releases.every(isReleaseReference) && scope === void 0 && (timeRange === void 0 || isTimeRange(timeRange));
163
+ const scopeQuery = releases === void 0 && require_eventRow.isRecord(scope) && hasOnlyKeys(scope, ["platform", "channel"]) && hasScope(scope) && isTimeRange(timeRange);
164
+ if (!releaseQuery && !scopeQuery) invalidQuery();
165
+ const result = await model.getReleaseActivity(input);
166
+ validateAggregateResult(result);
167
+ if (!Array.isArray(result.data)) invalidResult();
168
+ return result;
169
+ },
170
+ async getAppUsage(input) {
171
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
172
+ "channel",
173
+ "platform",
174
+ "appVersion",
175
+ "timeRange",
176
+ "intervalMs"
177
+ ]) || !isText(input.channel) || ![
178
+ "all",
179
+ "ios",
180
+ "android"
181
+ ].includes(input.platform) || input.appVersion !== void 0 && !isText(input.appVersion) || !isTimeRange(input.timeRange) || !isTimestamp(input.intervalMs) || input.intervalMs < 36e5) invalidQuery();
182
+ const result = await model.getAppUsage(input);
183
+ validateAggregateResult(result);
184
+ if (!isTimestamp(result.activeInstallations) || !Array.isArray(result.points) || !Array.isArray(result.appVersions) || !Array.isArray(result.versions) || !Array.isArray(result.platforms) || !Array.isArray(result.bundleDistribution)) invalidResult();
185
+ return result;
186
+ }
187
+ });
188
+ //#endregion
189
+ exports.assertBundleEventRow = assertBundleEventRow;
190
+ exports.compareInsightsText = compareInsightsText;
191
+ exports.createValidatedInsightsModel = createValidatedInsightsModel;
192
+ exports.isInsightsMovementEvent = isInsightsMovementEvent;
193
+ exports.matchesInsightsEventFilter = matchesInsightsEventFilter;
@@ -0,0 +1,189 @@
1
+ import { isRecord, validateBundleEventFields } from "./eventRow.mjs";
2
+ import { DatabaseAdapterInputError, isUUIDv7 } from "@hot-updater/plugin-core";
3
+ //#region src/server/contract.ts
4
+ const encoder = new TextEncoder();
5
+ /** Exact UTF-8 byte ordering, without case folding or Unicode normalization. */
6
+ const compareInsightsText = (left, right) => {
7
+ const a = encoder.encode(left);
8
+ const b = encoder.encode(right);
9
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) if (a[index] !== b[index]) return a[index] - b[index];
10
+ return a.length - b.length;
11
+ };
12
+ const isWellFormedText = (value) => {
13
+ for (const character of value) {
14
+ const point = character.codePointAt(0);
15
+ if (point >= 55296 && point <= 57343) return false;
16
+ }
17
+ return true;
18
+ };
19
+ const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255 && isWellFormedText(value);
20
+ const isText = (value) => typeof value === "string" && value.length > 0 && isWellFormedText(value);
21
+ const isTimestamp = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
22
+ const isLimit = (value) => isTimestamp(value) && value >= 1 && value <= 101;
23
+ const hasOnlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
24
+ const hasScope = (value) => (value.platform === "ios" || value.platform === "android") && isText(value.channel);
25
+ const isBundleFilter = (value, withKind = false) => {
26
+ if (!isRecord(value) || !hasScope(value)) return false;
27
+ const keys = [
28
+ "platform",
29
+ "channel",
30
+ "type",
31
+ ...withKind ? ["kind"] : []
32
+ ];
33
+ return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED") && isText(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
34
+ };
35
+ const isEventFilter = (value) => {
36
+ if (!isRecord(value)) return false;
37
+ if (value.kind === "all") return hasOnlyKeys(value, ["kind"]);
38
+ if (value.kind === "installationMovement") return isIdentity(value.installId) && hasOnlyKeys(value, ["kind", "installId"]);
39
+ return value.kind === "bundle" && isBundleFilter(value, true);
40
+ };
41
+ const validateRow = (model, row, result = false) => {
42
+ try {
43
+ validateBundleEventFields(row);
44
+ if (!isRecord(row) || typeof row.id !== "string" || !isUUIDv7(row.id) || [...Object.values(row), ...isRecord(row.metadata) ? Object.values(row.metadata) : []].some((value) => typeof value === "string" && !isWellFormedText(value))) throw new DatabaseAdapterInputError("invalid-data");
45
+ } catch (error) {
46
+ if (result) throw new DatabaseAdapterInputError("invalid-result");
47
+ throw error;
48
+ }
49
+ };
50
+ /** Throws `invalid-data` unless `row` is a complete, well-formed bundle event row. */
51
+ const assertBundleEventRow = (row) => validateRow("bundle_events", row);
52
+ /** Downloads, applies, recoveries, and failed updates belong in installation history. */
53
+ const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED";
54
+ const matchesInsightsEventFilter = (event, filter) => {
55
+ if (filter.kind === "all") return true;
56
+ if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
57
+ return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && (filter.type === "RECOVERED" ? event.from_bundle_id === filter.fromBundleId : event.to_bundle_id === filter.toBundleId && event.metadata.failure?.stage !== "check");
58
+ };
59
+ const invalidQuery = () => {
60
+ throw new DatabaseAdapterInputError("invalid-query");
61
+ };
62
+ const invalidResult = () => {
63
+ throw new DatabaseAdapterInputError("invalid-result");
64
+ };
65
+ const validateCount = (count) => isTimestamp(count) ? count : invalidResult();
66
+ const isTimeRange = (value) => isRecord(value) && hasOnlyKeys(value, ["start", "end"]) && isTimestamp(value.start) && isTimestamp(value.end) && value.start < value.end;
67
+ const isReleaseReference = (value) => isRecord(value) && hasOnlyKeys(value, [
68
+ "releaseId",
69
+ "platform",
70
+ "channel"
71
+ ]) && isIdentity(value.releaseId) && hasScope(value);
72
+ const validateAggregateResult = (value) => {
73
+ const result = isRecord(value) ? value : invalidResult();
74
+ if (!isTimestamp(result.measuredAtMs)) invalidResult();
75
+ const coverage = result.coverage;
76
+ if (!isRecord(coverage) || coverage.kind !== "complete" && coverage.kind !== "partial" || coverage.sinceMs !== null && !isTimestamp(coverage.sinceMs)) invalidResult();
77
+ };
78
+ /** Validate custom and bundled providers at the same public boundary. */
79
+ const createValidatedInsightsModel = (model) => ({
80
+ async recordEvent(input) {
81
+ if (!isRecord(input) || !hasOnlyKeys(input, ["event"])) throw new DatabaseAdapterInputError("invalid-data");
82
+ validateRow("bundle_events", input.event);
83
+ await model.recordEvent(input);
84
+ },
85
+ async listEvents(input) {
86
+ if (!isRecord(input) || !hasOnlyKeys(input, [
87
+ "filter",
88
+ "sinceMs",
89
+ "beforeReceivedAtMs",
90
+ "after",
91
+ "limit"
92
+ ]) || !isEventFilter(input.filter) || !isLimit(input.limit) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs !== void 0 && !isTimestamp(input.sinceMs)) invalidQuery();
93
+ const sinceMs = input.sinceMs ?? 0;
94
+ if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!isRecord(input.after) || !hasOnlyKeys(input.after, ["receivedAtMs", "id"]) || !isTimestamp(input.after.receivedAtMs) || typeof input.after.id !== "string" || !isUUIDv7(input.after.id) || input.after.receivedAtMs < sinceMs || input.after.receivedAtMs >= input.beforeReceivedAtMs)) invalidQuery();
95
+ const rows = await model.listEvents(input);
96
+ if (!Array.isArray(rows) || rows.length > input.limit) invalidResult();
97
+ let previous = input.after;
98
+ for (const row of rows) {
99
+ validateRow("bundle_events", row, true);
100
+ if (row.received_at_ms < sinceMs || row.received_at_ms >= input.beforeReceivedAtMs || !matchesInsightsEventFilter(row, input.filter) || previous !== void 0 && (row.received_at_ms > previous.receivedAtMs || row.received_at_ms === previous.receivedAtMs && row.id >= previous.id)) invalidResult();
101
+ previous = {
102
+ receivedAtMs: row.received_at_ms,
103
+ id: row.id
104
+ };
105
+ }
106
+ return rows;
107
+ },
108
+ async findLatestEvents(input) {
109
+ if (!isRecord(input)) invalidQuery();
110
+ if ("installId" in input) {
111
+ if (!hasOnlyKeys(input, ["installId"]) || !isIdentity(input.installId)) invalidQuery();
112
+ } else if (!hasOnlyKeys(input, [
113
+ "userId",
114
+ "afterInstallId",
115
+ "limit"
116
+ ]) || !isIdentity(input.userId) || !isLimit(input.limit) || input.afterInstallId !== void 0 && !isIdentity(input.afterInstallId)) invalidQuery();
117
+ const rows = await model.findLatestEvents(input);
118
+ if (!Array.isArray(rows) || rows.length > ("installId" in input ? 1 : input.limit)) invalidResult();
119
+ let previous = "installId" in input ? void 0 : input.afterInstallId;
120
+ for (const row of rows) {
121
+ validateRow("bundle_events", row, true);
122
+ if ("installId" in input ? row.install_id !== input.installId : row.user_id !== input.userId || previous !== void 0 && compareInsightsText(row.install_id, previous) <= 0) invalidResult();
123
+ previous = row.install_id;
124
+ }
125
+ return rows;
126
+ },
127
+ async countLatestEvents(input) {
128
+ if (!isRecord(input) || !hasOnlyKeys(input, [
129
+ "platform",
130
+ "channel",
131
+ "sinceMs",
132
+ "bundle"
133
+ ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundle !== void 0 && (!Array.isArray(input.bundle) || input.bundle.length < 1 || input.bundle.length > 2 || input.bundle.some((bundle) => !isRecord(bundle) || !hasOnlyKeys(bundle, [
134
+ "field",
135
+ "value",
136
+ "types"
137
+ ]) || bundle.field !== "from_bundle_id" && bundle.field !== "to_bundle_id" || !isText(bundle.value) || !Array.isArray(bundle.types) || bundle.types.length === 0 || bundle.types.some((type) => ![
138
+ "UNCHANGED",
139
+ "UPDATE_DOWNLOADED",
140
+ "UPDATE_APPLIED",
141
+ "RECOVERED"
142
+ ].includes(type))))) invalidQuery();
143
+ return validateCount(await model.countLatestEvents(input));
144
+ },
145
+ async countEvents(input) {
146
+ if (!isRecord(input) || !hasOnlyKeys(input, [
147
+ "filter",
148
+ "sinceMs",
149
+ "beforeReceivedAtMs"
150
+ ]) || !isBundleFilter(input.filter) || !isTimestamp(input.sinceMs) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs > input.beforeReceivedAtMs) invalidQuery();
151
+ return validateCount(await model.countEvents(input));
152
+ },
153
+ async getReleaseActivity(input) {
154
+ if (!isRecord(input) || !hasOnlyKeys(input, [
155
+ "releases",
156
+ "scope",
157
+ "timeRange"
158
+ ])) invalidQuery();
159
+ const releases = input.releases;
160
+ const scope = input.scope;
161
+ const timeRange = input.timeRange;
162
+ const releaseQuery = Array.isArray(releases) && releases.length > 0 && releases.length <= 100 && releases.every(isReleaseReference) && scope === void 0 && (timeRange === void 0 || isTimeRange(timeRange));
163
+ const scopeQuery = releases === void 0 && isRecord(scope) && hasOnlyKeys(scope, ["platform", "channel"]) && hasScope(scope) && isTimeRange(timeRange);
164
+ if (!releaseQuery && !scopeQuery) invalidQuery();
165
+ const result = await model.getReleaseActivity(input);
166
+ validateAggregateResult(result);
167
+ if (!Array.isArray(result.data)) invalidResult();
168
+ return result;
169
+ },
170
+ async getAppUsage(input) {
171
+ if (!isRecord(input) || !hasOnlyKeys(input, [
172
+ "channel",
173
+ "platform",
174
+ "appVersion",
175
+ "timeRange",
176
+ "intervalMs"
177
+ ]) || !isText(input.channel) || ![
178
+ "all",
179
+ "ios",
180
+ "android"
181
+ ].includes(input.platform) || input.appVersion !== void 0 && !isText(input.appVersion) || !isTimeRange(input.timeRange) || !isTimestamp(input.intervalMs) || input.intervalMs < 36e5) invalidQuery();
182
+ const result = await model.getAppUsage(input);
183
+ validateAggregateResult(result);
184
+ if (!isTimestamp(result.activeInstallations) || !Array.isArray(result.points) || !Array.isArray(result.appVersions) || !Array.isArray(result.versions) || !Array.isArray(result.platforms) || !Array.isArray(result.bundleDistribution)) invalidResult();
185
+ return result;
186
+ }
187
+ });
188
+ //#endregion
189
+ export { assertBundleEventRow, compareInsightsText, createValidatedInsightsModel, isInsightsMovementEvent, matchesInsightsEventFilter };