@hot-updater/server 1.0.0-rc.0 → 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 +11 -4
- package/dist/adapters/drizzle.d.cts +0 -1
- package/dist/adapters/drizzle.d.mts +0 -1
- package/dist/adapters/drizzle.mjs +12 -4
- 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 +27 -12
- package/dist/createHotUpdaterCore.d.cts +8 -8
- package/dist/createHotUpdaterCore.d.mts +8 -8
- package/dist/createHotUpdaterCore.mjs +26 -12
- 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.cjs +5 -5
- package/dist/handler.d.cts +1 -2
- package/dist/handler.d.mts +1 -2
- package/dist/handler.mjs +5 -5
- 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 +2 -2
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +2 -2
- package/dist/insights/domain.d.cts +89 -0
- package/dist/insights/domain.d.mts +89 -0
- package/dist/insights/errors.cjs +15 -0
- package/dist/insights/errors.mjs +14 -0
- package/dist/{analytics → insights}/eventInput.cjs +58 -14
- package/dist/{analytics → insights}/eventInput.mjs +58 -16
- 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 +93 -0
- package/dist/insights/queryInput.mjs +91 -0
- package/dist/insights/routes.cjs +58 -0
- package/dist/insights/routes.mjs +56 -0
- package/dist/insights/types.d.cts +32 -0
- package/dist/insights/types.d.mts +32 -0
- 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 +22 -4
- 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 +3 -3
- package/src/createHotUpdaterCore.ts +30 -16
- 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 +11 -3
- package/src/db/types.ts +13 -7
- package/src/handler-standalone.integration.spec.ts +6 -8
- package/src/handler.ts +12 -12
- package/src/index.ts +3 -3
- package/src/insights/domain.ts +103 -0
- package/src/insights/errors.ts +11 -0
- package/src/{analytics → insights}/eventInput.ts +71 -14
- package/src/insights/insights.integration.spec.ts +381 -0
- package/src/insights/provider.spec.ts +385 -0
- package/src/insights/provider.ts +591 -0
- package/src/insights/queryInput.ts +141 -0
- package/src/insights/routes.ts +104 -0
- package/src/insights/types.ts +55 -0
- 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/analytics/bounded/activeOverview.cjs +0 -87
- package/dist/analytics/bounded/activeOverview.mjs +0 -85
- package/dist/analytics/bounded/installationSearch.cjs +0 -42
- package/dist/analytics/bounded/installationSearch.mjs +0 -42
- package/dist/analytics/bounded/persistence.cjs +0 -39
- package/dist/analytics/bounded/persistence.mjs +0 -38
- package/dist/analytics/bounded/provider.cjs +0 -170
- package/dist/analytics/bounded/provider.d.cts +0 -7
- package/dist/analytics/bounded/provider.d.mts +0 -7
- package/dist/analytics/bounded/provider.mjs +0 -170
- package/dist/analytics/bounded/scan.cjs +0 -144
- package/dist/analytics/bounded/scan.mjs +0 -138
- package/dist/analytics/domain.d.cts +0 -121
- package/dist/analytics/domain.d.mts +0 -121
- 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/dist/analytics/queryInput.cjs +0 -58
- package/dist/analytics/queryInput.mjs +0 -55
- package/dist/analytics/routes.cjs +0 -63
- package/dist/analytics/routes.mjs +0 -61
- package/dist/analytics/types.d.cts +0 -17
- package/dist/analytics/types.d.mts +0 -17
- package/src/analytics/analytics.integration.spec.ts +0 -144
- package/src/analytics/bounded/activeOverview.spec.ts +0 -189
- package/src/analytics/bounded/activeOverview.ts +0 -148
- package/src/analytics/bounded/installationSearch.spec.ts +0 -130
- package/src/analytics/bounded/installationSearch.ts +0 -69
- package/src/analytics/bounded/persistence.ts +0 -43
- package/src/analytics/bounded/provider.spec.ts +0 -219
- package/src/analytics/bounded/provider.ts +0 -229
- package/src/analytics/bounded/scan.ts +0 -235
- package/src/analytics/domain.ts +0 -139
- package/src/analytics/errors.ts +0 -18
- package/src/analytics/persistence.ts +0 -10
- package/src/analytics/queryInput.ts +0 -102
- package/src/analytics/routes.ts +0 -123
- package/src/analytics/types.ts +0 -44
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { createUUIDv7, type BundleEventRow } from "@hot-updater/plugin-core";
|
|
2
|
+
|
|
1
3
|
import type {
|
|
2
4
|
CreateBundleEventRequest,
|
|
3
5
|
CreateBundleEventRequestBase,
|
|
4
6
|
} from "./domain";
|
|
5
7
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
InsightsBadRequestError,
|
|
9
|
+
InsightsPayloadTooLargeError,
|
|
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,9 +43,12 @@ 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
|
-
throw new
|
|
51
|
+
throw new InsightsBadRequestError(`Invalid event field: ${key}`);
|
|
46
52
|
}
|
|
47
53
|
return value;
|
|
48
54
|
}
|
|
@@ -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);
|
|
@@ -63,7 +80,7 @@ async function readBoundedText(request: Request): Promise<string> {
|
|
|
63
80
|
Number.isSafeInteger(declaredByteLength) &&
|
|
64
81
|
declaredByteLength > EVENT_BODY_MAX_BYTES
|
|
65
82
|
) {
|
|
66
|
-
throw new
|
|
83
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
67
84
|
}
|
|
68
85
|
if (request.body === null) return "";
|
|
69
86
|
const reader = request.body.getReader();
|
|
@@ -76,7 +93,7 @@ async function readBoundedText(request: Request): Promise<string> {
|
|
|
76
93
|
byteLength += result.value.byteLength;
|
|
77
94
|
if (byteLength > EVENT_BODY_MAX_BYTES) {
|
|
78
95
|
await reader.cancel();
|
|
79
|
-
throw new
|
|
96
|
+
throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
|
|
80
97
|
}
|
|
81
98
|
text += decoder.decode(result.value, { stream: true });
|
|
82
99
|
}
|
|
@@ -89,7 +106,7 @@ async function parseJson(request: Request): Promise<unknown> {
|
|
|
89
106
|
return JSON.parse(text);
|
|
90
107
|
} catch (error) {
|
|
91
108
|
if (error instanceof SyntaxError) {
|
|
92
|
-
throw new
|
|
109
|
+
throw new InsightsBadRequestError("Invalid event payload");
|
|
93
110
|
}
|
|
94
111
|
throw error;
|
|
95
112
|
}
|
|
@@ -100,18 +117,18 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
100
117
|
!isRecord(payload) ||
|
|
101
118
|
Object.keys(payload).some((key) => !eventKeys.has(key))
|
|
102
119
|
) {
|
|
103
|
-
throw new
|
|
120
|
+
throw new InsightsBadRequestError("Invalid event payload");
|
|
104
121
|
}
|
|
105
122
|
const platform = requireStringField(payload, "platform");
|
|
106
123
|
if (platform !== "ios" && platform !== "android") {
|
|
107
|
-
throw new
|
|
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") }),
|
|
@@ -134,7 +151,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
134
151
|
case "RELEASE_ADOPTED": {
|
|
135
152
|
const updateStrategy = requireStringField(payload, "updateStrategy");
|
|
136
153
|
if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") {
|
|
137
|
-
throw new
|
|
154
|
+
throw new InsightsBadRequestError(
|
|
138
155
|
"Invalid event field: updateStrategy",
|
|
139
156
|
);
|
|
140
157
|
}
|
|
@@ -147,7 +164,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
147
164
|
}
|
|
148
165
|
case "UNCHANGED":
|
|
149
166
|
if (payload.fromBundleId !== null || payload.updateStrategy !== null) {
|
|
150
|
-
throw new
|
|
167
|
+
throw new InsightsBadRequestError("Invalid unchanged event shape");
|
|
151
168
|
}
|
|
152
169
|
return {
|
|
153
170
|
...base,
|
|
@@ -156,7 +173,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
|
|
|
156
173
|
updateStrategy: null,
|
|
157
174
|
};
|
|
158
175
|
default:
|
|
159
|
-
throw new
|
|
176
|
+
throw new InsightsBadRequestError("Invalid event field: type");
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
|
|
4
|
+
import { createHotUpdater } from "../index";
|
|
5
|
+
import { createSchemaManagedDatabase } from "../runtime.testFixtures";
|
|
6
|
+
import { HOT_UPDATER_SCHEMA_VERSION } from "../schema/types";
|
|
7
|
+
|
|
8
|
+
const event = {
|
|
9
|
+
appVersion: "1.0.0",
|
|
10
|
+
channel: "production",
|
|
11
|
+
cohort: "default",
|
|
12
|
+
fingerprintHash: null,
|
|
13
|
+
fromBundleId: null,
|
|
14
|
+
fromReleaseId: null,
|
|
15
|
+
installId: "install-1",
|
|
16
|
+
platform: "ios",
|
|
17
|
+
toBundleId: "bundle-1",
|
|
18
|
+
toReleaseId: null,
|
|
19
|
+
type: "UNCHANGED",
|
|
20
|
+
updateStrategy: null,
|
|
21
|
+
userId: "user-1",
|
|
22
|
+
username: "Jane",
|
|
23
|
+
sdkVersion: "2.0.0",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
const eventRequest = (body: unknown = event) =>
|
|
27
|
+
new Request("https://example.com/events", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.useRealTimers();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("createHotUpdater Insights", () => {
|
|
38
|
+
it("persists an event and serves the lean Insights views", async () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
|
|
41
|
+
const receivedAtMs = Date.now();
|
|
42
|
+
const database = createInMemoryDatabasePlugin();
|
|
43
|
+
const append = vi.spyOn(database.models.insights, "record");
|
|
44
|
+
const hotUpdater = createHotUpdater({
|
|
45
|
+
database,
|
|
46
|
+
clientAccess: { type: "public" },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const ingestion = await hotUpdater.handlers.client(eventRequest());
|
|
50
|
+
vi.advanceTimersByTime(1);
|
|
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
|
+
),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(ingestion.status).toBe(204);
|
|
67
|
+
expect(append).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({
|
|
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
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
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",
|
|
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
|
+
);
|
|
159
|
+
expect(overview.status).toBe(200);
|
|
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" },
|
|
210
|
+
});
|
|
211
|
+
expect(
|
|
212
|
+
(
|
|
213
|
+
await hotUpdater.handlers.admin(
|
|
214
|
+
new Request(`https://example.com${path}`),
|
|
215
|
+
)
|
|
216
|
+
).status,
|
|
217
|
+
).toBe(400);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("checks the official schema before Insights reads and writes", async () => {
|
|
221
|
+
const database = createSchemaManagedDatabase(
|
|
222
|
+
"kysely",
|
|
223
|
+
HOT_UPDATER_SCHEMA_VERSION,
|
|
224
|
+
);
|
|
225
|
+
const createMigrator = vi.spyOn(database, "createMigrator");
|
|
226
|
+
const hotUpdater = createHotUpdater({
|
|
227
|
+
database,
|
|
228
|
+
clientAccess: { type: "public" },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
232
|
+
await hotUpdater.insights.listEvents({});
|
|
233
|
+
|
|
234
|
+
expect(createMigrator).toHaveBeenCalledOnce();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("keeps ingestion and queries on separate handler surfaces", async () => {
|
|
238
|
+
const hotUpdater = createHotUpdater({
|
|
239
|
+
database: createInMemoryDatabasePlugin(),
|
|
240
|
+
clientAccess: { type: "public" },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
|
|
244
|
+
const clientQuery = await hotUpdater.handlers.client(
|
|
245
|
+
new Request("https://example.com/events"),
|
|
246
|
+
);
|
|
247
|
+
const adminIngestion = await hotUpdater.handlers.admin(eventRequest());
|
|
248
|
+
const adminQuery = await hotUpdater.handlers.admin(
|
|
249
|
+
new Request("https://example.com/events"),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(clientQuery.status).toBe(404);
|
|
253
|
+
expect(adminIngestion.status).toBe(404);
|
|
254
|
+
expect(adminQuery.status).toBe(200);
|
|
255
|
+
expect(adminQuery.headers.get("cache-control")).toBe("private, no-store");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("returns a stable client error for malformed event payloads", async () => {
|
|
259
|
+
const hotUpdater = createHotUpdater({
|
|
260
|
+
database: createInMemoryDatabasePlugin(),
|
|
261
|
+
clientAccess: { type: "public" },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const response = await hotUpdater.handlers.client(
|
|
265
|
+
eventRequest({ ...event, platform: "web" }),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(response.status).toBe(400);
|
|
269
|
+
await expect(response.json()).resolves.toEqual({
|
|
270
|
+
error: "Invalid event field: platform",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
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
|
+
|
|
363
|
+
it.each(["fromReleaseId", "toReleaseId"] as const)(
|
|
364
|
+
"rejects an omitted %s instead of treating it as null",
|
|
365
|
+
async (field) => {
|
|
366
|
+
const hotUpdater = createHotUpdater({
|
|
367
|
+
database: createInMemoryDatabasePlugin(),
|
|
368
|
+
clientAccess: { type: "public" },
|
|
369
|
+
});
|
|
370
|
+
const payload: Record<string, unknown> = { ...event };
|
|
371
|
+
delete payload[field];
|
|
372
|
+
|
|
373
|
+
const response = await hotUpdater.handlers.client(eventRequest(payload));
|
|
374
|
+
|
|
375
|
+
expect(response.status).toBe(400);
|
|
376
|
+
await expect(response.json()).resolves.toEqual({
|
|
377
|
+
error: `Invalid event field: ${field}`,
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
});
|