@hot-updater/server 1.0.0-rc.1 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/_rolldown/runtime.cjs +1 -1
- package/dist/adapters/databaseConstraintErrors.cjs +0 -1
- package/dist/adapters/databasePluginUtils.cjs +0 -1
- package/dist/adapters/drizzle.cjs +10 -3
- package/dist/adapters/drizzle.d.cts +0 -1
- package/dist/adapters/drizzle.d.mts +0 -1
- package/dist/adapters/drizzle.mjs +11 -3
- package/dist/adapters/drizzleCrud.cjs +62 -1
- package/dist/adapters/drizzleCrud.mjs +61 -2
- package/dist/adapters/drizzleLazyDB.cjs +6 -1
- package/dist/adapters/drizzleLazyDB.mjs +6 -1
- package/dist/adapters/drizzleQuery.cjs +3 -2
- package/dist/adapters/drizzleQuery.mjs +3 -1
- package/dist/adapters/insightsTransaction.cjs +22 -0
- package/dist/adapters/insightsTransaction.mjs +22 -0
- package/dist/adapters/kysely.cjs +2 -1
- package/dist/adapters/kysely.d.cts +0 -1
- package/dist/adapters/kysely.d.mts +0 -1
- package/dist/adapters/kysely.mjs +3 -1
- package/dist/adapters/kyselyCrud.cjs +54 -3
- package/dist/adapters/kyselyCrud.mjs +54 -3
- package/dist/adapters/kyselyQuery.cjs +2 -2
- package/dist/adapters/kyselyQuery.mjs +2 -1
- package/dist/adapters/mongodb.cjs +27 -1
- package/dist/adapters/mongodb.d.cts +1 -1
- package/dist/adapters/mongodb.d.mts +1 -1
- package/dist/adapters/mongodb.mjs +28 -1
- package/dist/adapters/mongodbCollections.cjs +2 -0
- package/dist/adapters/mongodbCollections.mjs +2 -0
- package/dist/adapters/mongodbQuery.cjs +21 -4
- package/dist/adapters/mongodbQuery.mjs +21 -5
- package/dist/adapters/mongodbReads.cjs +91 -29
- package/dist/adapters/mongodbReads.mjs +92 -29
- package/dist/adapters/mongodbWrites.cjs +16 -2
- package/dist/adapters/mongodbWrites.mjs +18 -3
- package/dist/adapters/prisma.cjs +55 -7
- package/dist/adapters/prisma.d.cts +0 -1
- package/dist/adapters/prisma.d.mts +0 -1
- package/dist/adapters/prisma.mjs +56 -7
- package/dist/adapters/prismaQuery.cjs +0 -1
- package/dist/adapters/prismaRows.cjs +22 -1
- package/dist/adapters/prismaRows.mjs +22 -1
- package/dist/apiKeys.cjs +6 -2
- package/dist/apiKeys.d.cts +0 -1
- package/dist/apiKeys.d.mts +0 -1
- package/dist/apiKeys.mjs +3 -3
- package/dist/createHotUpdaterCore.cjs +24 -9
- package/dist/createHotUpdaterCore.d.cts +1 -1
- package/dist/createHotUpdaterCore.d.mts +1 -1
- package/dist/createHotUpdaterCore.mjs +23 -9
- package/dist/db/createBundleDiff.cjs +2 -2
- package/dist/db/createBundleDiff.d.cts +1 -5
- package/dist/db/createBundleDiff.d.mts +1 -5
- package/dist/db/databasePluginCore.cjs +0 -1
- package/dist/db/fixedMigrator.cjs +2 -2
- package/dist/db/fixedMigrator.mjs +2 -2
- package/dist/db/fixedMigratorMongo.cjs +6 -4
- package/dist/db/fixedMigratorMongo.mjs +6 -4
- package/dist/db/index.d.cts +0 -1
- package/dist/db/index.d.mts +0 -1
- package/dist/db/releaseCatalog.cjs +0 -1
- package/dist/db/releaseCatalog.d.cts +0 -1
- package/dist/db/releaseCatalog.d.mts +0 -1
- package/dist/db/schema/insightsCollation.cjs +14 -0
- package/dist/db/schema/insightsCollation.mjs +14 -0
- package/dist/db/schema/sql.cjs +11 -7
- package/dist/db/schema/sql.mjs +8 -8
- package/dist/db/schemaGenerators.cjs +28 -9
- package/dist/db/schemaGenerators.mjs +28 -9
- package/dist/db/schemaReadiness.cjs +2 -0
- package/dist/db/schemaReadiness.d.cts +1 -0
- package/dist/db/schemaReadiness.d.mts +1 -0
- package/dist/db/schemaReadiness.mjs +2 -0
- package/dist/db/sqlMigrationExecution.cjs +1 -1
- package/dist/db/sqlMigrationExecution.mjs +1 -0
- package/dist/db/types.cjs +3 -1
- package/dist/db/types.d.cts +3 -6
- package/dist/db/types.d.mts +3 -6
- package/dist/db/types.mjs +2 -2
- package/dist/db/updateArtifacts.cjs +0 -1
- package/dist/handler.d.cts +1 -2
- package/dist/handler.d.mts +1 -2
- package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
- package/dist/handlerReleaseManagementRoutes.cjs +0 -1
- package/dist/handlerTypes.d.cts +0 -1
- package/dist/handlerTypes.d.mts +0 -1
- package/dist/handlerVersionRoutes.d.cts +1 -0
- package/dist/handlerVersionRoutes.d.mts +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +1 -1
- package/dist/insights/domain.d.cts +37 -69
- package/dist/insights/domain.d.mts +37 -69
- package/dist/insights/errors.cjs +1 -8
- package/dist/insights/errors.mjs +2 -8
- package/dist/insights/eventInput.cjs +49 -5
- package/dist/insights/eventInput.mjs +48 -6
- package/dist/insights/provider.cjs +314 -0
- package/dist/insights/provider.d.cts +6 -0
- package/dist/insights/provider.d.mts +6 -0
- package/dist/insights/provider.mjs +314 -0
- package/dist/insights/queryInput.cjs +78 -43
- package/dist/insights/queryInput.mjs +76 -40
- package/dist/insights/routes.cjs +20 -25
- package/dist/insights/routes.mjs +22 -27
- package/dist/insights/types.d.cts +27 -12
- package/dist/insights/types.d.mts +27 -12
- package/dist/node.d.cts +0 -1
- package/dist/node.d.mts +0 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/schema/index.cjs +17 -3
- package/dist/schema/index.mjs +4 -4
- package/dist/schema/v1_0_0.cjs +72 -30
- package/dist/schema/v1_0_0.mjs +72 -31
- package/dist/storageAccess.cjs +0 -1
- package/dist/types/index.d.cts +0 -1
- package/dist/types/index.d.mts +0 -1
- package/package.json +7 -7
- package/src/adapters/databasePluginTestDatabase.ts +14 -0
- package/src/adapters/drizzle.spec.ts +99 -0
- package/src/adapters/drizzle.ts +21 -3
- package/src/adapters/drizzleCrud.ts +154 -2
- package/src/adapters/drizzleLazyDB.ts +17 -3
- package/src/adapters/insightsTransaction.ts +32 -0
- package/src/adapters/kysely.spec.ts +29 -0
- package/src/adapters/kysely.ts +10 -1
- package/src/adapters/kyselyCrud.ts +121 -0
- package/src/adapters/mongodb.integration.spec.ts +210 -0
- package/src/adapters/mongodb.spec.ts +69 -1
- package/src/adapters/mongodb.ts +66 -2
- package/src/adapters/mongodbCollections.ts +5 -0
- package/src/adapters/mongodbQuery.ts +32 -3
- package/src/adapters/mongodbReads.ts +69 -0
- package/src/adapters/mongodbTestClient.ts +41 -6
- package/src/adapters/mongodbTestFilter.ts +4 -1
- package/src/adapters/mongodbWrites.ts +27 -0
- package/src/adapters/prisma.spec.ts +97 -0
- package/src/adapters/prisma.ts +103 -3
- package/src/adapters/prismaRows.ts +37 -1
- package/src/adapters/prismaTestClient.ts +22 -5
- package/src/apiKeys.integration.spec.ts +1 -1
- package/src/createHotUpdaterCore.ts +21 -7
- package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
- package/src/db/fixedMigrator.spec.ts +65 -0
- package/src/db/fixedMigratorMongo.spec.ts +56 -9
- package/src/db/fixedMigratorMongo.ts +10 -10
- package/src/db/index.spec.ts +8 -1
- package/src/db/schema/insightsCollation.ts +21 -0
- package/src/db/schema/sql.ts +8 -5
- package/src/db/schemaGenerators.ts +41 -13
- package/src/db/types.spec.ts +8 -0
- package/src/db/types.ts +10 -4
- package/src/handler-standalone.integration.spec.ts +5 -7
- package/src/index.ts +2 -2
- package/src/insights/domain.ts +39 -75
- package/src/insights/errors.ts +0 -7
- package/src/insights/eventInput.ts +60 -3
- package/src/insights/insights.integration.spec.ts +254 -17
- package/src/insights/provider.spec.ts +385 -0
- package/src/insights/provider.ts +591 -0
- package/src/insights/queryInput.ts +112 -73
- package/src/insights/routes.ts +37 -56
- package/src/insights/types.ts +47 -36
- package/src/schema/dsl-all-versions.spec.ts +3 -1
- package/src/schema/v1_0_0.spec.ts +77 -3
- package/src/schema/v1_0_0.ts +94 -30
- package/dist/insights/bounded/activeOverview.cjs +0 -87
- package/dist/insights/bounded/activeOverview.mjs +0 -85
- package/dist/insights/bounded/installationSearch.cjs +0 -42
- package/dist/insights/bounded/installationSearch.mjs +0 -42
- package/dist/insights/bounded/persistence.cjs +0 -39
- package/dist/insights/bounded/persistence.mjs +0 -38
- package/dist/insights/bounded/provider.cjs +0 -170
- package/dist/insights/bounded/provider.d.cts +0 -7
- package/dist/insights/bounded/provider.d.mts +0 -7
- package/dist/insights/bounded/provider.mjs +0 -170
- package/dist/insights/bounded/scan.cjs +0 -144
- package/dist/insights/bounded/scan.mjs +0 -138
- package/dist/insights/persistence.d.cts +0 -6
- package/dist/insights/persistence.d.mts +0 -6
- package/src/insights/bounded/activeOverview.spec.ts +0 -189
- package/src/insights/bounded/activeOverview.ts +0 -148
- package/src/insights/bounded/installationSearch.spec.ts +0 -130
- package/src/insights/bounded/installationSearch.ts +0 -69
- package/src/insights/bounded/persistence.ts +0 -43
- package/src/insights/bounded/provider.spec.ts +0 -219
- package/src/insights/bounded/provider.ts +0 -229
- package/src/insights/bounded/scan.ts +0 -235
- package/src/insights/persistence.ts +0 -10
package/src/insights/domain.ts
CHANGED
|
@@ -15,17 +15,7 @@ export type CreateBundleEventRequestBase = {
|
|
|
15
15
|
|
|
16
16
|
export type CreateBundleEventRequest =
|
|
17
17
|
| (CreateBundleEventRequestBase & {
|
|
18
|
-
readonly type: "UPDATE_APPLIED";
|
|
19
|
-
readonly fromBundleId: string;
|
|
20
|
-
readonly updateStrategy: "fingerprint" | "appVersion";
|
|
21
|
-
})
|
|
22
|
-
| (CreateBundleEventRequestBase & {
|
|
23
|
-
readonly type: "RECOVERED";
|
|
24
|
-
readonly fromBundleId: string;
|
|
25
|
-
readonly updateStrategy: "fingerprint" | "appVersion";
|
|
26
|
-
})
|
|
27
|
-
| (CreateBundleEventRequestBase & {
|
|
28
|
-
readonly type: "RELEASE_ADOPTED";
|
|
18
|
+
readonly type: "UPDATE_APPLIED" | "RECOVERED" | "RELEASE_ADOPTED";
|
|
29
19
|
readonly fromBundleId: string;
|
|
30
20
|
readonly updateStrategy: "fingerprint" | "appVersion";
|
|
31
21
|
})
|
|
@@ -35,28 +25,20 @@ export type CreateBundleEventRequest =
|
|
|
35
25
|
readonly updateStrategy: null;
|
|
36
26
|
});
|
|
37
27
|
|
|
38
|
-
export type BundleEventSummary = {
|
|
39
|
-
readonly installed: number;
|
|
40
|
-
readonly recovered: number;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export type BundleEventSummaryByBundle = BundleEventSummary & {
|
|
44
|
-
readonly bundleId: string;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export type BundleEventInsightsWindow = "24h" | "7d" | "30d" | "all";
|
|
48
28
|
export type ActiveInstallationWindow = "24h" | "7d" | "30d";
|
|
49
29
|
|
|
50
|
-
export type
|
|
30
|
+
export type EventHistoryRow = {
|
|
31
|
+
readonly id: string;
|
|
51
32
|
readonly installId: string;
|
|
52
|
-
readonly
|
|
53
|
-
readonly userId: string | null;
|
|
54
|
-
readonly lastKnownBundleId: string;
|
|
55
|
-
readonly latestStatus:
|
|
33
|
+
readonly type:
|
|
56
34
|
| "UPDATE_APPLIED"
|
|
57
35
|
| "RECOVERED"
|
|
58
36
|
| "RELEASE_ADOPTED"
|
|
59
37
|
| "UNCHANGED";
|
|
38
|
+
readonly fromBundleId: string | null;
|
|
39
|
+
readonly toBundleId: string;
|
|
40
|
+
readonly username: string | null;
|
|
41
|
+
readonly userId: string | null;
|
|
60
42
|
readonly platform: "ios" | "android";
|
|
61
43
|
readonly appVersion: string;
|
|
62
44
|
readonly channel: string;
|
|
@@ -64,13 +46,17 @@ export type InstallationSearchRow = {
|
|
|
64
46
|
readonly receivedAtMs: number;
|
|
65
47
|
};
|
|
66
48
|
|
|
67
|
-
export type InstallationHistoryRow = {
|
|
68
|
-
readonly id: string;
|
|
49
|
+
export type InstallationHistoryRow = EventHistoryRow & {
|
|
69
50
|
readonly type: "UPDATE_APPLIED" | "RECOVERED";
|
|
70
51
|
readonly fromBundleId: string;
|
|
71
|
-
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type InstallationRow = {
|
|
55
|
+
readonly installId: string;
|
|
72
56
|
readonly username: string | null;
|
|
73
57
|
readonly userId: string | null;
|
|
58
|
+
readonly lastKnownBundleId: string;
|
|
59
|
+
readonly latestStatus: EventHistoryRow["type"];
|
|
74
60
|
readonly platform: "ios" | "android";
|
|
75
61
|
readonly appVersion: string;
|
|
76
62
|
readonly channel: string;
|
|
@@ -78,62 +64,40 @@ export type InstallationHistoryRow = {
|
|
|
78
64
|
readonly receivedAtMs: number;
|
|
79
65
|
};
|
|
80
66
|
|
|
81
|
-
export type
|
|
82
|
-
readonly data: readonly
|
|
83
|
-
readonly
|
|
84
|
-
readonly total: number;
|
|
85
|
-
readonly limit: number;
|
|
86
|
-
readonly offset: number;
|
|
87
|
-
};
|
|
67
|
+
export type CursorPage<T> = {
|
|
68
|
+
readonly data: readonly T[];
|
|
69
|
+
readonly nextCursor: string | null;
|
|
88
70
|
};
|
|
89
71
|
|
|
90
|
-
export type
|
|
91
|
-
readonly
|
|
92
|
-
readonly value: number;
|
|
72
|
+
export type EventCursorPage<T extends EventHistoryRow> = CursorPage<T> & {
|
|
73
|
+
readonly beforeReceivedAtMs: number;
|
|
93
74
|
};
|
|
94
75
|
|
|
95
|
-
export type
|
|
96
|
-
readonly
|
|
97
|
-
readonly
|
|
76
|
+
export type InsightsScope = {
|
|
77
|
+
readonly platform: "ios" | "android";
|
|
78
|
+
readonly channel: string;
|
|
98
79
|
};
|
|
99
80
|
|
|
100
|
-
export type
|
|
101
|
-
readonly
|
|
102
|
-
readonly
|
|
103
|
-
readonly installed: readonly InsightsSeriesPoint[];
|
|
104
|
-
readonly recovered: readonly InsightsSeriesPoint[];
|
|
105
|
-
};
|
|
106
|
-
readonly cohorts: {
|
|
107
|
-
readonly installed: readonly InsightsCohortPoint[];
|
|
108
|
-
readonly recovered: readonly InsightsCohortPoint[];
|
|
109
|
-
};
|
|
110
|
-
readonly recentEvents: OffsetPaginationResult<InstallationHistoryRow>;
|
|
81
|
+
export type InsightsBundleSelection = InsightsScope & {
|
|
82
|
+
readonly bundleId: string;
|
|
83
|
+
readonly outcome: "applied" | "recovered" | "adopted";
|
|
111
84
|
};
|
|
112
85
|
|
|
113
|
-
export type
|
|
114
|
-
readonly
|
|
115
|
-
readonly
|
|
116
|
-
readonly bundleId: string;
|
|
117
|
-
readonly installations: number;
|
|
118
|
-
}[];
|
|
86
|
+
export type InsightsCountMeasurement = {
|
|
87
|
+
readonly count: number;
|
|
88
|
+
readonly measuredAtMs: number;
|
|
119
89
|
};
|
|
120
90
|
|
|
121
|
-
export type
|
|
122
|
-
readonly asOfMs: number;
|
|
91
|
+
export type ReportingOverview = InsightsScope & {
|
|
123
92
|
readonly window: ActiveInstallationWindow;
|
|
124
|
-
readonly
|
|
125
|
-
readonly
|
|
126
|
-
readonly
|
|
127
|
-
|
|
128
|
-
readonly series: readonly InsightsSeriesPoint[];
|
|
129
|
-
}[];
|
|
130
|
-
readonly bundles: readonly {
|
|
93
|
+
readonly sinceMs: number;
|
|
94
|
+
readonly beforeReceivedAtMs: number;
|
|
95
|
+
readonly reportingInstallations: InsightsCountMeasurement;
|
|
96
|
+
readonly bundle?: {
|
|
131
97
|
readonly bundleId: string;
|
|
132
|
-
readonly
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
readonly window: ActiveInstallationWindow;
|
|
138
|
-
readonly userId?: string;
|
|
98
|
+
readonly reportingInstallations: InsightsCountMeasurement;
|
|
99
|
+
readonly appliedReports: InsightsCountMeasurement;
|
|
100
|
+
readonly recoveredReports: InsightsCountMeasurement;
|
|
101
|
+
readonly adoptedReports: InsightsCountMeasurement;
|
|
102
|
+
};
|
|
139
103
|
};
|
package/src/insights/errors.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
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
1
|
export class InsightsBadRequestError extends Error {
|
|
9
2
|
readonly name = "InsightsBadRequestError";
|
|
10
3
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createUUIDv7, type BundleEventRow } from "@hot-updater/plugin-core";
|
|
2
|
+
|
|
1
3
|
import type {
|
|
2
4
|
CreateBundleEventRequest,
|
|
3
5
|
CreateBundleEventRequestBase,
|
|
@@ -8,6 +10,7 @@ import {
|
|
|
8
10
|
} from "./errors";
|
|
9
11
|
|
|
10
12
|
const MAX_EVENT_STRING_LENGTH = 1_024;
|
|
13
|
+
const MAX_IDENTITY_LENGTH = 255;
|
|
11
14
|
export const EVENT_BODY_MAX_BYTES = 16 * 1_024;
|
|
12
15
|
|
|
13
16
|
const eventKeys = new Set([
|
|
@@ -40,7 +43,10 @@ function requireStringField(
|
|
|
40
43
|
if (
|
|
41
44
|
typeof value !== "string" ||
|
|
42
45
|
value.length === 0 ||
|
|
43
|
-
value.length > MAX_EVENT_STRING_LENGTH
|
|
46
|
+
value.length > MAX_EVENT_STRING_LENGTH ||
|
|
47
|
+
new TextDecoder("utf-8", { ignoreBOM: true }).decode(
|
|
48
|
+
new TextEncoder().encode(value),
|
|
49
|
+
) !== value
|
|
44
50
|
) {
|
|
45
51
|
throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
46
52
|
}
|
|
@@ -55,6 +61,17 @@ function requireNullableStringField(
|
|
|
55
61
|
return requireStringField(payload, key);
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
function requireIdentityField(
|
|
65
|
+
payload: Readonly<Record<string, unknown>>,
|
|
66
|
+
key: "installId" | "userId",
|
|
67
|
+
): string {
|
|
68
|
+
const value = requireStringField(payload, key);
|
|
69
|
+
if (value.length > MAX_IDENTITY_LENGTH) {
|
|
70
|
+
throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
async function readBoundedText(request: Request): Promise<string> {
|
|
59
76
|
const contentLength = request.headers.get("content-length");
|
|
60
77
|
const declaredByteLength = Number(contentLength);
|
|
@@ -107,11 +124,11 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
107
124
|
throw new InsightsBadRequestError("Invalid event field: platform");
|
|
108
125
|
}
|
|
109
126
|
const base: CreateBundleEventRequestBase = {
|
|
110
|
-
installId:
|
|
127
|
+
installId: requireIdentityField(payload, "installId"),
|
|
111
128
|
toBundleId: requireStringField(payload, "toBundleId"),
|
|
112
129
|
...(payload.userId === undefined
|
|
113
130
|
? {}
|
|
114
|
-
: { userId:
|
|
131
|
+
: { userId: requireIdentityField(payload, "userId") }),
|
|
115
132
|
...(payload.username === undefined
|
|
116
133
|
? {}
|
|
117
134
|
: { username: requireStringField(payload, "username") }),
|
|
@@ -166,3 +183,43 @@ export async function parseBundleEventRequest(
|
|
|
166
183
|
const payload = await parseJson(request);
|
|
167
184
|
return requireEvent(payload);
|
|
168
185
|
}
|
|
186
|
+
|
|
187
|
+
export function createBundleEventRow(
|
|
188
|
+
input: CreateBundleEventRequest,
|
|
189
|
+
): BundleEventRow {
|
|
190
|
+
input = requireEvent(input);
|
|
191
|
+
const base = {
|
|
192
|
+
app_version: input.appVersion,
|
|
193
|
+
channel: input.channel,
|
|
194
|
+
cohort: input.cohort,
|
|
195
|
+
fingerprint_hash: input.fingerprintHash,
|
|
196
|
+
from_release_id: input.fromReleaseId,
|
|
197
|
+
id: createUUIDv7(),
|
|
198
|
+
install_id: input.installId,
|
|
199
|
+
platform: input.platform,
|
|
200
|
+
received_at_ms: Date.now(),
|
|
201
|
+
sdk_version: input.sdkVersion ?? null,
|
|
202
|
+
to_bundle_id: input.toBundleId,
|
|
203
|
+
to_release_id: input.toReleaseId,
|
|
204
|
+
user_id: input.userId ?? null,
|
|
205
|
+
username: input.username ?? null,
|
|
206
|
+
};
|
|
207
|
+
switch (input.type) {
|
|
208
|
+
case "UPDATE_APPLIED":
|
|
209
|
+
case "RECOVERED":
|
|
210
|
+
case "RELEASE_ADOPTED":
|
|
211
|
+
return {
|
|
212
|
+
...base,
|
|
213
|
+
from_bundle_id: input.fromBundleId,
|
|
214
|
+
type: input.type,
|
|
215
|
+
update_strategy: input.updateStrategy,
|
|
216
|
+
};
|
|
217
|
+
case "UNCHANGED":
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
from_bundle_id: null,
|
|
221
|
+
type: input.type,
|
|
222
|
+
update_strategy: null,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -18,6 +18,8 @@ const event = {
|
|
|
18
18
|
toReleaseId: null,
|
|
19
19
|
type: "UNCHANGED",
|
|
20
20
|
updateStrategy: null,
|
|
21
|
+
userId: "user-1",
|
|
22
|
+
username: "Jane",
|
|
21
23
|
sdkVersion: "2.0.0",
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
@@ -33,11 +35,12 @@ afterEach(() => {
|
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
describe("createHotUpdater Insights", () => {
|
|
36
|
-
it("
|
|
38
|
+
it("persists an event and serves the lean Insights views", async () => {
|
|
37
39
|
vi.useFakeTimers();
|
|
38
40
|
vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
|
|
41
|
+
const receivedAtMs = Date.now();
|
|
39
42
|
const database = createInMemoryDatabasePlugin();
|
|
40
|
-
const append = vi.spyOn(database.models.insights, "
|
|
43
|
+
const append = vi.spyOn(database.models.insights, "record");
|
|
41
44
|
const hotUpdater = createHotUpdater({
|
|
42
45
|
database,
|
|
43
46
|
clientAccess: { type: "public" },
|
|
@@ -45,28 +48,173 @@ describe("createHotUpdater Insights", () => {
|
|
|
45
48
|
|
|
46
49
|
const ingestion = await hotUpdater.handlers.client(eventRequest());
|
|
47
50
|
vi.advanceTimersByTime(1);
|
|
48
|
-
const
|
|
49
|
-
new Request("https://example.com/
|
|
51
|
+
const events = await hotUpdater.handlers.admin(
|
|
52
|
+
new Request("https://example.com/events"),
|
|
53
|
+
);
|
|
54
|
+
const installation = await hotUpdater.handlers.admin(
|
|
55
|
+
new Request("https://example.com/installations/install-1"),
|
|
56
|
+
);
|
|
57
|
+
const matches = await hotUpdater.handlers.admin(
|
|
58
|
+
new Request("https://example.com/installations?userId=user-1"),
|
|
59
|
+
);
|
|
60
|
+
const active = await hotUpdater.handlers.admin(
|
|
61
|
+
new Request(
|
|
62
|
+
"https://example.com/overview?platform=ios&channel=production&window=24h",
|
|
63
|
+
),
|
|
50
64
|
);
|
|
51
65
|
|
|
52
66
|
expect(ingestion.status).toBe(204);
|
|
53
67
|
expect(append).toHaveBeenCalledWith(
|
|
54
68
|
expect.objectContaining({
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
event: expect.objectContaining({
|
|
70
|
+
install_id: "install-1",
|
|
71
|
+
sdk_version: "2.0.0",
|
|
72
|
+
to_bundle_id: "bundle-1",
|
|
73
|
+
type: "UNCHANGED",
|
|
74
|
+
}),
|
|
59
75
|
}),
|
|
60
76
|
);
|
|
61
|
-
expect(
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
expect(events.status).toBe(200);
|
|
78
|
+
await expect(events.json()).resolves.toMatchObject({
|
|
79
|
+
data: [
|
|
80
|
+
{
|
|
81
|
+
id: expect.any(String),
|
|
82
|
+
installId: "install-1",
|
|
83
|
+
receivedAtMs,
|
|
84
|
+
type: "UNCHANGED",
|
|
85
|
+
userId: "user-1",
|
|
86
|
+
username: "Jane",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
nextCursor: null,
|
|
90
|
+
});
|
|
91
|
+
expect(installation.status).toBe(200);
|
|
92
|
+
await expect(installation.json()).resolves.toMatchObject({
|
|
93
|
+
installId: "install-1",
|
|
94
|
+
latestStatus: "UNCHANGED",
|
|
95
|
+
userId: "user-1",
|
|
96
|
+
});
|
|
97
|
+
expect(matches.status).toBe(200);
|
|
98
|
+
await expect(matches.json()).resolves.toMatchObject({
|
|
99
|
+
data: [{ installId: "install-1", userId: "user-1" }],
|
|
100
|
+
nextCursor: null,
|
|
101
|
+
});
|
|
102
|
+
expect(active.status).toBe(200);
|
|
103
|
+
await expect(active.json()).resolves.toEqual({
|
|
104
|
+
platform: "ios",
|
|
105
|
+
channel: "production",
|
|
106
|
+
sinceMs: Date.now() - 24 * 60 * 60 * 1_000,
|
|
107
|
+
beforeReceivedAtMs: Date.now(),
|
|
108
|
+
reportingInstallations: { count: 1, measuredAtMs: Date.now() },
|
|
109
|
+
window: "24h",
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("serves scoped bundle counts and the same half-open recovery drill-down", async () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
const hotUpdater = createHotUpdater({
|
|
116
|
+
database: createInMemoryDatabasePlugin(),
|
|
117
|
+
clientAccess: { type: "public" },
|
|
118
|
+
});
|
|
119
|
+
const report = async (
|
|
120
|
+
receivedAtMs: number,
|
|
121
|
+
overrides: Record<string, unknown>,
|
|
122
|
+
) => {
|
|
123
|
+
vi.setSystemTime(receivedAtMs);
|
|
124
|
+
const response = await hotUpdater.handlers.client(
|
|
125
|
+
eventRequest({ ...event, ...overrides }),
|
|
126
|
+
);
|
|
127
|
+
expect(response.status).toBe(204);
|
|
128
|
+
};
|
|
129
|
+
await report(100, {
|
|
130
|
+
type: "UPDATE_APPLIED",
|
|
131
|
+
fromBundleId: "A",
|
|
132
|
+
toBundleId: "B",
|
|
133
|
+
updateStrategy: "appVersion",
|
|
134
|
+
});
|
|
135
|
+
await report(200, {
|
|
136
|
+
type: "RECOVERED",
|
|
137
|
+
fromBundleId: "B",
|
|
138
|
+
toBundleId: "A",
|
|
139
|
+
updateStrategy: "appVersion",
|
|
140
|
+
});
|
|
141
|
+
await report(300, {
|
|
142
|
+
installId: "install-2",
|
|
143
|
+
type: "RELEASE_ADOPTED",
|
|
144
|
+
fromBundleId: "B",
|
|
145
|
+
toBundleId: "B",
|
|
146
|
+
updateStrategy: "appVersion",
|
|
147
|
+
});
|
|
148
|
+
await report(400, {
|
|
149
|
+
installId: "install-3",
|
|
150
|
+
platform: "android",
|
|
151
|
+
toBundleId: "B",
|
|
64
152
|
});
|
|
153
|
+
vi.setSystemTime(500);
|
|
154
|
+
const overview = await hotUpdater.handlers.admin(
|
|
155
|
+
new Request(
|
|
156
|
+
"https://example.com/overview?platform=ios&channel=production&window=24h&bundleId=B",
|
|
157
|
+
),
|
|
158
|
+
);
|
|
65
159
|
expect(overview.status).toBe(200);
|
|
66
|
-
await expect(overview.json()).resolves.
|
|
67
|
-
|
|
68
|
-
|
|
160
|
+
await expect(overview.json()).resolves.toMatchObject({
|
|
161
|
+
sinceMs: 0,
|
|
162
|
+
beforeReceivedAtMs: 500,
|
|
163
|
+
reportingInstallations: { count: 2 },
|
|
164
|
+
bundle: {
|
|
165
|
+
bundleId: "B",
|
|
166
|
+
reportingInstallations: { count: 1 },
|
|
167
|
+
appliedReports: { count: 1 },
|
|
168
|
+
recoveredReports: { count: 1 },
|
|
169
|
+
adoptedReports: { count: 1 },
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const drilldown = await hotUpdater.handlers.admin(
|
|
173
|
+
new Request(
|
|
174
|
+
"https://example.com/events?platform=ios&channel=production&bundleId=B&outcome=recovered&sinceMs=200&beforeReceivedAtMs=300",
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
await expect(drilldown.json()).resolves.toMatchObject({
|
|
178
|
+
data: [
|
|
179
|
+
{
|
|
180
|
+
type: "RECOVERED",
|
|
181
|
+
fromBundleId: "B",
|
|
182
|
+
toBundleId: "A",
|
|
183
|
+
receivedAtMs: 200,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
nextCursor: null,
|
|
187
|
+
});
|
|
188
|
+
const outside = await hotUpdater.handlers.admin(
|
|
189
|
+
new Request(
|
|
190
|
+
"https://example.com/events?platform=ios&channel=production&bundleId=B&outcome=recovered&sinceMs=0&beforeReceivedAtMs=200",
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
await expect(outside.json()).resolves.toMatchObject({
|
|
194
|
+
data: [],
|
|
195
|
+
nextCursor: null,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it.each([
|
|
200
|
+
"/overview?window=24h",
|
|
201
|
+
"/overview?platform=ios&channel=production&platform=android",
|
|
202
|
+
"/events?bundleId=B",
|
|
203
|
+
"/events?platform=ios&channel=production&bundleId=B&outcome=UNCHANGED",
|
|
204
|
+
"/events?sinceMs=20&beforeReceivedAtMs=10",
|
|
205
|
+
"/installations/install-1/events?platform=ios&channel=production&bundleId=B&outcome=applied",
|
|
206
|
+
])("rejects ambiguous or invalid Insights query %s", async (path) => {
|
|
207
|
+
const hotUpdater = createHotUpdater({
|
|
208
|
+
database: createInMemoryDatabasePlugin(),
|
|
209
|
+
clientAccess: { type: "public" },
|
|
69
210
|
});
|
|
211
|
+
expect(
|
|
212
|
+
(
|
|
213
|
+
await hotUpdater.handlers.admin(
|
|
214
|
+
new Request(`https://example.com${path}`),
|
|
215
|
+
)
|
|
216
|
+
).status,
|
|
217
|
+
).toBe(400);
|
|
70
218
|
});
|
|
71
219
|
|
|
72
220
|
it("checks the official schema before Insights reads and writes", async () => {
|
|
@@ -81,7 +229,7 @@ describe("createHotUpdater Insights", () => {
|
|
|
81
229
|
});
|
|
82
230
|
|
|
83
231
|
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
84
|
-
await hotUpdater.insights
|
|
232
|
+
await hotUpdater.insights.listEvents({});
|
|
85
233
|
|
|
86
234
|
expect(createMigrator).toHaveBeenCalledOnce();
|
|
87
235
|
});
|
|
@@ -94,11 +242,11 @@ describe("createHotUpdater Insights", () => {
|
|
|
94
242
|
|
|
95
243
|
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
96
244
|
const clientQuery = await hotUpdater.handlers.client(
|
|
97
|
-
new Request("https://example.com/
|
|
245
|
+
new Request("https://example.com/events"),
|
|
98
246
|
);
|
|
99
247
|
const adminIngestion = await hotUpdater.handlers.admin(eventRequest());
|
|
100
248
|
const adminQuery = await hotUpdater.handlers.admin(
|
|
101
|
-
new Request("https://example.com/
|
|
249
|
+
new Request("https://example.com/events"),
|
|
102
250
|
);
|
|
103
251
|
|
|
104
252
|
expect(clientQuery.status).toBe(404);
|
|
@@ -123,6 +271,95 @@ describe("createHotUpdater Insights", () => {
|
|
|
123
271
|
});
|
|
124
272
|
});
|
|
125
273
|
|
|
274
|
+
it.each(["installId", "userId"] as const)(
|
|
275
|
+
"rejects a 256-character event %s",
|
|
276
|
+
async (field) => {
|
|
277
|
+
const hotUpdater = createHotUpdater({
|
|
278
|
+
database: createInMemoryDatabasePlugin(),
|
|
279
|
+
clientAccess: { type: "public" },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const response = await hotUpdater.handlers.client(
|
|
283
|
+
eventRequest({ ...event, [field]: "x".repeat(256) }),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(response.status).toBe(400);
|
|
287
|
+
await expect(response.json()).resolves.toEqual({
|
|
288
|
+
error: `Invalid event field: ${field}`,
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
it("preserves a leading Unicode BOM in exact installation and current-user IDs", async () => {
|
|
294
|
+
const hotUpdater = createHotUpdater({
|
|
295
|
+
database: createInMemoryDatabasePlugin(),
|
|
296
|
+
clientAccess: { type: "public" },
|
|
297
|
+
});
|
|
298
|
+
const installId = "\uFEFFinstall";
|
|
299
|
+
const userId = "\uFEFFuser";
|
|
300
|
+
expect(
|
|
301
|
+
(
|
|
302
|
+
await hotUpdater.handlers.client(
|
|
303
|
+
eventRequest({ ...event, installId, userId }),
|
|
304
|
+
)
|
|
305
|
+
).status,
|
|
306
|
+
).toBe(204);
|
|
307
|
+
const installation = await hotUpdater.handlers.admin(
|
|
308
|
+
new Request(
|
|
309
|
+
`https://example.com/installations/${encodeURIComponent(installId)}`,
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
expect(installation.status).toBe(200);
|
|
313
|
+
await expect(installation.json()).resolves.toMatchObject({
|
|
314
|
+
installId,
|
|
315
|
+
userId,
|
|
316
|
+
});
|
|
317
|
+
const matches = await hotUpdater.handlers.admin(
|
|
318
|
+
new Request(
|
|
319
|
+
`https://example.com/installations?userId=${encodeURIComponent(userId)}`,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
expect(matches.status).toBe(200);
|
|
323
|
+
await expect(matches.json()).resolves.toMatchObject({
|
|
324
|
+
data: [{ installId, userId }],
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it.each(["\uD800", "\uDC00"])(
|
|
329
|
+
"rejects an unmatched surrogate identity %j",
|
|
330
|
+
async (installId) => {
|
|
331
|
+
const hotUpdater = createHotUpdater({
|
|
332
|
+
database: createInMemoryDatabasePlugin(),
|
|
333
|
+
clientAccess: { type: "public" },
|
|
334
|
+
});
|
|
335
|
+
expect(
|
|
336
|
+
(
|
|
337
|
+
await hotUpdater.handlers.client(
|
|
338
|
+
eventRequest({ ...event, installId }),
|
|
339
|
+
)
|
|
340
|
+
).status,
|
|
341
|
+
).toBe(400);
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
it("rejects 256-character installation query identities", async () => {
|
|
346
|
+
const hotUpdater = createHotUpdater({
|
|
347
|
+
database: createInMemoryDatabasePlugin(),
|
|
348
|
+
clientAccess: { type: "public" },
|
|
349
|
+
});
|
|
350
|
+
const tooLong = "x".repeat(256);
|
|
351
|
+
|
|
352
|
+
const user = await hotUpdater.handlers.admin(
|
|
353
|
+
new Request(`https://example.com/installations?userId=${tooLong}`),
|
|
354
|
+
);
|
|
355
|
+
const installation = await hotUpdater.handlers.admin(
|
|
356
|
+
new Request(`https://example.com/installations/${tooLong}`),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(user.status).toBe(400);
|
|
360
|
+
expect(installation.status).toBe(400);
|
|
361
|
+
});
|
|
362
|
+
|
|
126
363
|
it.each(["fromReleaseId", "toReleaseId"] as const)(
|
|
127
364
|
"rejects an omitted %s instead of treating it as null",
|
|
128
365
|
async (field) => {
|