@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,39 +1,39 @@
|
|
|
1
1
|
const require_errors = require("./errors.cjs");
|
|
2
2
|
const require_eventInput = require("./eventInput.cjs");
|
|
3
3
|
const require_queryInput = require("./queryInput.cjs");
|
|
4
|
-
//#region src/
|
|
4
|
+
//#region src/insights/routes.ts
|
|
5
5
|
const json = (body, status) => Response.json(body, {
|
|
6
6
|
headers: { "cache-control": "private, no-store" },
|
|
7
7
|
status
|
|
8
8
|
});
|
|
9
9
|
const requireParam = (params, key) => {
|
|
10
10
|
const value = params[key];
|
|
11
|
-
if (value === void 0 || value.length === 0) throw new require_errors.
|
|
11
|
+
if (value === void 0 || value.length === 0) throw new require_errors.InsightsBadRequestError(`Missing route parameter: ${key}`);
|
|
12
12
|
return value;
|
|
13
13
|
};
|
|
14
14
|
const run = async (operation) => {
|
|
15
15
|
try {
|
|
16
16
|
return await operation();
|
|
17
17
|
} catch (error) {
|
|
18
|
-
if (error instanceof require_errors.
|
|
19
|
-
if (error instanceof require_errors.
|
|
20
|
-
if (error instanceof require_errors.
|
|
21
|
-
code: "
|
|
18
|
+
if (error instanceof require_errors.InsightsBadRequestError) return json({ error: error.message }, 400);
|
|
19
|
+
if (error instanceof require_errors.InsightsPayloadTooLargeError) return json({ error: error.message }, 413);
|
|
20
|
+
if (error instanceof require_errors.InsightsScanLimitExceededError) return json({ error: {
|
|
21
|
+
code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
|
|
22
22
|
limit: error.limit
|
|
23
23
|
} }, 503);
|
|
24
24
|
throw error;
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
27
|
const query = (operation) => run(async () => json(await operation(), 200));
|
|
28
|
-
const
|
|
28
|
+
const createInsightsRouteHandlers = (provider) => ({
|
|
29
29
|
appendBundleEvent: async (_params, request) => run(async () => {
|
|
30
30
|
await provider.appendBundleEvent(await require_eventInput.parseBundleEventRequest(request));
|
|
31
31
|
return new Response(null, { status: 204 });
|
|
32
32
|
}),
|
|
33
33
|
getBundleEventSummary: (params) => query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
|
|
34
|
-
|
|
35
|
-
const input = require_queryInput.
|
|
36
|
-
return provider.
|
|
34
|
+
getBundleEventInsights: (params, request) => query(() => {
|
|
35
|
+
const input = require_queryInput.parseInsightsQuery(request);
|
|
36
|
+
return provider.getBundleEventInsights(requireParam(params, "id"), input.window, input.limit, input.offset);
|
|
37
37
|
}),
|
|
38
38
|
getBundleEventOverview: () => query(() => provider.getBundleEventOverview()),
|
|
39
39
|
getActiveInstallationOverview: (_params, request) => query(() => provider.getActiveInstallationOverview(require_queryInput.parseActiveInstallationInput(request))),
|
|
@@ -46,18 +46,18 @@ const createAnalyticsRouteHandlers = (provider) => ({
|
|
|
46
46
|
return provider.getInstallationHistory(requireParam(params, "installId"), input.limit, input.offset);
|
|
47
47
|
})
|
|
48
48
|
});
|
|
49
|
-
const
|
|
49
|
+
const registerInsightsClientRoutes = (add) => {
|
|
50
50
|
add("POST", "/events", "appendBundleEvent");
|
|
51
51
|
};
|
|
52
|
-
const
|
|
52
|
+
const registerInsightsAdminRoutes = (add) => {
|
|
53
53
|
add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
|
|
54
|
-
add("GET", "/bundles/:id/events/
|
|
54
|
+
add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
|
|
55
55
|
add("GET", "/installations/overview", "getBundleEventOverview");
|
|
56
56
|
add("GET", "/installations/active", "getActiveInstallationOverview");
|
|
57
57
|
add("GET", "/installations", "searchInstallations");
|
|
58
58
|
add("GET", "/installations/:installId/events", "getInstallationHistory");
|
|
59
59
|
};
|
|
60
60
|
//#endregion
|
|
61
|
-
exports.
|
|
62
|
-
exports.
|
|
63
|
-
exports.
|
|
61
|
+
exports.createInsightsRouteHandlers = createInsightsRouteHandlers;
|
|
62
|
+
exports.registerInsightsAdminRoutes = registerInsightsAdminRoutes;
|
|
63
|
+
exports.registerInsightsClientRoutes = registerInsightsClientRoutes;
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { InsightsBadRequestError, InsightsPayloadTooLargeError, InsightsScanLimitExceededError } from "./errors.mjs";
|
|
2
2
|
import { parseBundleEventRequest } from "./eventInput.mjs";
|
|
3
|
-
import { parseActiveInstallationInput,
|
|
4
|
-
//#region src/
|
|
3
|
+
import { parseActiveInstallationInput, parseInsightsQuery, parsePagination, parseSearchInput } from "./queryInput.mjs";
|
|
4
|
+
//#region src/insights/routes.ts
|
|
5
5
|
const json = (body, status) => Response.json(body, {
|
|
6
6
|
headers: { "cache-control": "private, no-store" },
|
|
7
7
|
status
|
|
8
8
|
});
|
|
9
9
|
const requireParam = (params, key) => {
|
|
10
10
|
const value = params[key];
|
|
11
|
-
if (value === void 0 || value.length === 0) throw new
|
|
11
|
+
if (value === void 0 || value.length === 0) throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
|
|
12
12
|
return value;
|
|
13
13
|
};
|
|
14
14
|
const run = async (operation) => {
|
|
15
15
|
try {
|
|
16
16
|
return await operation();
|
|
17
17
|
} catch (error) {
|
|
18
|
-
if (error instanceof
|
|
19
|
-
if (error instanceof
|
|
20
|
-
if (error instanceof
|
|
21
|
-
code: "
|
|
18
|
+
if (error instanceof InsightsBadRequestError) return json({ error: error.message }, 400);
|
|
19
|
+
if (error instanceof InsightsPayloadTooLargeError) return json({ error: error.message }, 413);
|
|
20
|
+
if (error instanceof InsightsScanLimitExceededError) return json({ error: {
|
|
21
|
+
code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
|
|
22
22
|
limit: error.limit
|
|
23
23
|
} }, 503);
|
|
24
24
|
throw error;
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
27
|
const query = (operation) => run(async () => json(await operation(), 200));
|
|
28
|
-
const
|
|
28
|
+
const createInsightsRouteHandlers = (provider) => ({
|
|
29
29
|
appendBundleEvent: async (_params, request) => run(async () => {
|
|
30
30
|
await provider.appendBundleEvent(await parseBundleEventRequest(request));
|
|
31
31
|
return new Response(null, { status: 204 });
|
|
32
32
|
}),
|
|
33
33
|
getBundleEventSummary: (params) => query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
|
|
34
|
-
|
|
35
|
-
const input =
|
|
36
|
-
return provider.
|
|
34
|
+
getBundleEventInsights: (params, request) => query(() => {
|
|
35
|
+
const input = parseInsightsQuery(request);
|
|
36
|
+
return provider.getBundleEventInsights(requireParam(params, "id"), input.window, input.limit, input.offset);
|
|
37
37
|
}),
|
|
38
38
|
getBundleEventOverview: () => query(() => provider.getBundleEventOverview()),
|
|
39
39
|
getActiveInstallationOverview: (_params, request) => query(() => provider.getActiveInstallationOverview(parseActiveInstallationInput(request))),
|
|
@@ -46,16 +46,16 @@ const createAnalyticsRouteHandlers = (provider) => ({
|
|
|
46
46
|
return provider.getInstallationHistory(requireParam(params, "installId"), input.limit, input.offset);
|
|
47
47
|
})
|
|
48
48
|
});
|
|
49
|
-
const
|
|
49
|
+
const registerInsightsClientRoutes = (add) => {
|
|
50
50
|
add("POST", "/events", "appendBundleEvent");
|
|
51
51
|
};
|
|
52
|
-
const
|
|
52
|
+
const registerInsightsAdminRoutes = (add) => {
|
|
53
53
|
add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
|
|
54
|
-
add("GET", "/bundles/:id/events/
|
|
54
|
+
add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
|
|
55
55
|
add("GET", "/installations/overview", "getBundleEventOverview");
|
|
56
56
|
add("GET", "/installations/active", "getActiveInstallationOverview");
|
|
57
57
|
add("GET", "/installations", "searchInstallations");
|
|
58
58
|
add("GET", "/installations/:installId/events", "getInstallationHistory");
|
|
59
59
|
};
|
|
60
60
|
//#endregion
|
|
61
|
-
export {
|
|
61
|
+
export { createInsightsRouteHandlers, registerInsightsAdminRoutes, registerInsightsClientRoutes };
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { ActiveInstallationInput, ActiveInstallationOverview,
|
|
1
|
+
import { ActiveInstallationInput, ActiveInstallationOverview, BundleEventInsightsResult, BundleEventInsightsWindow, BundleEventOverview, BundleEventSummary, BundleEventSummaryByBundle, CreateBundleEventRequest, InstallationHistoryRow, InstallationSearchRow, OffsetPaginationResult } from "./domain.cjs";
|
|
2
2
|
|
|
3
|
-
//#region src/
|
|
4
|
-
type
|
|
3
|
+
//#region src/insights/types.d.ts
|
|
4
|
+
type InsightsProvider = {
|
|
5
5
|
readonly mode: "bounded";
|
|
6
6
|
readonly maxMatchingRows: number;
|
|
7
7
|
appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
|
|
8
8
|
getBundleEventSummary(bundleId: string): Promise<BundleEventSummary>;
|
|
9
|
-
getBundleEventSummaries(bundleIds: readonly string[], window:
|
|
10
|
-
|
|
9
|
+
getBundleEventSummaries(bundleIds: readonly string[], window: BundleEventInsightsWindow): Promise<readonly BundleEventSummaryByBundle[]>;
|
|
10
|
+
getBundleEventInsights(bundleId: string, window: BundleEventInsightsWindow, limit: number, offset: number): Promise<BundleEventInsightsResult>;
|
|
11
11
|
getBundleEventOverview(): Promise<BundleEventOverview>;
|
|
12
12
|
getActiveInstallationOverview(input: ActiveInstallationInput): Promise<ActiveInstallationOverview>;
|
|
13
13
|
searchInstallations(query: string, limit: number, offset: number): Promise<OffsetPaginationResult<InstallationSearchRow>>;
|
|
14
14
|
getInstallationHistory(installId: string, limit: number, offset: number): Promise<OffsetPaginationResult<InstallationHistoryRow>>;
|
|
15
15
|
};
|
|
16
16
|
//#endregion
|
|
17
|
-
export {
|
|
17
|
+
export { InsightsProvider };
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { ActiveInstallationInput, ActiveInstallationOverview,
|
|
1
|
+
import { ActiveInstallationInput, ActiveInstallationOverview, BundleEventInsightsResult, BundleEventInsightsWindow, BundleEventOverview, BundleEventSummary, BundleEventSummaryByBundle, CreateBundleEventRequest, InstallationHistoryRow, InstallationSearchRow, OffsetPaginationResult } from "./domain.mjs";
|
|
2
2
|
|
|
3
|
-
//#region src/
|
|
4
|
-
type
|
|
3
|
+
//#region src/insights/types.d.ts
|
|
4
|
+
type InsightsProvider = {
|
|
5
5
|
readonly mode: "bounded";
|
|
6
6
|
readonly maxMatchingRows: number;
|
|
7
7
|
appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
|
|
8
8
|
getBundleEventSummary(bundleId: string): Promise<BundleEventSummary>;
|
|
9
|
-
getBundleEventSummaries(bundleIds: readonly string[], window:
|
|
10
|
-
|
|
9
|
+
getBundleEventSummaries(bundleIds: readonly string[], window: BundleEventInsightsWindow): Promise<readonly BundleEventSummaryByBundle[]>;
|
|
10
|
+
getBundleEventInsights(bundleId: string, window: BundleEventInsightsWindow, limit: number, offset: number): Promise<BundleEventInsightsResult>;
|
|
11
11
|
getBundleEventOverview(): Promise<BundleEventOverview>;
|
|
12
12
|
getActiveInstallationOverview(input: ActiveInstallationInput): Promise<ActiveInstallationOverview>;
|
|
13
13
|
searchInstallations(query: string, limit: number, offset: number): Promise<OffsetPaginationResult<InstallationSearchRow>>;
|
|
14
14
|
getInstallationHistory(installId: string, limit: number, offset: number): Promise<OffsetPaginationResult<InstallationHistoryRow>>;
|
|
15
15
|
};
|
|
16
16
|
//#endregion
|
|
17
|
-
export {
|
|
17
|
+
export { InsightsProvider };
|
package/dist/package.cjs
CHANGED
package/dist/package.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/server",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React Native OTA solution for self-hosted",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"rou3": "0.7.9",
|
|
57
57
|
"verkit": "0.3.2",
|
|
58
|
-
"@hot-updater/plugin-core": "1.0.0-rc.
|
|
58
|
+
"@hot-updater/plugin-core": "1.0.0-rc.1",
|
|
59
59
|
"@hot-updater/bsdiff": "1.0.0-rc.0",
|
|
60
60
|
"@hot-updater/core": "1.0.0-rc.0"
|
|
61
61
|
},
|
|
@@ -91,8 +91,8 @@
|
|
|
91
91
|
"mongodb": "6.20.0",
|
|
92
92
|
"msw": "^2.7.0",
|
|
93
93
|
"uuidv7": "^1.0.2",
|
|
94
|
-
"@hot-updater/standalone": "1.0.0-rc.
|
|
95
|
-
"@hot-updater/test-utils": "1.0.0-rc.
|
|
94
|
+
"@hot-updater/standalone": "1.0.0-rc.1",
|
|
95
|
+
"@hot-updater/test-utils": "1.0.0-rc.1"
|
|
96
96
|
},
|
|
97
97
|
"scripts": {
|
|
98
98
|
"build": "tsdown",
|
package/src/adapters/drizzle.ts
CHANGED
|
@@ -115,9 +115,9 @@ export const drizzleAdapter = (
|
|
|
115
115
|
list: (input) => getAdapter().models.channels.list(input),
|
|
116
116
|
delete: (input) => getAdapter().models.channels.delete(input),
|
|
117
117
|
},
|
|
118
|
-
|
|
119
|
-
append: (row) => getAdapter().models.
|
|
120
|
-
scan: (input) => getAdapter().models.
|
|
118
|
+
insights: {
|
|
119
|
+
append: (row) => getAdapter().models.insights.append(row),
|
|
120
|
+
scan: (input) => getAdapter().models.insights.scan(input),
|
|
121
121
|
},
|
|
122
122
|
apiKeys: {
|
|
123
123
|
create: (row) => getAdapter().models.apiKeys.create(row),
|
|
@@ -31,7 +31,7 @@ describe("createHotUpdater API keys", () => {
|
|
|
31
31
|
).toThrow("clientAccess.headerName must be a valid header name.");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it("protects only client OTA and
|
|
34
|
+
it("protects only client OTA and Insights write routes", async () => {
|
|
35
35
|
const database = createInMemoryDatabasePlugin();
|
|
36
36
|
await registerApiKey({
|
|
37
37
|
apiKeys: database.models.apiKeys,
|
|
@@ -86,7 +86,7 @@ describe("createHotUpdater API keys", () => {
|
|
|
86
86
|
expect(revoked).not.toHaveProperty("hash");
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it("authenticates before parsing
|
|
89
|
+
it("authenticates before parsing Insights event bodies", async () => {
|
|
90
90
|
const database = createInMemoryDatabasePlugin();
|
|
91
91
|
await registerApiKey({
|
|
92
92
|
apiKeys: database.models.apiKeys,
|
|
@@ -3,8 +3,6 @@ import {
|
|
|
3
3
|
type StoragePlugin,
|
|
4
4
|
} from "@hot-updater/plugin-core";
|
|
5
5
|
|
|
6
|
-
import { createAnalyticsProvider } from "./analytics/bounded/provider";
|
|
7
|
-
import type { AnalyticsProvider } from "./analytics/types";
|
|
8
6
|
import {
|
|
9
7
|
authenticateApiKey,
|
|
10
8
|
createApiKeyManagement,
|
|
@@ -20,17 +18,19 @@ import {
|
|
|
20
18
|
isDatabasePlugin,
|
|
21
19
|
} from "./db/types";
|
|
22
20
|
import { createHotUpdaterHandlers, type HotUpdaterHandlers } from "./handler";
|
|
21
|
+
import { createInsightsProvider } from "./insights/bounded/provider";
|
|
22
|
+
import type { InsightsProvider } from "./insights/types";
|
|
23
23
|
import { createStorageAccess } from "./storageAccess";
|
|
24
24
|
|
|
25
25
|
export type RuntimeHotUpdaterAPI = DatabaseAPI & {
|
|
26
26
|
readonly handlers: HotUpdaterHandlers;
|
|
27
27
|
readonly adapterName: string;
|
|
28
28
|
/**
|
|
29
|
-
* Built-in
|
|
30
|
-
* always mounted; React Native clients
|
|
31
|
-
*
|
|
29
|
+
* Built-in Insights provider. Client ingestion and admin query routes are
|
|
30
|
+
* always mounted; React Native clients report lifecycle events by default
|
|
31
|
+
* and can opt out with `HotUpdater.init({ insights: false })`.
|
|
32
32
|
*/
|
|
33
|
-
readonly
|
|
33
|
+
readonly insights: InsightsProvider;
|
|
34
34
|
/**
|
|
35
35
|
* In-process API key lifecycle operations for trusted server tooling.
|
|
36
36
|
* Creation returns the plaintext once; list and revoke expose only metadata.
|
|
@@ -44,7 +44,7 @@ export type HotUpdaterAPI = RuntimeHotUpdaterAPI;
|
|
|
44
44
|
export type ClientAccessPolicy =
|
|
45
45
|
| {
|
|
46
46
|
/**
|
|
47
|
-
* Leaves Release Catalog, artifact, and
|
|
47
|
+
* Leaves Release Catalog, artifact, and Insights ingestion routes
|
|
48
48
|
* publicly accessible without a client credential.
|
|
49
49
|
*/
|
|
50
50
|
readonly type: "public";
|
|
@@ -52,7 +52,7 @@ export type ClientAccessPolicy =
|
|
|
52
52
|
| {
|
|
53
53
|
/**
|
|
54
54
|
* Requires a key registered in `database.models.apiKeys` for
|
|
55
|
-
* Release Catalog, artifact, and
|
|
55
|
+
* Release Catalog, artifact, and Insights ingestion requests.
|
|
56
56
|
*/
|
|
57
57
|
readonly type: "api-key";
|
|
58
58
|
/**
|
|
@@ -168,14 +168,14 @@ export function createHotUpdaterCore(
|
|
|
168
168
|
readStorageText,
|
|
169
169
|
});
|
|
170
170
|
const clientAccess = normalizeClientAccess(options.clientAccess);
|
|
171
|
-
const
|
|
171
|
+
const insights = createInsightsProvider({
|
|
172
172
|
async append(row) {
|
|
173
173
|
await assertSchemaReady();
|
|
174
|
-
return plugin.models.
|
|
174
|
+
return plugin.models.insights.append(row);
|
|
175
175
|
},
|
|
176
176
|
async scan(input) {
|
|
177
177
|
await assertSchemaReady();
|
|
178
|
-
return plugin.models.
|
|
178
|
+
return plugin.models.insights.scan(input);
|
|
179
179
|
},
|
|
180
180
|
});
|
|
181
181
|
const apiKeys = createApiKeyManagement({
|
|
@@ -185,7 +185,7 @@ export function createHotUpdaterCore(
|
|
|
185
185
|
|
|
186
186
|
const handlers = createHotUpdaterHandlers(
|
|
187
187
|
core.api,
|
|
188
|
-
|
|
188
|
+
insights,
|
|
189
189
|
clientAccess.type === "api-key"
|
|
190
190
|
? {
|
|
191
191
|
authenticate: (request) =>
|
|
@@ -204,7 +204,7 @@ export function createHotUpdaterCore(
|
|
|
204
204
|
const api: RuntimeHotUpdaterAPI = Object.assign(
|
|
205
205
|
{
|
|
206
206
|
adapterName: adapterCapabilities.adapterName ?? core.adapterName,
|
|
207
|
-
|
|
207
|
+
insights,
|
|
208
208
|
apiKeys,
|
|
209
209
|
handlers,
|
|
210
210
|
},
|
package/src/db/types.spec.ts
CHANGED
|
@@ -22,9 +22,9 @@ describe("isDatabasePlugin", () => {
|
|
|
22
22
|
bundles: { ...plugin.models.bundles, findMany: null },
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
|
-
const
|
|
25
|
+
const missingInsights = {
|
|
26
26
|
...plugin,
|
|
27
|
-
models: { ...plugin.models,
|
|
27
|
+
models: { ...plugin.models, insights: undefined },
|
|
28
28
|
};
|
|
29
29
|
const malformedChannels = {
|
|
30
30
|
...plugin,
|
|
@@ -47,7 +47,7 @@ describe("isDatabasePlugin", () => {
|
|
|
47
47
|
|
|
48
48
|
expect(isDatabasePlugin(factory)).toBe(false);
|
|
49
49
|
expect(isDatabasePlugin(malformedBundles)).toBe(false);
|
|
50
|
-
expect(isDatabasePlugin(
|
|
50
|
+
expect(isDatabasePlugin(missingInsights)).toBe(false);
|
|
51
51
|
expect(isDatabasePlugin(malformedChannels)).toBe(false);
|
|
52
52
|
expect(isDatabasePlugin(malformedApiKeys)).toBe(false);
|
|
53
53
|
expect(isDatabasePlugin(malformedDispose)).toBe(false);
|
package/src/db/types.ts
CHANGED
|
@@ -123,13 +123,13 @@ export function isDatabasePlugin(plugin: unknown): plugin is DatabasePlugin {
|
|
|
123
123
|
typeof plugin.models.channels.list === "function" &&
|
|
124
124
|
"delete" in plugin.models.channels &&
|
|
125
125
|
typeof plugin.models.channels.delete === "function" &&
|
|
126
|
-
"
|
|
127
|
-
typeof plugin.models.
|
|
128
|
-
plugin.models.
|
|
129
|
-
"append" in plugin.models.
|
|
130
|
-
typeof plugin.models.
|
|
131
|
-
"scan" in plugin.models.
|
|
132
|
-
typeof plugin.models.
|
|
126
|
+
"insights" in plugin.models &&
|
|
127
|
+
typeof plugin.models.insights === "object" &&
|
|
128
|
+
plugin.models.insights !== null &&
|
|
129
|
+
"append" in plugin.models.insights &&
|
|
130
|
+
typeof plugin.models.insights.append === "function" &&
|
|
131
|
+
"scan" in plugin.models.insights &&
|
|
132
|
+
typeof plugin.models.insights.scan === "function" &&
|
|
133
133
|
"apiKeys" in plugin.models &&
|
|
134
134
|
typeof plugin.models.apiKeys === "object" &&
|
|
135
135
|
plugin.models.apiKeys !== null &&
|
|
@@ -156,7 +156,7 @@ const commitTestRelease = async (
|
|
|
156
156
|
};
|
|
157
157
|
|
|
158
158
|
describe("Handler <-> Standalone Repository Integration", () => {
|
|
159
|
-
it("uses Standalone for bundle management and the server database for
|
|
159
|
+
it("uses Standalone for bundle management and the server database for Insights", async () => {
|
|
160
160
|
const bundleId = uuidv7();
|
|
161
161
|
const installId = "standalone-install";
|
|
162
162
|
await createStandaloneClient().insertBundle(
|
package/src/handler.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createAnalyticsRouteHandlers,
|
|
3
|
-
registerAnalyticsAdminRoutes,
|
|
4
|
-
registerAnalyticsClientRoutes,
|
|
5
|
-
} from "./analytics/routes";
|
|
6
|
-
import type { AnalyticsProvider } from "./analytics/types";
|
|
7
1
|
import { createBundleRouteHandlers } from "./handlerBundleRoutes";
|
|
8
2
|
import { HandlerBadRequestError } from "./handlerErrors";
|
|
9
3
|
import { createReleaseCatalogRouteHandlers } from "./handlerReleaseCatalogRoutes";
|
|
@@ -15,6 +9,12 @@ import type {
|
|
|
15
9
|
RouteHandler,
|
|
16
10
|
} from "./handlerTypes";
|
|
17
11
|
import { createVersionRouteHandlers } from "./handlerVersionRoutes";
|
|
12
|
+
import {
|
|
13
|
+
createInsightsRouteHandlers,
|
|
14
|
+
registerInsightsAdminRoutes,
|
|
15
|
+
registerInsightsClientRoutes,
|
|
16
|
+
} from "./insights/routes";
|
|
17
|
+
import type { InsightsProvider } from "./insights/types";
|
|
18
18
|
import { addRoute, createRouter, findRoute } from "./internalRouter";
|
|
19
19
|
|
|
20
20
|
export type {
|
|
@@ -142,7 +142,7 @@ export function createHandlers(api: HandlerAPI): HotUpdaterHandlers {
|
|
|
142
142
|
|
|
143
143
|
export function createHotUpdaterHandlers(
|
|
144
144
|
api: HandlerAPI,
|
|
145
|
-
|
|
145
|
+
insights?: InsightsProvider,
|
|
146
146
|
apiKeyAuth?: {
|
|
147
147
|
readonly authenticate: (request: Request) => Promise<boolean>;
|
|
148
148
|
readonly headerName: string;
|
|
@@ -157,7 +157,7 @@ export function createHotUpdaterHandlers(
|
|
|
157
157
|
...createReleaseCatalogRouteHandlers(apiKeyAuth?.headerName),
|
|
158
158
|
...createReleaseManagementRouteHandlers(),
|
|
159
159
|
...createBundleRouteHandlers(),
|
|
160
|
-
...(
|
|
160
|
+
...(insights === undefined ? {} : createInsightsRouteHandlers(insights)),
|
|
161
161
|
...(downloadStorageObject === undefined
|
|
162
162
|
? {}
|
|
163
163
|
: {
|
|
@@ -196,8 +196,8 @@ export function createHotUpdaterHandlers(
|
|
|
196
196
|
"/artifacts/:targetBundleId/from/:currentBundleId",
|
|
197
197
|
"artifact",
|
|
198
198
|
);
|
|
199
|
-
if (
|
|
200
|
-
|
|
199
|
+
if (insights !== undefined) {
|
|
200
|
+
registerInsightsClientRoutes(addClientRoute);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
const adminRouter = createRouter<string>();
|
|
@@ -224,8 +224,8 @@ export function createHotUpdaterHandlers(
|
|
|
224
224
|
addAdminRoute("POST", "/bundles", "createBundles");
|
|
225
225
|
addAdminRoute("PATCH", "/bundles/:id", "updateBundle");
|
|
226
226
|
addAdminRoute("DELETE", "/bundles/:id", "deleteBundle");
|
|
227
|
-
if (
|
|
228
|
-
|
|
227
|
+
if (insights !== undefined) {
|
|
228
|
+
registerInsightsAdminRoutes(addAdminRoute);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
return Object.freeze({
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,9 @@ export type {
|
|
|
4
4
|
HotUpdaterHandler,
|
|
5
5
|
HotUpdaterHandlers,
|
|
6
6
|
} from "./handler";
|
|
7
|
-
export {
|
|
8
|
-
export type * from "./
|
|
9
|
-
export type {
|
|
7
|
+
export { createInsightsProvider } from "./insights/bounded/provider";
|
|
8
|
+
export type * from "./insights/domain";
|
|
9
|
+
export type { InsightsProvider } from "./insights/types";
|
|
10
10
|
export {
|
|
11
11
|
API_KEY_HEADER_NAME,
|
|
12
12
|
createApiKey,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { InsightsScanLimitExceededError } from "../errors";
|
|
4
4
|
import type {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
InsightsPersistence,
|
|
6
|
+
InsightsScanInput,
|
|
7
7
|
BundleEventPersistenceRow,
|
|
8
8
|
} from "../persistence";
|
|
9
|
-
import {
|
|
9
|
+
import { createInsightsProvider } from "./provider";
|
|
10
10
|
import { getWindowRange } from "./scan";
|
|
11
11
|
|
|
12
12
|
const eventRow = (
|
|
@@ -42,13 +42,13 @@ const compareRows = (
|
|
|
42
42
|
const inMemoryPersistence = (
|
|
43
43
|
initialRows: readonly BundleEventPersistenceRow[],
|
|
44
44
|
maximumPageSize = Number.MAX_SAFE_INTEGER,
|
|
45
|
-
):
|
|
45
|
+
): InsightsPersistence => {
|
|
46
46
|
const rows = [...initialRows];
|
|
47
47
|
return {
|
|
48
48
|
async append(row) {
|
|
49
49
|
rows.push(row);
|
|
50
50
|
},
|
|
51
|
-
async scan(input:
|
|
51
|
+
async scan(input: InsightsScanInput) {
|
|
52
52
|
return rows
|
|
53
53
|
.filter((row) => row.received_at_ms < input.beforeReceivedAtMs)
|
|
54
54
|
.filter(
|
|
@@ -64,19 +64,19 @@ const inMemoryPersistence = (
|
|
|
64
64
|
};
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
describe("
|
|
68
|
-
it("starts windowed
|
|
67
|
+
describe("createInsightsProvider", () => {
|
|
68
|
+
it("starts windowed insights scans at the storage lower boundary", async () => {
|
|
69
69
|
// Given
|
|
70
70
|
const now = Date.UTC(2026, 1, 2, 12);
|
|
71
71
|
vi.spyOn(Date, "now").mockReturnValue(now);
|
|
72
|
-
const scan = vi.fn<
|
|
73
|
-
const provider =
|
|
72
|
+
const scan = vi.fn<InsightsPersistence["scan"]>().mockResolvedValue([]);
|
|
73
|
+
const provider = createInsightsProvider({
|
|
74
74
|
async append() {},
|
|
75
75
|
scan,
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
// When
|
|
79
|
-
await provider.
|
|
79
|
+
await provider.getBundleEventInsights("new", "24h", 20, 0);
|
|
80
80
|
|
|
81
81
|
// Then
|
|
82
82
|
expect(scan).toHaveBeenCalledWith(
|
|
@@ -93,7 +93,7 @@ describe("createAnalyticsProvider", () => {
|
|
|
93
93
|
const rows = Array.from({ length: 1_001 }, (_, index) =>
|
|
94
94
|
eventRow(index.toString().padStart(4, "0"), 1_000, `install-${index}`),
|
|
95
95
|
);
|
|
96
|
-
const provider =
|
|
96
|
+
const provider = createInsightsProvider(inMemoryPersistence(rows));
|
|
97
97
|
|
|
98
98
|
await expect(provider.getBundleEventSummary("new")).resolves.toEqual({
|
|
99
99
|
installed: 1_001,
|
|
@@ -105,7 +105,7 @@ describe("createAnalyticsProvider", () => {
|
|
|
105
105
|
const rows = Array.from({ length: 101 }, (_, index) =>
|
|
106
106
|
eventRow(index.toString().padStart(4, "0"), 1_000, `install-${index}`),
|
|
107
107
|
);
|
|
108
|
-
const provider =
|
|
108
|
+
const provider = createInsightsProvider(inMemoryPersistence(rows, 17));
|
|
109
109
|
|
|
110
110
|
await expect(provider.getBundleEventSummary("new")).resolves.toEqual({
|
|
111
111
|
installed: 101,
|
|
@@ -146,7 +146,7 @@ describe("createAnalyticsProvider", () => {
|
|
|
146
146
|
other,
|
|
147
147
|
]);
|
|
148
148
|
const scan = vi.spyOn(persistence, "scan");
|
|
149
|
-
const provider =
|
|
149
|
+
const provider = createInsightsProvider(persistence);
|
|
150
150
|
|
|
151
151
|
await expect(
|
|
152
152
|
provider.getBundleEventSummaries(["new", "other", "missing"], "30d"),
|
|
@@ -166,7 +166,7 @@ describe("createAnalyticsProvider", () => {
|
|
|
166
166
|
from_bundle_id: null,
|
|
167
167
|
update_strategy: null,
|
|
168
168
|
};
|
|
169
|
-
const provider =
|
|
169
|
+
const provider = createInsightsProvider(inMemoryPersistence([unchanged]));
|
|
170
170
|
|
|
171
171
|
await expect(provider.getBundleEventOverview()).resolves.toEqual({
|
|
172
172
|
trackedInstallations: 1,
|
|
@@ -181,7 +181,7 @@ describe("createAnalyticsProvider", () => {
|
|
|
181
181
|
"rejects duplicate ids and rows outside strict cursor order",
|
|
182
182
|
async (...rows) => {
|
|
183
183
|
let delivered = false;
|
|
184
|
-
const provider =
|
|
184
|
+
const provider = createInsightsProvider({
|
|
185
185
|
async append() {},
|
|
186
186
|
async scan() {
|
|
187
187
|
if (delivered) return [];
|
|
@@ -192,14 +192,14 @@ describe("createAnalyticsProvider", () => {
|
|
|
192
192
|
|
|
193
193
|
await expect(provider.getBundleEventSummary("new")).rejects.toMatchObject(
|
|
194
194
|
{
|
|
195
|
-
name: "
|
|
195
|
+
name: "InsightsPersistenceOrderError",
|
|
196
196
|
},
|
|
197
197
|
);
|
|
198
198
|
},
|
|
199
199
|
);
|
|
200
200
|
|
|
201
|
-
it("fails instead of returning partial
|
|
202
|
-
const persistence:
|
|
201
|
+
it("fails instead of returning partial insights beyond 50,000 rows", async () => {
|
|
202
|
+
const persistence: InsightsPersistence = {
|
|
203
203
|
async append() {},
|
|
204
204
|
async scan(input) {
|
|
205
205
|
const start =
|
|
@@ -210,10 +210,10 @@ describe("createAnalyticsProvider", () => {
|
|
|
210
210
|
});
|
|
211
211
|
},
|
|
212
212
|
};
|
|
213
|
-
const provider =
|
|
213
|
+
const provider = createInsightsProvider(persistence);
|
|
214
214
|
|
|
215
215
|
await expect(provider.getBundleEventSummary("new")).rejects.toBeInstanceOf(
|
|
216
|
-
|
|
216
|
+
InsightsScanLimitExceededError,
|
|
217
217
|
);
|
|
218
218
|
});
|
|
219
219
|
});
|