@hot-updater/server 1.0.0-rc.0 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/drizzle.cjs +3 -3
- package/dist/adapters/drizzle.mjs +3 -3
- package/dist/createHotUpdaterCore.cjs +6 -6
- package/dist/createHotUpdaterCore.d.cts +7 -7
- package/dist/createHotUpdaterCore.d.mts +7 -7
- package/dist/createHotUpdaterCore.mjs +6 -6
- package/dist/db/types.cjs +1 -1
- package/dist/db/types.mjs +1 -1
- package/dist/handler.cjs +5 -5
- package/dist/handler.mjs +5 -5
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +2 -2
- package/dist/{analytics → insights}/bounded/activeOverview.cjs +1 -1
- package/dist/{analytics → insights}/bounded/activeOverview.mjs +1 -1
- package/dist/{analytics → insights}/bounded/installationSearch.cjs +1 -1
- package/dist/{analytics → insights}/bounded/installationSearch.mjs +1 -1
- package/dist/{analytics → insights}/bounded/persistence.cjs +1 -1
- package/dist/{analytics → insights}/bounded/persistence.mjs +1 -1
- package/dist/{analytics → insights}/bounded/provider.cjs +9 -9
- package/dist/insights/bounded/provider.d.cts +7 -0
- package/dist/insights/bounded/provider.d.mts +7 -0
- package/dist/{analytics → insights}/bounded/provider.mjs +10 -10
- package/dist/{analytics → insights}/bounded/scan.cjs +15 -15
- package/dist/{analytics → insights}/bounded/scan.mjs +16 -16
- package/dist/{analytics → insights}/domain.d.cts +12 -12
- package/dist/{analytics → insights}/domain.d.mts +12 -12
- package/dist/insights/errors.cjs +22 -0
- package/dist/insights/errors.mjs +20 -0
- package/dist/{analytics → insights}/eventInput.cjs +10 -10
- package/dist/{analytics → insights}/eventInput.mjs +11 -11
- package/dist/insights/persistence.d.cts +6 -0
- package/dist/insights/persistence.d.mts +6 -0
- package/dist/{analytics → insights}/queryInput.cjs +9 -9
- package/dist/{analytics → insights}/queryInput.mjs +10 -10
- package/dist/{analytics → insights}/routes.cjs +16 -16
- package/dist/{analytics → insights}/routes.mjs +16 -16
- package/dist/{analytics → insights}/types.d.cts +6 -6
- package/dist/{analytics → insights}/types.d.mts +6 -6
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/package.json +4 -4
- package/src/adapters/drizzle.ts +3 -3
- package/src/apiKeys.integration.spec.ts +2 -2
- package/src/createHotUpdaterCore.ts +13 -13
- package/src/db/types.spec.ts +3 -3
- package/src/db/types.ts +7 -7
- package/src/handler-standalone.integration.spec.ts +1 -1
- package/src/handler.ts +12 -12
- package/src/index.ts +3 -3
- package/src/{analytics → insights}/bounded/provider.spec.ts +21 -21
- package/src/{analytics → insights}/bounded/provider.ts +18 -18
- package/src/{analytics → insights}/bounded/scan.ts +26 -26
- package/src/{analytics → insights}/domain.ts +10 -10
- package/src/insights/errors.ts +18 -0
- package/src/{analytics → insights}/eventInput.ts +11 -11
- package/src/{analytics/analytics.integration.spec.ts → insights/insights.integration.spec.ts} +6 -6
- package/src/insights/persistence.ts +10 -0
- package/src/{analytics → insights}/queryInput.ts +11 -11
- package/src/{analytics → insights}/routes.ts +18 -18
- package/src/{analytics → insights}/types.ts +7 -7
- package/dist/analytics/bounded/provider.d.cts +0 -7
- package/dist/analytics/bounded/provider.d.mts +0 -7
- package/dist/analytics/errors.cjs +0 -22
- package/dist/analytics/errors.mjs +0 -20
- package/dist/analytics/persistence.d.cts +0 -6
- package/dist/analytics/persistence.d.mts +0 -6
- package/src/analytics/errors.ts +0 -18
- package/src/analytics/persistence.ts +0 -10
- /package/src/{analytics → insights}/bounded/activeOverview.spec.ts +0 -0
- /package/src/{analytics → insights}/bounded/activeOverview.ts +0 -0
- /package/src/{analytics → insights}/bounded/installationSearch.spec.ts +0 -0
- /package/src/{analytics → insights}/bounded/installationSearch.ts +0 -0
- /package/src/{analytics → insights}/bounded/persistence.ts +0 -0
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
2
|
+
BundleEventInsightsWindow,
|
|
3
3
|
InstallationHistoryRow,
|
|
4
4
|
} from "../domain";
|
|
5
5
|
import type {
|
|
6
|
-
|
|
6
|
+
InsightsPersistence,
|
|
7
7
|
BundleEventPersistenceRow,
|
|
8
8
|
} from "../persistence";
|
|
9
|
-
import type {
|
|
9
|
+
import type { InsightsProvider } from "../types";
|
|
10
10
|
import { collectActiveInstallationOverview } from "./activeOverview";
|
|
11
11
|
import { searchEventInstallations } from "./installationSearch";
|
|
12
12
|
import { createBundleEventRow } from "./persistence";
|
|
13
13
|
import {
|
|
14
|
-
|
|
14
|
+
INSIGHTS_SCAN_MAX_ROWS,
|
|
15
15
|
collectEventActivity,
|
|
16
16
|
compareCodePoints,
|
|
17
17
|
compareEventNewest,
|
|
@@ -57,10 +57,10 @@ const countDistinctInstallations = (
|
|
|
57
57
|
rows: readonly BundleEventPersistenceRow[],
|
|
58
58
|
): number => new Set(rows.map(({ install_id }) => install_id)).size;
|
|
59
59
|
|
|
60
|
-
const
|
|
61
|
-
persistence:
|
|
60
|
+
const getInsightsResult = async (
|
|
61
|
+
persistence: InsightsPersistence,
|
|
62
62
|
bundleId: string,
|
|
63
|
-
window:
|
|
63
|
+
window: BundleEventInsightsWindow,
|
|
64
64
|
limit: number,
|
|
65
65
|
offset: number,
|
|
66
66
|
) => {
|
|
@@ -98,10 +98,10 @@ const getAnalyticsResult = async (
|
|
|
98
98
|
};
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
const
|
|
102
|
-
persistence:
|
|
101
|
+
const getInsightsSummaries = async (
|
|
102
|
+
persistence: InsightsPersistence,
|
|
103
103
|
bundleIds: readonly string[],
|
|
104
|
-
window:
|
|
104
|
+
window: BundleEventInsightsWindow,
|
|
105
105
|
) => {
|
|
106
106
|
const normalizedBundleIds = [...new Set(bundleIds)];
|
|
107
107
|
if (normalizedBundleIds.length === 0) return [];
|
|
@@ -135,12 +135,12 @@ const getAnalyticsSummaries = async (
|
|
|
135
135
|
}));
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
-
export const
|
|
139
|
-
persistence:
|
|
140
|
-
):
|
|
138
|
+
export const createInsightsProvider = (
|
|
139
|
+
persistence: InsightsPersistence,
|
|
140
|
+
): InsightsProvider =>
|
|
141
141
|
Object.freeze({
|
|
142
142
|
mode: "bounded",
|
|
143
|
-
maxMatchingRows:
|
|
143
|
+
maxMatchingRows: INSIGHTS_SCAN_MAX_ROWS,
|
|
144
144
|
async appendBundleEvent(input) {
|
|
145
145
|
await persistence.append(createBundleEventRow(input));
|
|
146
146
|
},
|
|
@@ -158,10 +158,10 @@ export const createAnalyticsProvider = (
|
|
|
158
158
|
};
|
|
159
159
|
},
|
|
160
160
|
getBundleEventSummaries(bundleIds, window) {
|
|
161
|
-
return
|
|
161
|
+
return getInsightsSummaries(persistence, bundleIds, window);
|
|
162
162
|
},
|
|
163
|
-
|
|
164
|
-
return
|
|
163
|
+
getBundleEventInsights(bundleId, window, limit, offset) {
|
|
164
|
+
return getInsightsResult(persistence, bundleId, window, limit, offset);
|
|
165
165
|
},
|
|
166
166
|
async getBundleEventOverview() {
|
|
167
167
|
const rows = await materializeEventRows({
|
|
@@ -226,4 +226,4 @@ export const createAnalyticsProvider = (
|
|
|
226
226
|
pagination: { total: ordered.length, limit, offset },
|
|
227
227
|
};
|
|
228
228
|
},
|
|
229
|
-
} satisfies
|
|
229
|
+
} satisfies InsightsProvider);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActiveInstallationWindow,
|
|
3
|
-
|
|
3
|
+
BundleEventInsightsWindow,
|
|
4
4
|
} from "../domain";
|
|
5
|
-
import {
|
|
5
|
+
import { InsightsScanLimitExceededError } from "../errors";
|
|
6
6
|
import type {
|
|
7
|
-
|
|
7
|
+
InsightsPersistence,
|
|
8
8
|
BundleEventPersistenceRow,
|
|
9
9
|
} from "../persistence";
|
|
10
10
|
import {
|
|
@@ -12,20 +12,20 @@ import {
|
|
|
12
12
|
getActiveWindowDefinition,
|
|
13
13
|
} from "./activeOverview";
|
|
14
14
|
|
|
15
|
-
export const
|
|
16
|
-
export const
|
|
17
|
-
export const
|
|
18
|
-
const
|
|
15
|
+
export const INSIGHTS_SCAN_MAX_ROWS = 50_000;
|
|
16
|
+
export const INSIGHTS_MATERIALIZATION_LIMIT = INSIGHTS_SCAN_MAX_ROWS + 1;
|
|
17
|
+
export const INSIGHTS_SCAN_PAGE_SIZE = 1_000;
|
|
18
|
+
const INSIGHTS_LOWER_BOUND_ID = "00000000-0000-0000-0000-000000000000";
|
|
19
19
|
|
|
20
|
-
export type
|
|
21
|
-
readonly persistence:
|
|
20
|
+
export type InsightsScanScope = {
|
|
21
|
+
readonly persistence: InsightsPersistence;
|
|
22
22
|
readonly cutoffMs: number;
|
|
23
23
|
readonly lowerBoundMs?: number;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
type EventActivityRequest = {
|
|
27
27
|
readonly rows: readonly BundleEventPersistenceRow[];
|
|
28
|
-
readonly window:
|
|
28
|
+
readonly window: BundleEventInsightsWindow;
|
|
29
29
|
readonly cutoffMs: number;
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -50,18 +50,18 @@ const compareEventOldest = (
|
|
|
50
50
|
compareCodePoints(left.id, right.id);
|
|
51
51
|
|
|
52
52
|
export const materializeEventRows = async (
|
|
53
|
-
scope:
|
|
53
|
+
scope: InsightsScanScope,
|
|
54
54
|
): Promise<readonly BundleEventPersistenceRow[]> => {
|
|
55
55
|
const rows: BundleEventPersistenceRow[] = [];
|
|
56
56
|
const seenIds = new Set<string>();
|
|
57
57
|
let after =
|
|
58
58
|
scope.lowerBoundMs === undefined
|
|
59
59
|
? undefined
|
|
60
|
-
: { receivedAtMs: scope.lowerBoundMs, id:
|
|
61
|
-
while (rows.length <
|
|
60
|
+
: { receivedAtMs: scope.lowerBoundMs, id: INSIGHTS_LOWER_BOUND_ID };
|
|
61
|
+
while (rows.length < INSIGHTS_MATERIALIZATION_LIMIT) {
|
|
62
62
|
const limit = Math.min(
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
INSIGHTS_SCAN_PAGE_SIZE,
|
|
64
|
+
INSIGHTS_MATERIALIZATION_LIMIT - rows.length,
|
|
65
65
|
);
|
|
66
66
|
const page = await scope.persistence.scan({
|
|
67
67
|
beforeReceivedAtMs: scope.cutoffMs,
|
|
@@ -69,7 +69,7 @@ export const materializeEventRows = async (
|
|
|
69
69
|
limit,
|
|
70
70
|
});
|
|
71
71
|
if (page.length === 0) break;
|
|
72
|
-
if (page.length > limit) throw new
|
|
72
|
+
if (page.length > limit) throw new InsightsPersistenceOrderError();
|
|
73
73
|
let previous =
|
|
74
74
|
after === undefined
|
|
75
75
|
? undefined
|
|
@@ -80,7 +80,7 @@ export const materializeEventRows = async (
|
|
|
80
80
|
(previous !== undefined && compareEventOldest(previous, row) >= 0) ||
|
|
81
81
|
seenIds.has(row.id)
|
|
82
82
|
) {
|
|
83
|
-
throw new
|
|
83
|
+
throw new InsightsPersistenceOrderError();
|
|
84
84
|
}
|
|
85
85
|
seenIds.add(row.id);
|
|
86
86
|
rows.push(row);
|
|
@@ -90,23 +90,23 @@ export const materializeEventRows = async (
|
|
|
90
90
|
if (last !== undefined) {
|
|
91
91
|
after = { receivedAtMs: last.received_at_ms, id: last.id };
|
|
92
92
|
}
|
|
93
|
-
if (rows.length >
|
|
94
|
-
throw new
|
|
93
|
+
if (rows.length > INSIGHTS_SCAN_MAX_ROWS) {
|
|
94
|
+
throw new InsightsScanLimitExceededError(INSIGHTS_SCAN_MAX_ROWS);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
return rows;
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
-
export class
|
|
101
|
-
readonly name = "
|
|
100
|
+
export class InsightsPersistenceOrderError extends Error {
|
|
101
|
+
readonly name = "InsightsPersistenceOrderError";
|
|
102
102
|
|
|
103
103
|
constructor() {
|
|
104
|
-
super("
|
|
104
|
+
super("Insights persistence did not advance its scan cursor.");
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export const materializeActiveRows = async (
|
|
109
|
-
scope:
|
|
109
|
+
scope: InsightsScanScope,
|
|
110
110
|
window: ActiveInstallationWindow,
|
|
111
111
|
): Promise<readonly BundleEventPersistenceRow[]> => {
|
|
112
112
|
const definition = getActiveWindowDefinition(window);
|
|
@@ -139,7 +139,7 @@ const startOfUtcDay = (value: number): number => {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
export const getWindowRange = (
|
|
142
|
-
window: Exclude<
|
|
142
|
+
window: Exclude<BundleEventInsightsWindow, "all">,
|
|
143
143
|
now: number,
|
|
144
144
|
): { readonly sizeMs: number; readonly rangeStart: number } => {
|
|
145
145
|
if (window === "24h") {
|
|
@@ -156,8 +156,8 @@ export const getWindowRange = (
|
|
|
156
156
|
};
|
|
157
157
|
|
|
158
158
|
export const materializeRowsForWindow = async (
|
|
159
|
-
scope:
|
|
160
|
-
window:
|
|
159
|
+
scope: InsightsScanScope,
|
|
160
|
+
window: BundleEventInsightsWindow,
|
|
161
161
|
): Promise<readonly BundleEventPersistenceRow[]> => {
|
|
162
162
|
if (window === "all") return materializeEventRows(scope);
|
|
163
163
|
const range = getWindowRange(window, scope.cutoffMs);
|
|
@@ -44,7 +44,7 @@ export type BundleEventSummaryByBundle = BundleEventSummary & {
|
|
|
44
44
|
readonly bundleId: string;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
export type
|
|
47
|
+
export type BundleEventInsightsWindow = "24h" | "7d" | "30d" | "all";
|
|
48
48
|
export type ActiveInstallationWindow = "24h" | "7d" | "30d";
|
|
49
49
|
|
|
50
50
|
export type InstallationSearchRow = {
|
|
@@ -87,25 +87,25 @@ export type OffsetPaginationResult<TData> = {
|
|
|
87
87
|
};
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
-
export type
|
|
90
|
+
export type InsightsSeriesPoint = {
|
|
91
91
|
readonly bucketStartMs: number;
|
|
92
92
|
readonly value: number;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
export type
|
|
95
|
+
export type InsightsCohortPoint = {
|
|
96
96
|
readonly cohort: string;
|
|
97
97
|
readonly value: number;
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
-
export type
|
|
100
|
+
export type BundleEventInsightsResult = {
|
|
101
101
|
readonly summary: BundleEventSummary;
|
|
102
102
|
readonly series: {
|
|
103
|
-
readonly installed: readonly
|
|
104
|
-
readonly recovered: readonly
|
|
103
|
+
readonly installed: readonly InsightsSeriesPoint[];
|
|
104
|
+
readonly recovered: readonly InsightsSeriesPoint[];
|
|
105
105
|
};
|
|
106
106
|
readonly cohorts: {
|
|
107
|
-
readonly installed: readonly
|
|
108
|
-
readonly recovered: readonly
|
|
107
|
+
readonly installed: readonly InsightsCohortPoint[];
|
|
108
|
+
readonly recovered: readonly InsightsCohortPoint[];
|
|
109
109
|
};
|
|
110
110
|
readonly recentEvents: OffsetPaginationResult<InstallationHistoryRow>;
|
|
111
111
|
};
|
|
@@ -122,10 +122,10 @@ export type ActiveInstallationOverview = {
|
|
|
122
122
|
readonly asOfMs: number;
|
|
123
123
|
readonly window: ActiveInstallationWindow;
|
|
124
124
|
readonly activeInstallations: number;
|
|
125
|
-
readonly series: readonly
|
|
125
|
+
readonly series: readonly InsightsSeriesPoint[];
|
|
126
126
|
readonly bundleSeries: readonly {
|
|
127
127
|
readonly bundleId: string;
|
|
128
|
-
readonly series: readonly
|
|
128
|
+
readonly series: readonly InsightsSeriesPoint[];
|
|
129
129
|
}[];
|
|
130
130
|
readonly bundles: readonly {
|
|
131
131
|
readonly bundleId: string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class InsightsScanLimitExceededError extends Error {
|
|
2
|
+
constructor(readonly limit: number) {
|
|
3
|
+
super(`Insights event scan exceeded ${limit} rows.`);
|
|
4
|
+
this.name = "InsightsScanLimitExceededError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class InsightsBadRequestError extends Error {
|
|
9
|
+
readonly name = "InsightsBadRequestError";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class InsightsPayloadTooLargeError extends Error {
|
|
13
|
+
readonly name = "InsightsPayloadTooLargeError";
|
|
14
|
+
|
|
15
|
+
constructor(readonly maximumBytes: number) {
|
|
16
|
+
super(`Event payload exceeds ${maximumBytes} bytes`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -3,8 +3,8 @@ import type {
|
|
|
3
3
|
CreateBundleEventRequestBase,
|
|
4
4
|
} from "./domain";
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
InsightsBadRequestError,
|
|
7
|
+
InsightsPayloadTooLargeError,
|
|
8
8
|
} from "./errors";
|
|
9
9
|
|
|
10
10
|
const MAX_EVENT_STRING_LENGTH = 1_024;
|
|
@@ -42,7 +42,7 @@ function requireStringField(
|
|
|
42
42
|
value.length === 0 ||
|
|
43
43
|
value.length > MAX_EVENT_STRING_LENGTH
|
|
44
44
|
) {
|
|
45
|
-
throw new
|
|
45
|
+
throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
46
46
|
}
|
|
47
47
|
return value;
|
|
48
48
|
}
|
|
@@ -63,7 +63,7 @@ async function readBoundedText(request: Request): Promise<string> {
|
|
|
63
63
|
Number.isSafeInteger(declaredByteLength) &&
|
|
64
64
|
declaredByteLength > EVENT_BODY_MAX_BYTES
|
|
65
65
|
) {
|
|
66
|
-
throw new
|
|
66
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
67
67
|
}
|
|
68
68
|
if (request.body === null) return "";
|
|
69
69
|
const reader = request.body.getReader();
|
|
@@ -76,7 +76,7 @@ async function readBoundedText(request: Request): Promise<string> {
|
|
|
76
76
|
byteLength += result.value.byteLength;
|
|
77
77
|
if (byteLength > EVENT_BODY_MAX_BYTES) {
|
|
78
78
|
await reader.cancel();
|
|
79
|
-
throw new
|
|
79
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
80
80
|
}
|
|
81
81
|
text += decoder.decode(result.value, { stream: true });
|
|
82
82
|
}
|
|
@@ -89,7 +89,7 @@ async function parseJson(request: Request): Promise<unknown> {
|
|
|
89
89
|
return JSON.parse(text);
|
|
90
90
|
} catch (error) {
|
|
91
91
|
if (error instanceof SyntaxError) {
|
|
92
|
-
throw new
|
|
92
|
+
throw new InsightsBadRequestError("Invalid event payload");
|
|
93
93
|
}
|
|
94
94
|
throw error;
|
|
95
95
|
}
|
|
@@ -100,11 +100,11 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
100
100
|
!isRecord(payload) ||
|
|
101
101
|
Object.keys(payload).some((key) => !eventKeys.has(key))
|
|
102
102
|
) {
|
|
103
|
-
throw new
|
|
103
|
+
throw new InsightsBadRequestError("Invalid event payload");
|
|
104
104
|
}
|
|
105
105
|
const platform = requireStringField(payload, "platform");
|
|
106
106
|
if (platform !== "ios" && platform !== "android") {
|
|
107
|
-
throw new
|
|
107
|
+
throw new InsightsBadRequestError("Invalid event field: platform");
|
|
108
108
|
}
|
|
109
109
|
const base: CreateBundleEventRequestBase = {
|
|
110
110
|
installId: requireStringField(payload, "installId"),
|
|
@@ -134,7 +134,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
134
134
|
case "RELEASE_ADOPTED": {
|
|
135
135
|
const updateStrategy = requireStringField(payload, "updateStrategy");
|
|
136
136
|
if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") {
|
|
137
|
-
throw new
|
|
137
|
+
throw new InsightsBadRequestError(
|
|
138
138
|
"Invalid event field: updateStrategy",
|
|
139
139
|
);
|
|
140
140
|
}
|
|
@@ -147,7 +147,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
147
147
|
}
|
|
148
148
|
case "UNCHANGED":
|
|
149
149
|
if (payload.fromBundleId !== null || payload.updateStrategy !== null) {
|
|
150
|
-
throw new
|
|
150
|
+
throw new InsightsBadRequestError("Invalid unchanged event shape");
|
|
151
151
|
}
|
|
152
152
|
return {
|
|
153
153
|
...base,
|
|
@@ -156,7 +156,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
156
156
|
updateStrategy: null,
|
|
157
157
|
};
|
|
158
158
|
default:
|
|
159
|
-
throw new
|
|
159
|
+
throw new InsightsBadRequestError("Invalid event field: type");
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
package/src/{analytics/analytics.integration.spec.ts → insights/insights.integration.spec.ts}
RENAMED
|
@@ -32,12 +32,12 @@ afterEach(() => {
|
|
|
32
32
|
vi.useRealTimers();
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
describe("createHotUpdater
|
|
36
|
-
it("always persists events and serves queries without a server
|
|
35
|
+
describe("createHotUpdater Insights", () => {
|
|
36
|
+
it("always persists events and serves queries without a server Insights flag", async () => {
|
|
37
37
|
vi.useFakeTimers();
|
|
38
38
|
vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
|
|
39
39
|
const database = createInMemoryDatabasePlugin();
|
|
40
|
-
const append = vi.spyOn(database.models.
|
|
40
|
+
const append = vi.spyOn(database.models.insights, "append");
|
|
41
41
|
const hotUpdater = createHotUpdater({
|
|
42
42
|
database,
|
|
43
43
|
clientAccess: { type: "public" },
|
|
@@ -58,7 +58,7 @@ describe("createHotUpdater Analytics", () => {
|
|
|
58
58
|
type: "UNCHANGED",
|
|
59
59
|
}),
|
|
60
60
|
);
|
|
61
|
-
expect(hotUpdater.
|
|
61
|
+
expect(hotUpdater.insights).toMatchObject({
|
|
62
62
|
mode: "bounded",
|
|
63
63
|
maxMatchingRows: 50_000,
|
|
64
64
|
});
|
|
@@ -69,7 +69,7 @@ describe("createHotUpdater Analytics", () => {
|
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
it("checks the official schema before
|
|
72
|
+
it("checks the official schema before Insights reads and writes", async () => {
|
|
73
73
|
const database = createSchemaManagedDatabase(
|
|
74
74
|
"kysely",
|
|
75
75
|
HOT_UPDATER_SCHEMA_VERSION,
|
|
@@ -81,7 +81,7 @@ describe("createHotUpdater Analytics", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
84
|
-
await hotUpdater.
|
|
84
|
+
await hotUpdater.insights?.getBundleEventOverview();
|
|
85
85
|
|
|
86
86
|
expect(createMigrator).toHaveBeenCalledOnce();
|
|
87
87
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InsightsScanCursor,
|
|
3
|
+
InsightsScanInput,
|
|
4
|
+
InsightsModel,
|
|
5
|
+
BundleEventRow,
|
|
6
|
+
} from "@hot-updater/plugin-core";
|
|
7
|
+
|
|
8
|
+
export type BundleEventPersistenceRow = BundleEventRow;
|
|
9
|
+
export type InsightsPersistence = InsightsModel;
|
|
10
|
+
export type { InsightsScanCursor, InsightsScanInput };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActiveInstallationInput,
|
|
3
|
-
|
|
3
|
+
BundleEventInsightsWindow,
|
|
4
4
|
} from "./domain";
|
|
5
|
-
import {
|
|
5
|
+
import { InsightsBadRequestError } from "./errors";
|
|
6
6
|
|
|
7
7
|
type IntegerBounds = {
|
|
8
8
|
readonly defaultValue: number;
|
|
@@ -26,8 +26,8 @@ export type PaginationInput = {
|
|
|
26
26
|
readonly offset: number;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
export type
|
|
30
|
-
readonly window:
|
|
29
|
+
export type InsightsQueryInput = PaginationInput & {
|
|
30
|
+
readonly window: BundleEventInsightsWindow;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
function parseInteger(url: URL, key: string, bounds: IntegerBounds): number {
|
|
@@ -39,7 +39,7 @@ function parseInteger(url: URL, key: string, bounds: IntegerBounds): number {
|
|
|
39
39
|
parsed < bounds.minimum ||
|
|
40
40
|
(bounds.maximum !== undefined && parsed > bounds.maximum)
|
|
41
41
|
) {
|
|
42
|
-
throw new
|
|
42
|
+
throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
|
|
43
43
|
}
|
|
44
44
|
return parsed;
|
|
45
45
|
}
|
|
@@ -52,7 +52,7 @@ export const parsePagination = (request: Request): PaginationInput => {
|
|
|
52
52
|
};
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
export const
|
|
55
|
+
export const parseInsightsQuery = (request: Request): InsightsQueryInput => {
|
|
56
56
|
const url = new URL(request.url);
|
|
57
57
|
const window = url.searchParams.get("window") ?? "24h";
|
|
58
58
|
if (
|
|
@@ -61,7 +61,7 @@ export const parseAnalyticsQuery = (request: Request): AnalyticsQueryInput => {
|
|
|
61
61
|
window !== "30d" &&
|
|
62
62
|
window !== "all"
|
|
63
63
|
) {
|
|
64
|
-
throw new
|
|
64
|
+
throw new InsightsBadRequestError("Invalid 'window' query parameter.");
|
|
65
65
|
}
|
|
66
66
|
return { ...parsePagination(request), window };
|
|
67
67
|
};
|
|
@@ -72,22 +72,22 @@ export const parseActiveInstallationInput = (
|
|
|
72
72
|
const url = new URL(request.url);
|
|
73
73
|
const windows = url.searchParams.getAll("window");
|
|
74
74
|
if (windows.length > 1) {
|
|
75
|
-
throw new
|
|
75
|
+
throw new InsightsBadRequestError("Duplicate 'window' query parameter.");
|
|
76
76
|
}
|
|
77
77
|
const window = windows[0] ?? "30d";
|
|
78
78
|
if (window !== "24h" && window !== "7d" && window !== "30d") {
|
|
79
|
-
throw new
|
|
79
|
+
throw new InsightsBadRequestError("Invalid 'window' query parameter.");
|
|
80
80
|
}
|
|
81
81
|
const userIds = url.searchParams.getAll("userId");
|
|
82
82
|
if (userIds.length > 1) {
|
|
83
|
-
throw new
|
|
83
|
+
throw new InsightsBadRequestError("Duplicate 'userId' query parameter.");
|
|
84
84
|
}
|
|
85
85
|
const userId = userIds[0];
|
|
86
86
|
if (
|
|
87
87
|
userId !== undefined &&
|
|
88
88
|
(userId.length === 0 || userId.length > MAX_USER_ID_LENGTH)
|
|
89
89
|
) {
|
|
90
|
-
throw new
|
|
90
|
+
throw new InsightsBadRequestError("Invalid 'userId' query parameter.");
|
|
91
91
|
}
|
|
92
92
|
return userId === undefined ? { window } : { window, userId };
|
|
93
93
|
};
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { RouteHandler } from "../handlerTypes";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
InsightsBadRequestError,
|
|
4
|
+
InsightsPayloadTooLargeError,
|
|
5
|
+
InsightsScanLimitExceededError,
|
|
6
6
|
} from "./errors";
|
|
7
7
|
import { parseBundleEventRequest } from "./eventInput";
|
|
8
8
|
import {
|
|
9
9
|
parseActiveInstallationInput,
|
|
10
|
-
|
|
10
|
+
parseInsightsQuery,
|
|
11
11
|
parsePagination,
|
|
12
12
|
parseSearchInput,
|
|
13
13
|
} from "./queryInput";
|
|
14
|
-
import type {
|
|
14
|
+
import type { InsightsProvider } from "./types";
|
|
15
15
|
|
|
16
16
|
const json = (body: unknown, status: number): Response =>
|
|
17
17
|
Response.json(body, {
|
|
@@ -25,7 +25,7 @@ const requireParam = (
|
|
|
25
25
|
): string => {
|
|
26
26
|
const value = params[key];
|
|
27
27
|
if (value === undefined || value.length === 0) {
|
|
28
|
-
throw new
|
|
28
|
+
throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
|
|
29
29
|
}
|
|
30
30
|
return value;
|
|
31
31
|
};
|
|
@@ -34,17 +34,17 @@ const run = async (operation: () => Promise<Response>): Promise<Response> => {
|
|
|
34
34
|
try {
|
|
35
35
|
return await operation();
|
|
36
36
|
} catch (error) {
|
|
37
|
-
if (error instanceof
|
|
37
|
+
if (error instanceof InsightsBadRequestError) {
|
|
38
38
|
return json({ error: error.message }, 400);
|
|
39
39
|
}
|
|
40
|
-
if (error instanceof
|
|
40
|
+
if (error instanceof InsightsPayloadTooLargeError) {
|
|
41
41
|
return json({ error: error.message }, 413);
|
|
42
42
|
}
|
|
43
|
-
if (error instanceof
|
|
43
|
+
if (error instanceof InsightsScanLimitExceededError) {
|
|
44
44
|
return json(
|
|
45
45
|
{
|
|
46
46
|
error: {
|
|
47
|
-
code: "
|
|
47
|
+
code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
|
|
48
48
|
limit: error.limit,
|
|
49
49
|
},
|
|
50
50
|
},
|
|
@@ -58,8 +58,8 @@ const run = async (operation: () => Promise<Response>): Promise<Response> => {
|
|
|
58
58
|
const query = (operation: () => Promise<unknown>): Promise<Response> =>
|
|
59
59
|
run(async () => json(await operation(), 200));
|
|
60
60
|
|
|
61
|
-
export const
|
|
62
|
-
provider:
|
|
61
|
+
export const createInsightsRouteHandlers = (
|
|
62
|
+
provider: InsightsProvider,
|
|
63
63
|
): Record<string, RouteHandler> => ({
|
|
64
64
|
appendBundleEvent: async (_params, request) =>
|
|
65
65
|
run(async () => {
|
|
@@ -68,10 +68,10 @@ export const createAnalyticsRouteHandlers = (
|
|
|
68
68
|
}),
|
|
69
69
|
getBundleEventSummary: (params) =>
|
|
70
70
|
query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
|
|
71
|
-
|
|
71
|
+
getBundleEventInsights: (params, request) =>
|
|
72
72
|
query(() => {
|
|
73
|
-
const input =
|
|
74
|
-
return provider.
|
|
73
|
+
const input = parseInsightsQuery(request);
|
|
74
|
+
return provider.getBundleEventInsights(
|
|
75
75
|
requireParam(params, "id"),
|
|
76
76
|
input.window,
|
|
77
77
|
input.limit,
|
|
@@ -105,17 +105,17 @@ export const createAnalyticsRouteHandlers = (
|
|
|
105
105
|
}),
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
export const
|
|
108
|
+
export const registerInsightsClientRoutes = (
|
|
109
109
|
add: (method: string, path: string, handler: string) => void,
|
|
110
110
|
): void => {
|
|
111
111
|
add("POST", "/events", "appendBundleEvent");
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
export const
|
|
114
|
+
export const registerInsightsAdminRoutes = (
|
|
115
115
|
add: (method: string, path: string, handler: string) => void,
|
|
116
116
|
): void => {
|
|
117
117
|
add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
|
|
118
|
-
add("GET", "/bundles/:id/events/
|
|
118
|
+
add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
|
|
119
119
|
add("GET", "/installations/overview", "getBundleEventOverview");
|
|
120
120
|
add("GET", "/installations/active", "getActiveInstallationOverview");
|
|
121
121
|
add("GET", "/installations", "searchInstallations");
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActiveInstallationInput,
|
|
3
3
|
ActiveInstallationOverview,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
BundleEventInsightsResult,
|
|
5
|
+
BundleEventInsightsWindow,
|
|
6
6
|
BundleEventOverview,
|
|
7
7
|
BundleEventSummary,
|
|
8
8
|
BundleEventSummaryByBundle,
|
|
@@ -12,21 +12,21 @@ import type {
|
|
|
12
12
|
OffsetPaginationResult,
|
|
13
13
|
} from "./domain";
|
|
14
14
|
|
|
15
|
-
export type
|
|
15
|
+
export type InsightsProvider = {
|
|
16
16
|
readonly mode: "bounded";
|
|
17
17
|
readonly maxMatchingRows: number;
|
|
18
18
|
appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
|
|
19
19
|
getBundleEventSummary(bundleId: string): Promise<BundleEventSummary>;
|
|
20
20
|
getBundleEventSummaries(
|
|
21
21
|
bundleIds: readonly string[],
|
|
22
|
-
window:
|
|
22
|
+
window: BundleEventInsightsWindow,
|
|
23
23
|
): Promise<readonly BundleEventSummaryByBundle[]>;
|
|
24
|
-
|
|
24
|
+
getBundleEventInsights(
|
|
25
25
|
bundleId: string,
|
|
26
|
-
window:
|
|
26
|
+
window: BundleEventInsightsWindow,
|
|
27
27
|
limit: number,
|
|
28
28
|
offset: number,
|
|
29
|
-
): Promise<
|
|
29
|
+
): Promise<BundleEventInsightsResult>;
|
|
30
30
|
getBundleEventOverview(): Promise<BundleEventOverview>;
|
|
31
31
|
getActiveInstallationOverview(
|
|
32
32
|
input: ActiveInstallationInput,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { AnalyticsProvider } from "../types.cjs";
|
|
2
|
-
import { AnalyticsPersistence } from "../persistence.cjs";
|
|
3
|
-
|
|
4
|
-
//#region src/analytics/bounded/provider.d.ts
|
|
5
|
-
declare const createAnalyticsProvider: (persistence: AnalyticsPersistence) => AnalyticsProvider;
|
|
6
|
-
//#endregion
|
|
7
|
-
export { createAnalyticsProvider };
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { AnalyticsProvider } from "../types.mjs";
|
|
2
|
-
import { AnalyticsPersistence } from "../persistence.mjs";
|
|
3
|
-
|
|
4
|
-
//#region src/analytics/bounded/provider.d.ts
|
|
5
|
-
declare const createAnalyticsProvider: (persistence: AnalyticsPersistence) => AnalyticsProvider;
|
|
6
|
-
//#endregion
|
|
7
|
-
export { createAnalyticsProvider };
|