@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,148 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ActiveInstallationOverview,
|
|
3
|
-
ActiveInstallationWindow,
|
|
4
|
-
} from "../domain";
|
|
5
|
-
import type { BundleEventPersistenceRow } from "../persistence";
|
|
6
|
-
|
|
7
|
-
const HOUR_MS = 60 * 60 * 1_000;
|
|
8
|
-
const DAY_MS = 24 * HOUR_MS;
|
|
9
|
-
|
|
10
|
-
export const ACTIVE_BUNDLE_EVENT_TYPES = [
|
|
11
|
-
"UPDATE_APPLIED",
|
|
12
|
-
"RECOVERED",
|
|
13
|
-
"RELEASE_ADOPTED",
|
|
14
|
-
"UNCHANGED",
|
|
15
|
-
] as const;
|
|
16
|
-
|
|
17
|
-
const activeWindowDefinitions = {
|
|
18
|
-
"24h": { bucketCount: 24, bucketSizeMs: HOUR_MS },
|
|
19
|
-
"7d": { bucketCount: 7, bucketSizeMs: DAY_MS },
|
|
20
|
-
"30d": { bucketCount: 30, bucketSizeMs: DAY_MS },
|
|
21
|
-
} as const satisfies Record<
|
|
22
|
-
ActiveInstallationWindow,
|
|
23
|
-
{ readonly bucketCount: number; readonly bucketSizeMs: number }
|
|
24
|
-
>;
|
|
25
|
-
|
|
26
|
-
type ActiveWindowDefinition =
|
|
27
|
-
(typeof activeWindowDefinitions)[ActiveInstallationWindow];
|
|
28
|
-
|
|
29
|
-
export function getActiveWindowDefinition(
|
|
30
|
-
window: ActiveInstallationWindow,
|
|
31
|
-
): ActiveWindowDefinition {
|
|
32
|
-
return activeWindowDefinitions[window];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
type ActiveOverviewRequest = {
|
|
36
|
-
readonly rows: readonly BundleEventPersistenceRow[];
|
|
37
|
-
readonly asOfMs: number;
|
|
38
|
-
readonly window: ActiveInstallationWindow;
|
|
39
|
-
readonly userId?: string;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
function compareCodePoints(left: string, right: string): number {
|
|
43
|
-
if (left < right) return -1;
|
|
44
|
-
if (left > right) return 1;
|
|
45
|
-
return 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isNewer(
|
|
49
|
-
candidate: BundleEventPersistenceRow,
|
|
50
|
-
current: BundleEventPersistenceRow,
|
|
51
|
-
): boolean {
|
|
52
|
-
return (
|
|
53
|
-
candidate.received_at_ms > current.received_at_ms ||
|
|
54
|
-
(candidate.received_at_ms === current.received_at_ms &&
|
|
55
|
-
compareCodePoints(candidate.id, current.id) > 0)
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function collectActiveInstallationOverview(
|
|
60
|
-
request: ActiveOverviewRequest,
|
|
61
|
-
): ActiveInstallationOverview {
|
|
62
|
-
const definition = getActiveWindowDefinition(request.window);
|
|
63
|
-
const windowStartMs =
|
|
64
|
-
request.asOfMs - definition.bucketCount * definition.bucketSizeMs;
|
|
65
|
-
const rows = request.rows.filter(
|
|
66
|
-
(row) =>
|
|
67
|
-
row.received_at_ms >= windowStartMs &&
|
|
68
|
-
row.received_at_ms < request.asOfMs,
|
|
69
|
-
);
|
|
70
|
-
const latestByInstall = new Map<string, BundleEventPersistenceRow>();
|
|
71
|
-
for (const row of rows) {
|
|
72
|
-
const current = latestByInstall.get(row.install_id);
|
|
73
|
-
if (current === undefined || isNewer(row, current)) {
|
|
74
|
-
latestByInstall.set(row.install_id, row);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
const selectedRows = [...latestByInstall.values()].filter(
|
|
78
|
-
(row) => request.userId === undefined || row.user_id === request.userId,
|
|
79
|
-
);
|
|
80
|
-
const selectedInstallIds = new Set(selectedRows.map((row) => row.install_id));
|
|
81
|
-
const bundleCounts = new Map<string, number>();
|
|
82
|
-
for (const row of selectedRows) {
|
|
83
|
-
bundleCounts.set(
|
|
84
|
-
row.to_bundle_id,
|
|
85
|
-
(bundleCounts.get(row.to_bundle_id) ?? 0) + 1,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
const latestByBucket = Array.from(
|
|
89
|
-
{ length: definition.bucketCount },
|
|
90
|
-
() => new Map<string, BundleEventPersistenceRow>(),
|
|
91
|
-
);
|
|
92
|
-
for (const row of rows) {
|
|
93
|
-
if (!selectedInstallIds.has(row.install_id)) continue;
|
|
94
|
-
const bucketIndex = Math.floor(
|
|
95
|
-
(row.received_at_ms - windowStartMs) / definition.bucketSizeMs,
|
|
96
|
-
);
|
|
97
|
-
const bucket = latestByBucket[bucketIndex];
|
|
98
|
-
if (bucket === undefined) continue;
|
|
99
|
-
const current = bucket.get(row.install_id);
|
|
100
|
-
if (current === undefined || isNewer(row, current)) {
|
|
101
|
-
bucket.set(row.install_id, row);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const bundleCountsByBucket = latestByBucket.map((bucket) => {
|
|
105
|
-
const counts = new Map<string, number>();
|
|
106
|
-
for (const row of bucket.values()) {
|
|
107
|
-
counts.set(row.to_bundle_id, (counts.get(row.to_bundle_id) ?? 0) + 1);
|
|
108
|
-
}
|
|
109
|
-
return counts;
|
|
110
|
-
});
|
|
111
|
-
const bundleObservationTotals = new Map<string, number>();
|
|
112
|
-
for (const counts of bundleCountsByBucket) {
|
|
113
|
-
for (const [bundleId, count] of counts) {
|
|
114
|
-
bundleObservationTotals.set(
|
|
115
|
-
bundleId,
|
|
116
|
-
(bundleObservationTotals.get(bundleId) ?? 0) + count,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
asOfMs: request.asOfMs,
|
|
122
|
-
window: request.window,
|
|
123
|
-
activeInstallations: selectedInstallIds.size,
|
|
124
|
-
series: latestByBucket.map((bucket, index) => ({
|
|
125
|
-
bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
|
|
126
|
-
value: bucket.size,
|
|
127
|
-
})),
|
|
128
|
-
bundleSeries: [...bundleObservationTotals]
|
|
129
|
-
.sort(
|
|
130
|
-
([leftId, leftTotal], [rightId, rightTotal]) =>
|
|
131
|
-
rightTotal - leftTotal || compareCodePoints(leftId, rightId),
|
|
132
|
-
)
|
|
133
|
-
.map(([bundleId]) => ({
|
|
134
|
-
bundleId,
|
|
135
|
-
series: bundleCountsByBucket.map((counts, index) => ({
|
|
136
|
-
bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
|
|
137
|
-
value: counts.get(bundleId) ?? 0,
|
|
138
|
-
})),
|
|
139
|
-
})),
|
|
140
|
-
bundles: [...bundleCounts]
|
|
141
|
-
.map(([bundleId, installations]) => ({ bundleId, installations }))
|
|
142
|
-
.sort(
|
|
143
|
-
(left, right) =>
|
|
144
|
-
right.installations - left.installations ||
|
|
145
|
-
compareCodePoints(left.bundleId, right.bundleId),
|
|
146
|
-
),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { BundleEventPersistenceRow } from "../persistence";
|
|
4
|
-
import { searchEventInstallations } from "./installationSearch";
|
|
5
|
-
|
|
6
|
-
type UpdateAppliedRow = BundleEventPersistenceRow & {
|
|
7
|
-
readonly type: "UPDATE_APPLIED";
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
function eventRow(overrides: Partial<UpdateAppliedRow> = {}): UpdateAppliedRow {
|
|
11
|
-
return {
|
|
12
|
-
id: "event-1",
|
|
13
|
-
type: "UPDATE_APPLIED",
|
|
14
|
-
install_id: "install-1",
|
|
15
|
-
user_id: "user-1",
|
|
16
|
-
username: "alice",
|
|
17
|
-
from_release_id: null,
|
|
18
|
-
from_bundle_id: "bundle-old",
|
|
19
|
-
to_release_id: null,
|
|
20
|
-
to_bundle_id: "bundle-new",
|
|
21
|
-
platform: "ios",
|
|
22
|
-
app_version: "1.0.0",
|
|
23
|
-
channel: "production",
|
|
24
|
-
cohort: "default",
|
|
25
|
-
update_strategy: "fingerprint",
|
|
26
|
-
fingerprint_hash: null,
|
|
27
|
-
sdk_version: null,
|
|
28
|
-
received_at_ms: 1,
|
|
29
|
-
...overrides,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe("searchEventInstallations", () => {
|
|
34
|
-
it.each([
|
|
35
|
-
["installation id", "INSTALL-1"],
|
|
36
|
-
["user id", "USER-1"],
|
|
37
|
-
["username", "ALICE"],
|
|
38
|
-
])("matches %s without case sensitivity", (_identity, query) => {
|
|
39
|
-
const row = eventRow();
|
|
40
|
-
|
|
41
|
-
const result = searchEventInstallations({
|
|
42
|
-
rows: [row],
|
|
43
|
-
query,
|
|
44
|
-
limit: 10,
|
|
45
|
-
offset: 0,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
expect(result.data.map(({ installId }) => installId)).toEqual([
|
|
49
|
-
"install-1",
|
|
50
|
-
]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("returns the newest state when an older identity matches", () => {
|
|
54
|
-
const rows = [
|
|
55
|
-
eventRow({
|
|
56
|
-
id: "event-old",
|
|
57
|
-
username: "legacy-name",
|
|
58
|
-
to_bundle_id: "bundle-old",
|
|
59
|
-
received_at_ms: 1,
|
|
60
|
-
}),
|
|
61
|
-
eventRow({
|
|
62
|
-
id: "event-new",
|
|
63
|
-
username: "current-name",
|
|
64
|
-
to_bundle_id: "bundle-current",
|
|
65
|
-
received_at_ms: 2,
|
|
66
|
-
}),
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
const result = searchEventInstallations({
|
|
70
|
-
rows,
|
|
71
|
-
query: "LEGACY-NAME",
|
|
72
|
-
limit: 10,
|
|
73
|
-
offset: 0,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
expect(result.data).toEqual([
|
|
77
|
-
{
|
|
78
|
-
installId: "install-1",
|
|
79
|
-
username: "current-name",
|
|
80
|
-
userId: "user-1",
|
|
81
|
-
lastKnownBundleId: "bundle-current",
|
|
82
|
-
latestStatus: "UPDATE_APPLIED",
|
|
83
|
-
platform: "ios",
|
|
84
|
-
appVersion: "1.0.0",
|
|
85
|
-
channel: "production",
|
|
86
|
-
cohort: "default",
|
|
87
|
-
receivedAtMs: 2,
|
|
88
|
-
},
|
|
89
|
-
]);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("sorts installation ids by code point", () => {
|
|
93
|
-
const rows = [
|
|
94
|
-
eventRow({ install_id: "install-z" }),
|
|
95
|
-
eventRow({ install_id: "install-a" }),
|
|
96
|
-
eventRow({ install_id: "install-A" }),
|
|
97
|
-
];
|
|
98
|
-
|
|
99
|
-
const result = searchEventInstallations({
|
|
100
|
-
rows,
|
|
101
|
-
query: "",
|
|
102
|
-
limit: 10,
|
|
103
|
-
offset: 0,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
expect(result.data.map(({ installId }) => installId)).toEqual([
|
|
107
|
-
"install-A",
|
|
108
|
-
"install-a",
|
|
109
|
-
"install-z",
|
|
110
|
-
]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("applies the offset and caps the page size at 100", () => {
|
|
114
|
-
const rows = Array.from({ length: 105 }, (_, index) =>
|
|
115
|
-
eventRow({ install_id: `install-${index.toString().padStart(3, "0")}` }),
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
const result = searchEventInstallations({
|
|
119
|
-
rows,
|
|
120
|
-
query: "",
|
|
121
|
-
limit: 200,
|
|
122
|
-
offset: 2,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
expect(result.data).toHaveLength(100);
|
|
126
|
-
expect(result.data.at(0)?.installId).toBe("install-002");
|
|
127
|
-
expect(result.data.at(-1)?.installId).toBe("install-101");
|
|
128
|
-
expect(result.pagination).toEqual({ total: 105, limit: 200, offset: 2 });
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import type { InstallationSearchRow, OffsetPaginationResult } from "../domain";
|
|
2
|
-
import type { BundleEventPersistenceRow } from "../persistence";
|
|
3
|
-
import { compareCodePoints, compareEventNewest } from "./scan";
|
|
4
|
-
|
|
5
|
-
type InstallationSearchRequest = {
|
|
6
|
-
readonly rows: readonly BundleEventPersistenceRow[];
|
|
7
|
-
readonly query: string;
|
|
8
|
-
readonly limit: number;
|
|
9
|
-
readonly offset: number;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
function toSearchRow(row: BundleEventPersistenceRow): InstallationSearchRow {
|
|
13
|
-
return {
|
|
14
|
-
installId: row.install_id,
|
|
15
|
-
username: row.username,
|
|
16
|
-
userId: row.user_id,
|
|
17
|
-
lastKnownBundleId: row.to_bundle_id,
|
|
18
|
-
latestStatus: row.type,
|
|
19
|
-
platform: row.platform,
|
|
20
|
-
appVersion: row.app_version,
|
|
21
|
-
channel: row.channel,
|
|
22
|
-
cohort: row.cohort,
|
|
23
|
-
receivedAtMs: row.received_at_ms,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function matchesIdentity(
|
|
28
|
-
row: BundleEventPersistenceRow,
|
|
29
|
-
query: string,
|
|
30
|
-
): boolean {
|
|
31
|
-
return (
|
|
32
|
-
row.install_id.toLowerCase().includes(query) ||
|
|
33
|
-
row.user_id?.toLowerCase().includes(query) === true ||
|
|
34
|
-
row.username?.toLowerCase().includes(query) === true
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function searchEventInstallations(
|
|
39
|
-
request: InstallationSearchRequest,
|
|
40
|
-
): OffsetPaginationResult<InstallationSearchRow> {
|
|
41
|
-
const query = request.query.toLowerCase();
|
|
42
|
-
const matchingInstallIds = new Set<string>();
|
|
43
|
-
for (const row of request.rows) {
|
|
44
|
-
if (query.length === 0 || matchesIdentity(row, query)) {
|
|
45
|
-
matchingInstallIds.add(row.install_id);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
const latestByInstall = new Map<string, BundleEventPersistenceRow>();
|
|
49
|
-
for (const row of request.rows) {
|
|
50
|
-
if (!matchingInstallIds.has(row.install_id)) continue;
|
|
51
|
-
const current = latestByInstall.get(row.install_id);
|
|
52
|
-
if (current === undefined || compareEventNewest(row, current) < 0) {
|
|
53
|
-
latestByInstall.set(row.install_id, row);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const matchingRows = [...latestByInstall.values()].sort((left, right) =>
|
|
57
|
-
compareCodePoints(left.install_id, right.install_id),
|
|
58
|
-
);
|
|
59
|
-
const pageSize = Math.min(Math.max(request.limit, 0), 100);
|
|
60
|
-
const page = matchingRows.slice(request.offset, request.offset + pageSize);
|
|
61
|
-
return {
|
|
62
|
-
data: page.map(toSearchRow),
|
|
63
|
-
pagination: {
|
|
64
|
-
total: matchingRows.length,
|
|
65
|
-
limit: request.limit,
|
|
66
|
-
offset: request.offset,
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { createUUIDv7 } from "@hot-updater/plugin-core";
|
|
2
|
-
|
|
3
|
-
import type { CreateBundleEventRequest } from "../domain.js";
|
|
4
|
-
import type { BundleEventPersistenceRow } from "../persistence.js";
|
|
5
|
-
|
|
6
|
-
export function createBundleEventRow(
|
|
7
|
-
input: CreateBundleEventRequest,
|
|
8
|
-
): BundleEventPersistenceRow {
|
|
9
|
-
const base = {
|
|
10
|
-
id: createUUIDv7(),
|
|
11
|
-
install_id: input.installId,
|
|
12
|
-
user_id: input.userId ?? null,
|
|
13
|
-
username: input.username ?? null,
|
|
14
|
-
from_release_id: input.fromReleaseId,
|
|
15
|
-
to_release_id: input.toReleaseId,
|
|
16
|
-
to_bundle_id: input.toBundleId,
|
|
17
|
-
platform: input.platform,
|
|
18
|
-
app_version: input.appVersion,
|
|
19
|
-
channel: input.channel,
|
|
20
|
-
cohort: input.cohort,
|
|
21
|
-
fingerprint_hash: input.fingerprintHash,
|
|
22
|
-
sdk_version: input.sdkVersion ?? null,
|
|
23
|
-
received_at_ms: Date.now(),
|
|
24
|
-
};
|
|
25
|
-
switch (input.type) {
|
|
26
|
-
case "UPDATE_APPLIED":
|
|
27
|
-
case "RECOVERED":
|
|
28
|
-
case "RELEASE_ADOPTED":
|
|
29
|
-
return {
|
|
30
|
-
...base,
|
|
31
|
-
type: input.type,
|
|
32
|
-
from_bundle_id: input.fromBundleId,
|
|
33
|
-
update_strategy: input.updateStrategy,
|
|
34
|
-
};
|
|
35
|
-
case "UNCHANGED":
|
|
36
|
-
return {
|
|
37
|
-
...base,
|
|
38
|
-
type: input.type,
|
|
39
|
-
from_bundle_id: null,
|
|
40
|
-
update_strategy: null,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { AnalyticsScanLimitExceededError } from "../errors";
|
|
4
|
-
import type {
|
|
5
|
-
AnalyticsPersistence,
|
|
6
|
-
AnalyticsScanInput,
|
|
7
|
-
BundleEventPersistenceRow,
|
|
8
|
-
} from "../persistence";
|
|
9
|
-
import { createAnalyticsProvider } from "./provider";
|
|
10
|
-
import { getWindowRange } from "./scan";
|
|
11
|
-
|
|
12
|
-
const eventRow = (
|
|
13
|
-
id: string,
|
|
14
|
-
receivedAtMs: number,
|
|
15
|
-
installId = `install-${id}`,
|
|
16
|
-
): BundleEventPersistenceRow => ({
|
|
17
|
-
id,
|
|
18
|
-
type: "UPDATE_APPLIED",
|
|
19
|
-
install_id: installId,
|
|
20
|
-
user_id: null,
|
|
21
|
-
username: null,
|
|
22
|
-
from_release_id: null,
|
|
23
|
-
from_bundle_id: "old",
|
|
24
|
-
to_release_id: null,
|
|
25
|
-
to_bundle_id: "new",
|
|
26
|
-
platform: "ios",
|
|
27
|
-
app_version: "1.0.0",
|
|
28
|
-
channel: "production",
|
|
29
|
-
cohort: "default",
|
|
30
|
-
update_strategy: "fingerprint",
|
|
31
|
-
fingerprint_hash: null,
|
|
32
|
-
sdk_version: null,
|
|
33
|
-
received_at_ms: receivedAtMs,
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const compareRows = (
|
|
37
|
-
left: BundleEventPersistenceRow,
|
|
38
|
-
right: BundleEventPersistenceRow,
|
|
39
|
-
): number =>
|
|
40
|
-
left.received_at_ms - right.received_at_ms || left.id.localeCompare(right.id);
|
|
41
|
-
|
|
42
|
-
const inMemoryPersistence = (
|
|
43
|
-
initialRows: readonly BundleEventPersistenceRow[],
|
|
44
|
-
maximumPageSize = Number.MAX_SAFE_INTEGER,
|
|
45
|
-
): AnalyticsPersistence => {
|
|
46
|
-
const rows = [...initialRows];
|
|
47
|
-
return {
|
|
48
|
-
async append(row) {
|
|
49
|
-
rows.push(row);
|
|
50
|
-
},
|
|
51
|
-
async scan(input: AnalyticsScanInput) {
|
|
52
|
-
return rows
|
|
53
|
-
.filter((row) => row.received_at_ms < input.beforeReceivedAtMs)
|
|
54
|
-
.filter(
|
|
55
|
-
(row) =>
|
|
56
|
-
input.after === undefined ||
|
|
57
|
-
row.received_at_ms > input.after.receivedAtMs ||
|
|
58
|
-
(row.received_at_ms === input.after.receivedAtMs &&
|
|
59
|
-
row.id > input.after.id),
|
|
60
|
-
)
|
|
61
|
-
.toSorted(compareRows)
|
|
62
|
-
.slice(0, Math.min(input.limit, maximumPageSize));
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
describe("createAnalyticsProvider", () => {
|
|
68
|
-
it("starts windowed analytics scans at the storage lower boundary", async () => {
|
|
69
|
-
// Given
|
|
70
|
-
const now = Date.UTC(2026, 1, 2, 12);
|
|
71
|
-
vi.spyOn(Date, "now").mockReturnValue(now);
|
|
72
|
-
const scan = vi.fn<AnalyticsPersistence["scan"]>().mockResolvedValue([]);
|
|
73
|
-
const provider = createAnalyticsProvider({
|
|
74
|
-
async append() {},
|
|
75
|
-
scan,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// When
|
|
79
|
-
await provider.getBundleEventAnalytics("new", "24h", 20, 0);
|
|
80
|
-
|
|
81
|
-
// Then
|
|
82
|
-
expect(scan).toHaveBeenCalledWith(
|
|
83
|
-
expect.objectContaining({
|
|
84
|
-
after: {
|
|
85
|
-
id: "00000000-0000-0000-0000-000000000000",
|
|
86
|
-
receivedAtMs: getWindowRange("24h", now).rangeStart,
|
|
87
|
-
},
|
|
88
|
-
}),
|
|
89
|
-
);
|
|
90
|
-
vi.restoreAllMocks();
|
|
91
|
-
});
|
|
92
|
-
it("counts distinct installations across cursor pages", async () => {
|
|
93
|
-
const rows = Array.from({ length: 1_001 }, (_, index) =>
|
|
94
|
-
eventRow(index.toString().padStart(4, "0"), 1_000, `install-${index}`),
|
|
95
|
-
);
|
|
96
|
-
const provider = createAnalyticsProvider(inMemoryPersistence(rows));
|
|
97
|
-
|
|
98
|
-
await expect(provider.getBundleEventSummary("new")).resolves.toEqual({
|
|
99
|
-
installed: 1_001,
|
|
100
|
-
recovered: 0,
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("continues scanning when persistence caps pages below the requested limit", async () => {
|
|
105
|
-
const rows = Array.from({ length: 101 }, (_, index) =>
|
|
106
|
-
eventRow(index.toString().padStart(4, "0"), 1_000, `install-${index}`),
|
|
107
|
-
);
|
|
108
|
-
const provider = createAnalyticsProvider(inMemoryPersistence(rows, 17));
|
|
109
|
-
|
|
110
|
-
await expect(provider.getBundleEventSummary("new")).resolves.toEqual({
|
|
111
|
-
installed: 101,
|
|
112
|
-
recovered: 0,
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("collects distinct 30-day movement for multiple Bundles in one scan", async () => {
|
|
117
|
-
const now = Date.UTC(2026, 7, 18, 12);
|
|
118
|
-
vi.spyOn(Date, "now").mockReturnValue(now);
|
|
119
|
-
const applied = eventRow("applied", now - 1_000, "install-applied");
|
|
120
|
-
const duplicateApplied = eventRow(
|
|
121
|
-
"applied-again",
|
|
122
|
-
now - 900,
|
|
123
|
-
"install-applied",
|
|
124
|
-
);
|
|
125
|
-
const recovered = {
|
|
126
|
-
...eventRow("recovered", now - 800, "install-recovered"),
|
|
127
|
-
type: "RECOVERED" as const,
|
|
128
|
-
from_bundle_id: "new",
|
|
129
|
-
to_bundle_id: "fallback",
|
|
130
|
-
update_strategy: "fingerprint" as const,
|
|
131
|
-
} satisfies BundleEventPersistenceRow;
|
|
132
|
-
const other = {
|
|
133
|
-
...eventRow("other", now - 700, "install-other"),
|
|
134
|
-
to_bundle_id: "other",
|
|
135
|
-
};
|
|
136
|
-
const outsideWindow = eventRow(
|
|
137
|
-
"outside-window",
|
|
138
|
-
now - 31 * 24 * 60 * 60 * 1_000,
|
|
139
|
-
"install-old",
|
|
140
|
-
);
|
|
141
|
-
const persistence = inMemoryPersistence([
|
|
142
|
-
outsideWindow,
|
|
143
|
-
applied,
|
|
144
|
-
duplicateApplied,
|
|
145
|
-
recovered,
|
|
146
|
-
other,
|
|
147
|
-
]);
|
|
148
|
-
const scan = vi.spyOn(persistence, "scan");
|
|
149
|
-
const provider = createAnalyticsProvider(persistence);
|
|
150
|
-
|
|
151
|
-
await expect(
|
|
152
|
-
provider.getBundleEventSummaries(["new", "other", "missing"], "30d"),
|
|
153
|
-
).resolves.toEqual([
|
|
154
|
-
{ bundleId: "new", installed: 1, recovered: 1 },
|
|
155
|
-
{ bundleId: "other", installed: 1, recovered: 0 },
|
|
156
|
-
{ bundleId: "missing", installed: 0, recovered: 0 },
|
|
157
|
-
]);
|
|
158
|
-
expect(scan).toHaveBeenCalledTimes(2);
|
|
159
|
-
vi.restoreAllMocks();
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("counts an installation whose current bundle is reported by UNCHANGED", async () => {
|
|
163
|
-
const unchanged: BundleEventPersistenceRow = {
|
|
164
|
-
...eventRow("unchanged", 1, "install-unchanged"),
|
|
165
|
-
type: "UNCHANGED",
|
|
166
|
-
from_bundle_id: null,
|
|
167
|
-
update_strategy: null,
|
|
168
|
-
};
|
|
169
|
-
const provider = createAnalyticsProvider(inMemoryPersistence([unchanged]));
|
|
170
|
-
|
|
171
|
-
await expect(provider.getBundleEventOverview()).resolves.toEqual({
|
|
172
|
-
trackedInstallations: 1,
|
|
173
|
-
bundles: [{ bundleId: "new", installations: 1 }],
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it.each([
|
|
178
|
-
[eventRow("same", 1), eventRow("same", 2)],
|
|
179
|
-
[eventRow("later", 2), eventRow("earlier", 1)],
|
|
180
|
-
])(
|
|
181
|
-
"rejects duplicate ids and rows outside strict cursor order",
|
|
182
|
-
async (...rows) => {
|
|
183
|
-
let delivered = false;
|
|
184
|
-
const provider = createAnalyticsProvider({
|
|
185
|
-
async append() {},
|
|
186
|
-
async scan() {
|
|
187
|
-
if (delivered) return [];
|
|
188
|
-
delivered = true;
|
|
189
|
-
return rows;
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
await expect(provider.getBundleEventSummary("new")).rejects.toMatchObject(
|
|
194
|
-
{
|
|
195
|
-
name: "AnalyticsPersistenceOrderError",
|
|
196
|
-
},
|
|
197
|
-
);
|
|
198
|
-
},
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
it("fails instead of returning partial analytics beyond 50,000 rows", async () => {
|
|
202
|
-
const persistence: AnalyticsPersistence = {
|
|
203
|
-
async append() {},
|
|
204
|
-
async scan(input) {
|
|
205
|
-
const start =
|
|
206
|
-
input.after === undefined ? 0 : Number(input.after.id) + 1;
|
|
207
|
-
return Array.from({ length: input.limit }, (_, index) => {
|
|
208
|
-
const id = start + index;
|
|
209
|
-
return eventRow(String(id), id);
|
|
210
|
-
});
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
const provider = createAnalyticsProvider(persistence);
|
|
214
|
-
|
|
215
|
-
await expect(provider.getBundleEventSummary("new")).rejects.toBeInstanceOf(
|
|
216
|
-
AnalyticsScanLimitExceededError,
|
|
217
|
-
);
|
|
218
|
-
});
|
|
219
|
-
});
|