@hot-updater/server 0.36.6 → 1.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/databaseConstraintErrors.cjs +23 -0
- package/dist/adapters/databaseConstraintErrors.mjs +21 -0
- package/dist/adapters/databasePluginUtils.cjs +117 -0
- package/dist/adapters/databasePluginUtils.mjs +106 -0
- package/dist/adapters/drizzle.cjs +53 -176
- package/dist/adapters/drizzle.d.cts +6 -4
- package/dist/adapters/drizzle.d.mts +6 -4
- package/dist/adapters/drizzle.mjs +54 -177
- package/dist/adapters/drizzleCrud.cjs +250 -0
- package/dist/adapters/drizzleCrud.mjs +249 -0
- package/dist/adapters/drizzleLazyDB.cjs +84 -36
- package/dist/adapters/drizzleLazyDB.mjs +84 -36
- package/dist/adapters/drizzleQuery.cjs +68 -0
- package/dist/adapters/drizzleQuery.mjs +67 -0
- package/dist/adapters/kysely.cjs +18 -138
- package/dist/adapters/kysely.d.cts +3 -12
- package/dist/adapters/kysely.d.mts +3 -12
- package/dist/adapters/kysely.mjs +19 -139
- package/dist/adapters/kyselyCrud.cjs +254 -0
- package/dist/adapters/kyselyCrud.mjs +253 -0
- package/dist/adapters/kyselyQuery.cjs +56 -0
- package/dist/adapters/kyselyQuery.mjs +55 -0
- package/dist/adapters/mongodb.cjs +21 -174
- package/dist/adapters/mongodb.d.cts +3 -2
- package/dist/adapters/mongodb.d.mts +3 -2
- package/dist/adapters/mongodb.mjs +21 -174
- package/dist/adapters/mongodbCollections.cjs +36 -0
- package/dist/adapters/mongodbCollections.mjs +30 -0
- package/dist/adapters/mongodbQuery.cjs +77 -0
- package/dist/adapters/mongodbQuery.mjs +70 -0
- package/dist/adapters/mongodbReads.cjs +148 -0
- package/dist/adapters/mongodbReads.mjs +147 -0
- package/dist/adapters/mongodbWrites.cjs +134 -0
- package/dist/adapters/mongodbWrites.mjs +133 -0
- package/dist/adapters/prisma.cjs +205 -180
- package/dist/adapters/prisma.d.cts +3 -3
- package/dist/adapters/prisma.d.mts +3 -3
- package/dist/adapters/prisma.mjs +206 -181
- package/dist/adapters/prismaQuery.cjs +58 -0
- package/dist/adapters/prismaQuery.mjs +56 -0
- package/dist/adapters/prismaRows.cjs +198 -0
- package/dist/adapters/prismaRows.mjs +188 -0
- package/dist/analytics/bounded/activeOverview.cjs +87 -0
- package/dist/analytics/bounded/activeOverview.mjs +85 -0
- package/dist/analytics/bounded/installationSearch.cjs +42 -0
- package/dist/analytics/bounded/installationSearch.mjs +42 -0
- package/dist/analytics/bounded/persistence.cjs +39 -0
- package/dist/analytics/bounded/persistence.mjs +38 -0
- package/dist/analytics/bounded/provider.cjs +170 -0
- package/dist/analytics/bounded/provider.d.cts +7 -0
- package/dist/analytics/bounded/provider.d.mts +7 -0
- package/dist/analytics/bounded/provider.mjs +170 -0
- package/dist/analytics/bounded/scan.cjs +144 -0
- package/dist/analytics/bounded/scan.mjs +138 -0
- package/dist/analytics/domain.d.cts +121 -0
- package/dist/analytics/domain.d.mts +121 -0
- package/dist/analytics/errors.cjs +22 -0
- package/dist/analytics/errors.mjs +20 -0
- package/dist/analytics/eventInput.cjs +111 -0
- package/dist/analytics/eventInput.mjs +111 -0
- package/dist/analytics/persistence.d.cts +6 -0
- package/dist/analytics/persistence.d.mts +6 -0
- package/dist/analytics/queryInput.cjs +58 -0
- package/dist/analytics/queryInput.mjs +55 -0
- package/dist/analytics/routes.cjs +63 -0
- package/dist/analytics/routes.mjs +61 -0
- package/dist/analytics/types.d.cts +17 -0
- package/dist/analytics/types.d.mts +17 -0
- package/dist/apiKeys.cjs +139 -0
- package/dist/apiKeys.d.cts +50 -0
- package/dist/apiKeys.d.mts +50 -0
- package/dist/apiKeys.mjs +133 -0
- package/dist/createHotUpdaterCore.cjs +48 -26
- package/dist/createHotUpdaterCore.d.cts +49 -17
- package/dist/createHotUpdaterCore.d.mts +49 -17
- package/dist/createHotUpdaterCore.mjs +51 -29
- package/dist/db/createBundleDiff.cjs +45 -68
- package/dist/db/createBundleDiff.d.cts +3 -3
- package/dist/db/createBundleDiff.d.mts +3 -3
- package/dist/db/createBundleDiff.mjs +47 -68
- package/dist/db/databasePluginCore.cjs +138 -0
- package/dist/db/databasePluginCore.mjs +137 -0
- package/dist/db/fixedMigrator.cjs +36 -93
- package/dist/db/fixedMigrator.mjs +36 -93
- package/dist/db/fixedMigratorMongo.cjs +319 -0
- package/dist/db/fixedMigratorMongo.mjs +319 -0
- package/dist/db/fixedMigratorShared.cjs +23 -0
- package/dist/db/fixedMigratorShared.mjs +18 -0
- package/dist/db/mongoMigrationExecution.cjs +11 -0
- package/dist/db/mongoMigrationExecution.mjs +11 -0
- package/dist/db/releaseCatalog.cjs +66 -0
- package/dist/db/releaseCatalog.d.cts +17 -0
- package/dist/db/releaseCatalog.d.mts +17 -0
- package/dist/db/releaseCatalog.mjs +64 -0
- package/dist/db/schema/mongodb.cjs +14 -6
- package/dist/db/schema/mongodb.mjs +14 -6
- package/dist/db/schema/registry.cjs +0 -2
- package/dist/db/schema/registry.mjs +1 -2
- package/dist/db/schema/sql.cjs +31 -14
- package/dist/db/schema/sql.mjs +32 -13
- package/dist/db/schema/sqlOperations.cjs +4 -4
- package/dist/db/schema/sqlOperations.mjs +5 -5
- package/dist/db/schemaGenerators.cjs +52 -18
- package/dist/db/schemaGenerators.mjs +52 -18
- package/dist/db/schemaReadiness.cjs +4 -3
- package/dist/db/schemaReadiness.mjs +4 -3
- package/dist/db/sqlMigrationExecution.cjs +67 -0
- package/dist/db/sqlMigrationExecution.mjs +66 -0
- package/dist/db/types.cjs +2 -6
- package/dist/db/types.d.cts +60 -16
- package/dist/db/types.d.mts +60 -16
- package/dist/db/types.mjs +3 -6
- package/dist/db/updateArtifacts.cjs +114 -60
- package/dist/db/updateArtifacts.mjs +117 -62
- package/dist/handler.cjs +108 -282
- package/dist/handler.d.cts +3 -46
- package/dist/handler.d.mts +3 -46
- package/dist/handler.mjs +107 -281
- package/dist/handlerBundleRoutes.cjs +155 -0
- package/dist/handlerBundleRoutes.mjs +155 -0
- package/dist/handlerErrors.cjs +9 -0
- package/dist/handlerErrors.mjs +9 -0
- package/dist/handlerParameters.cjs +39 -0
- package/dist/handlerParameters.mjs +34 -0
- package/dist/handlerReleaseCatalogRoutes.cjs +111 -0
- package/dist/handlerReleaseCatalogRoutes.mjs +110 -0
- package/dist/handlerReleaseManagementRoutes.cjs +125 -0
- package/dist/handlerReleaseManagementRoutes.mjs +124 -0
- package/dist/handlerTypes.d.cts +63 -0
- package/dist/handlerTypes.d.mts +63 -0
- package/dist/handlerVersionRoutes.cjs +10 -0
- package/dist/handlerVersionRoutes.d.cts +4 -0
- package/dist/handlerVersionRoutes.d.mts +4 -0
- package/dist/handlerVersionRoutes.mjs +9 -0
- package/dist/index.cjs +10 -1
- package/dist/index.d.cts +9 -3
- package/dist/index.d.mts +9 -3
- package/dist/index.mjs +5 -2
- package/dist/node.cjs +19 -12
- package/dist/node.d.cts +15 -10
- package/dist/node.d.mts +15 -10
- package/dist/node.mjs +19 -13
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/schema/dsl.cjs +19 -2
- package/dist/schema/dsl.mjs +17 -3
- package/dist/schema/index.cjs +1 -8
- package/dist/schema/index.mjs +2 -8
- package/dist/schema/types.cjs +3 -1
- package/dist/schema/types.mjs +3 -2
- package/dist/schema/v1_0_0.cjs +364 -0
- package/dist/schema/v1_0_0.mjs +357 -0
- package/dist/storageAccess.cjs +69 -25
- package/dist/storageAccess.mjs +68 -25
- package/dist/types/index.d.cts +4 -3
- package/dist/types/index.d.mts +5 -4
- package/package.json +8 -7
- package/src/adapters/databaseConstraintErrors.ts +25 -0
- package/src/adapters/databasePluginTestDatabase.ts +106 -0
- package/src/adapters/databasePluginUtils.spec.ts +29 -0
- package/src/adapters/databasePluginUtils.ts +214 -0
- package/src/adapters/drizzle.spec.ts +208 -47
- package/src/adapters/drizzle.ts +106 -293
- package/src/adapters/drizzleCrud.ts +419 -0
- package/src/adapters/drizzleLazyDB.ts +206 -100
- package/src/adapters/drizzleQuery.ts +164 -0
- package/src/adapters/kysely.spec.ts +126 -88
- package/src/adapters/kysely.ts +76 -334
- package/src/adapters/kyselyCrud.ts +588 -0
- package/src/adapters/kyselyQuery.ts +128 -0
- package/src/adapters/mongodb.spec.ts +141 -0
- package/src/adapters/mongodb.ts +60 -288
- package/src/adapters/mongodbCollections.ts +62 -0
- package/src/adapters/mongodbQuery.ts +226 -0
- package/src/adapters/mongodbReads.ts +311 -0
- package/src/adapters/mongodbRows.spec.ts +64 -0
- package/src/adapters/mongodbRows.ts +118 -0
- package/src/adapters/mongodbTestClient.ts +342 -0
- package/src/adapters/mongodbTestFilter.ts +166 -0
- package/src/adapters/mongodbWrites.ts +260 -0
- package/src/adapters/prisma.spec.ts +196 -0
- package/src/adapters/prisma.ts +417 -292
- package/src/adapters/prismaQuery.ts +129 -0
- package/src/adapters/prismaRows.spec.ts +98 -0
- package/src/adapters/prismaRows.ts +301 -0
- package/src/adapters/prismaTestClient.ts +396 -0
- package/src/analytics/analytics.integration.spec.ts +144 -0
- package/src/analytics/bounded/activeOverview.spec.ts +189 -0
- package/src/analytics/bounded/activeOverview.ts +148 -0
- package/src/analytics/bounded/installationSearch.spec.ts +130 -0
- package/src/analytics/bounded/installationSearch.ts +69 -0
- package/src/analytics/bounded/persistence.ts +43 -0
- package/src/analytics/bounded/provider.spec.ts +219 -0
- package/src/analytics/bounded/provider.ts +229 -0
- package/src/analytics/bounded/scan.ts +235 -0
- package/src/analytics/domain.ts +139 -0
- package/src/analytics/errors.ts +18 -0
- package/src/analytics/eventInput.ts +168 -0
- package/src/analytics/persistence.ts +10 -0
- package/src/analytics/queryInput.ts +102 -0
- package/src/analytics/routes.ts +123 -0
- package/src/analytics/types.ts +44 -0
- package/src/apiKeys.integration.spec.ts +192 -0
- package/src/apiKeys.spec.ts +162 -0
- package/src/apiKeys.ts +220 -0
- package/src/createHotUpdaterCore.ts +164 -100
- package/src/db/bundleRows.spec.ts +164 -0
- package/src/db/bundleRows.ts +10 -140
- package/src/db/createBundleDiff.spec.ts +172 -127
- package/src/db/createBundleDiff.ts +91 -110
- package/src/db/databasePluginCore.spec.ts +62 -0
- package/src/db/databasePluginCore.testFixtures.ts +76 -0
- package/src/db/databasePluginCore.ts +185 -0
- package/src/db/databasePluginUpdateCheck.bench.ts +107 -0
- package/src/db/fixedMigrator.spec.ts +184 -42
- package/src/db/fixedMigrator.ts +64 -205
- package/src/db/fixedMigratorMongo.spec.ts +192 -0
- package/src/db/fixedMigratorMongo.ts +425 -0
- package/src/db/fixedMigratorShared.ts +36 -0
- package/src/db/hotUpdaterSchema.ts +0 -1
- package/src/db/index.spec.ts +333 -1025
- package/src/db/mongoMigrationExecution.ts +23 -0
- package/src/db/node-sqlite.d.ts +25 -0
- package/src/db/releaseCatalog.ts +148 -0
- package/src/db/schema/mongodb.ts +20 -6
- package/src/db/schema/sql.ts +52 -11
- package/src/db/schema/sqlOperations.ts +5 -4
- package/src/db/schemaGenerators.ts +107 -23
- package/src/db/schemaReadiness.ts +2 -1
- package/src/db/sqlMigrationExecution.spec.ts +65 -0
- package/src/db/sqlMigrationExecution.ts +108 -0
- package/src/db/types.spec.ts +55 -0
- package/src/db/types.ts +129 -65
- package/src/db/updateArtifacts.spec.ts +359 -0
- package/src/db/updateArtifacts.ts +290 -126
- package/src/handler-standalone.integration.spec.ts +401 -364
- package/src/handler.spec.ts +137 -582
- package/src/handler.testFixtures.ts +30 -0
- package/src/handler.ts +218 -616
- package/src/handlerAuth.integration.spec.ts +33 -0
- package/src/handlerBundleRoutes.ts +259 -0
- package/src/handlerErrors.ts +7 -0
- package/src/handlerManagement.spec.ts +262 -0
- package/src/handlerParameters.ts +105 -0
- package/src/handlerReleaseCatalogRoutes.ts +154 -0
- package/src/handlerReleaseManagementRoutes.ts +213 -0
- package/src/handlerTypes.ts +96 -0
- package/src/handlerVersionRoutes.ts +12 -0
- package/src/index.ts +22 -2
- package/src/node.spec.ts +79 -10
- package/src/node.ts +20 -17
- package/src/releaseCatalogRoutes.integration.spec.ts +238 -0
- package/src/runtime.spec.ts +126 -959
- package/src/runtime.testFixtures.ts +71 -0
- package/src/schema/dsl-all-versions.spec.ts +26 -17
- package/src/schema/dsl.ts +41 -2
- package/src/schema/index.ts +3 -9
- package/src/schema/types.ts +9 -3
- package/src/schema/v1_0_0.spec.ts +146 -0
- package/src/schema/v1_0_0.ts +465 -0
- package/src/storageAccess.spec.ts +127 -32
- package/src/storageAccess.ts +106 -55
- package/src/types/index.ts +2 -1
- package/dist/db/bundleRows.cjs +0 -82
- package/dist/db/bundleRows.d.cts +0 -33
- package/dist/db/bundleRows.d.mts +0 -33
- package/dist/db/bundleRows.mjs +0 -79
- package/dist/db/pluginCore.cjs +0 -242
- package/dist/db/pluginCore.mjs +0 -241
- package/dist/db/schema/sqlMigrations.cjs +0 -74
- package/dist/db/schema/sqlMigrations.mjs +0 -73
- package/dist/db/schemaEnhancements.cjs +0 -20
- package/dist/db/schemaEnhancements.mjs +0 -19
- package/dist/route.cjs +0 -7
- package/dist/route.mjs +0 -7
- package/dist/schema/v0_21_0.cjs +0 -38
- package/dist/schema/v0_21_0.mjs +0 -37
- package/dist/schema/v0_29_0.cjs +0 -45
- package/dist/schema/v0_29_0.mjs +0 -44
- package/dist/schema/v0_31_0.cjs +0 -82
- package/dist/schema/v0_31_0.mjs +0 -80
- package/src/db/pluginCore.spec.ts +0 -1095
- package/src/db/pluginCore.ts +0 -475
- package/src/db/pluginUpdateCheck.bench.ts +0 -254
- package/src/db/schema/sqlMigrations.ts +0 -219
- package/src/db/schemaEnhancements.ts +0 -55
- package/src/route.ts +0 -7
- package/src/schema/v0_21_0.ts +0 -52
- package/src/schema/v0_29_0.ts +0 -62
- package/src/schema/v0_31_0.spec.ts +0 -73
- package/src/schema/v0_31_0.ts +0 -120
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BundleEventAnalyticsWindow,
|
|
3
|
+
InstallationHistoryRow,
|
|
4
|
+
} from "../domain";
|
|
5
|
+
import type {
|
|
6
|
+
AnalyticsPersistence,
|
|
7
|
+
BundleEventPersistenceRow,
|
|
8
|
+
} from "../persistence";
|
|
9
|
+
import type { AnalyticsProvider } from "../types";
|
|
10
|
+
import { collectActiveInstallationOverview } from "./activeOverview";
|
|
11
|
+
import { searchEventInstallations } from "./installationSearch";
|
|
12
|
+
import { createBundleEventRow } from "./persistence";
|
|
13
|
+
import {
|
|
14
|
+
ANALYTICS_SCAN_MAX_ROWS,
|
|
15
|
+
collectEventActivity,
|
|
16
|
+
compareCodePoints,
|
|
17
|
+
compareEventNewest,
|
|
18
|
+
materializeActiveRows,
|
|
19
|
+
materializeEventRows,
|
|
20
|
+
materializeRowsForWindow,
|
|
21
|
+
} from "./scan";
|
|
22
|
+
|
|
23
|
+
type TransitionEventRow = BundleEventPersistenceRow & {
|
|
24
|
+
readonly type: "UPDATE_APPLIED" | "RECOVERED";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isTransitionEventRow = (
|
|
28
|
+
row: BundleEventPersistenceRow,
|
|
29
|
+
): row is TransitionEventRow =>
|
|
30
|
+
row.type === "UPDATE_APPLIED" || row.type === "RECOVERED";
|
|
31
|
+
|
|
32
|
+
const toHistoryRow = (row: TransitionEventRow): InstallationHistoryRow => ({
|
|
33
|
+
id: row.id,
|
|
34
|
+
type: row.type,
|
|
35
|
+
fromBundleId: row.from_bundle_id,
|
|
36
|
+
toBundleId: row.to_bundle_id,
|
|
37
|
+
username: row.username,
|
|
38
|
+
userId: row.user_id,
|
|
39
|
+
platform: row.platform,
|
|
40
|
+
appVersion: row.app_version,
|
|
41
|
+
channel: row.channel,
|
|
42
|
+
cohort: row.cohort,
|
|
43
|
+
receivedAtMs: row.received_at_ms,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const isInstalledForBundle = (
|
|
47
|
+
row: TransitionEventRow,
|
|
48
|
+
bundleId: string,
|
|
49
|
+
): boolean => row.type === "UPDATE_APPLIED" && row.to_bundle_id === bundleId;
|
|
50
|
+
|
|
51
|
+
const isRecoveredFromBundle = (
|
|
52
|
+
row: TransitionEventRow,
|
|
53
|
+
bundleId: string,
|
|
54
|
+
): boolean => row.type === "RECOVERED" && row.from_bundle_id === bundleId;
|
|
55
|
+
|
|
56
|
+
const countDistinctInstallations = (
|
|
57
|
+
rows: readonly BundleEventPersistenceRow[],
|
|
58
|
+
): number => new Set(rows.map(({ install_id }) => install_id)).size;
|
|
59
|
+
|
|
60
|
+
const getAnalyticsResult = async (
|
|
61
|
+
persistence: AnalyticsPersistence,
|
|
62
|
+
bundleId: string,
|
|
63
|
+
window: BundleEventAnalyticsWindow,
|
|
64
|
+
limit: number,
|
|
65
|
+
offset: number,
|
|
66
|
+
) => {
|
|
67
|
+
const scope = { persistence, cutoffMs: Date.now() };
|
|
68
|
+
const rows = (await materializeRowsForWindow(scope, window)).filter(
|
|
69
|
+
isTransitionEventRow,
|
|
70
|
+
);
|
|
71
|
+
const installedRows = rows.filter((row) =>
|
|
72
|
+
isInstalledForBundle(row, bundleId),
|
|
73
|
+
);
|
|
74
|
+
const recoveredRows = rows.filter((row) =>
|
|
75
|
+
isRecoveredFromBundle(row, bundleId),
|
|
76
|
+
);
|
|
77
|
+
const installed = collectEventActivity({
|
|
78
|
+
rows: installedRows,
|
|
79
|
+
window,
|
|
80
|
+
cutoffMs: scope.cutoffMs,
|
|
81
|
+
});
|
|
82
|
+
const recovered = collectEventActivity({
|
|
83
|
+
rows: recoveredRows,
|
|
84
|
+
window,
|
|
85
|
+
cutoffMs: scope.cutoffMs,
|
|
86
|
+
});
|
|
87
|
+
const recentRows = [...installedRows, ...recoveredRows].sort(
|
|
88
|
+
compareEventNewest,
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
summary: { installed: installed.summary, recovered: recovered.summary },
|
|
92
|
+
series: { installed: installed.series, recovered: recovered.series },
|
|
93
|
+
cohorts: { installed: installed.cohorts, recovered: recovered.cohorts },
|
|
94
|
+
recentEvents: {
|
|
95
|
+
data: recentRows.slice(offset, offset + limit).map(toHistoryRow),
|
|
96
|
+
pagination: { total: recentRows.length, limit, offset },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const getAnalyticsSummaries = async (
|
|
102
|
+
persistence: AnalyticsPersistence,
|
|
103
|
+
bundleIds: readonly string[],
|
|
104
|
+
window: BundleEventAnalyticsWindow,
|
|
105
|
+
) => {
|
|
106
|
+
const normalizedBundleIds = [...new Set(bundleIds)];
|
|
107
|
+
if (normalizedBundleIds.length === 0) return [];
|
|
108
|
+
|
|
109
|
+
const requestedBundleIds = new Set(normalizedBundleIds);
|
|
110
|
+
const installedByBundleId = new Map<string, Set<string>>();
|
|
111
|
+
const recoveredByBundleId = new Map<string, Set<string>>();
|
|
112
|
+
const rows = (
|
|
113
|
+
await materializeRowsForWindow(
|
|
114
|
+
{ persistence, cutoffMs: Date.now() },
|
|
115
|
+
window,
|
|
116
|
+
)
|
|
117
|
+
).filter(isTransitionEventRow);
|
|
118
|
+
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
const bundleId =
|
|
121
|
+
row.type === "UPDATE_APPLIED" ? row.to_bundle_id : row.from_bundle_id;
|
|
122
|
+
if (!requestedBundleIds.has(bundleId)) continue;
|
|
123
|
+
|
|
124
|
+
const counts =
|
|
125
|
+
row.type === "UPDATE_APPLIED" ? installedByBundleId : recoveredByBundleId;
|
|
126
|
+
const installIds = counts.get(bundleId) ?? new Set<string>();
|
|
127
|
+
installIds.add(row.install_id);
|
|
128
|
+
counts.set(bundleId, installIds);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return normalizedBundleIds.map((bundleId) => ({
|
|
132
|
+
bundleId,
|
|
133
|
+
installed: installedByBundleId.get(bundleId)?.size ?? 0,
|
|
134
|
+
recovered: recoveredByBundleId.get(bundleId)?.size ?? 0,
|
|
135
|
+
}));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const createAnalyticsProvider = (
|
|
139
|
+
persistence: AnalyticsPersistence,
|
|
140
|
+
): AnalyticsProvider =>
|
|
141
|
+
Object.freeze({
|
|
142
|
+
mode: "bounded",
|
|
143
|
+
maxMatchingRows: ANALYTICS_SCAN_MAX_ROWS,
|
|
144
|
+
async appendBundleEvent(input) {
|
|
145
|
+
await persistence.append(createBundleEventRow(input));
|
|
146
|
+
},
|
|
147
|
+
async getBundleEventSummary(bundleId) {
|
|
148
|
+
const rows = (
|
|
149
|
+
await materializeEventRows({ persistence, cutoffMs: Date.now() })
|
|
150
|
+
).filter(isTransitionEventRow);
|
|
151
|
+
return {
|
|
152
|
+
installed: countDistinctInstallations(
|
|
153
|
+
rows.filter((row) => isInstalledForBundle(row, bundleId)),
|
|
154
|
+
),
|
|
155
|
+
recovered: countDistinctInstallations(
|
|
156
|
+
rows.filter((row) => isRecoveredFromBundle(row, bundleId)),
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
getBundleEventSummaries(bundleIds, window) {
|
|
161
|
+
return getAnalyticsSummaries(persistence, bundleIds, window);
|
|
162
|
+
},
|
|
163
|
+
getBundleEventAnalytics(bundleId, window, limit, offset) {
|
|
164
|
+
return getAnalyticsResult(persistence, bundleId, window, limit, offset);
|
|
165
|
+
},
|
|
166
|
+
async getBundleEventOverview() {
|
|
167
|
+
const rows = await materializeEventRows({
|
|
168
|
+
persistence,
|
|
169
|
+
cutoffMs: Date.now(),
|
|
170
|
+
});
|
|
171
|
+
const latestByInstall = new Map<string, BundleEventPersistenceRow>();
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
const current = latestByInstall.get(row.install_id);
|
|
174
|
+
if (current === undefined || compareEventNewest(row, current) < 0) {
|
|
175
|
+
latestByInstall.set(row.install_id, row);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const counts = new Map<string, number>();
|
|
179
|
+
for (const row of latestByInstall.values()) {
|
|
180
|
+
counts.set(row.to_bundle_id, (counts.get(row.to_bundle_id) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
trackedInstallations: latestByInstall.size,
|
|
184
|
+
bundles: [...counts]
|
|
185
|
+
.map(([bundleId, installations]) => ({ bundleId, installations }))
|
|
186
|
+
.sort(
|
|
187
|
+
(left, right) =>
|
|
188
|
+
right.installations - left.installations ||
|
|
189
|
+
compareCodePoints(left.bundleId, right.bundleId),
|
|
190
|
+
),
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
async getActiveInstallationOverview(input) {
|
|
194
|
+
const asOfMs = Date.now();
|
|
195
|
+
const rows = await materializeActiveRows(
|
|
196
|
+
{ persistence, cutoffMs: asOfMs },
|
|
197
|
+
input.window,
|
|
198
|
+
);
|
|
199
|
+
return collectActiveInstallationOverview({
|
|
200
|
+
rows,
|
|
201
|
+
asOfMs,
|
|
202
|
+
window: input.window,
|
|
203
|
+
...(input.userId === undefined ? {} : { userId: input.userId }),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
async searchInstallations(query, limit, offset) {
|
|
207
|
+
const rows = await materializeEventRows({
|
|
208
|
+
persistence,
|
|
209
|
+
cutoffMs: Date.now(),
|
|
210
|
+
});
|
|
211
|
+
return searchEventInstallations({ rows, query, limit, offset });
|
|
212
|
+
},
|
|
213
|
+
async getInstallationHistory(installId, limit, offset) {
|
|
214
|
+
const rows = await materializeEventRows({
|
|
215
|
+
persistence,
|
|
216
|
+
cutoffMs: Date.now(),
|
|
217
|
+
});
|
|
218
|
+
const ordered = rows
|
|
219
|
+
.filter(
|
|
220
|
+
(row): row is TransitionEventRow =>
|
|
221
|
+
row.install_id === installId && isTransitionEventRow(row),
|
|
222
|
+
)
|
|
223
|
+
.toSorted(compareEventNewest);
|
|
224
|
+
return {
|
|
225
|
+
data: ordered.slice(offset, offset + limit).map(toHistoryRow),
|
|
226
|
+
pagination: { total: ordered.length, limit, offset },
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
} satisfies AnalyticsProvider);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActiveInstallationWindow,
|
|
3
|
+
BundleEventAnalyticsWindow,
|
|
4
|
+
} from "../domain";
|
|
5
|
+
import { AnalyticsScanLimitExceededError } from "../errors";
|
|
6
|
+
import type {
|
|
7
|
+
AnalyticsPersistence,
|
|
8
|
+
BundleEventPersistenceRow,
|
|
9
|
+
} from "../persistence";
|
|
10
|
+
import {
|
|
11
|
+
ACTIVE_BUNDLE_EVENT_TYPES,
|
|
12
|
+
getActiveWindowDefinition,
|
|
13
|
+
} from "./activeOverview";
|
|
14
|
+
|
|
15
|
+
export const ANALYTICS_SCAN_MAX_ROWS = 50_000;
|
|
16
|
+
export const ANALYTICS_MATERIALIZATION_LIMIT = ANALYTICS_SCAN_MAX_ROWS + 1;
|
|
17
|
+
export const ANALYTICS_SCAN_PAGE_SIZE = 1_000;
|
|
18
|
+
const ANALYTICS_LOWER_BOUND_ID = "00000000-0000-0000-0000-000000000000";
|
|
19
|
+
|
|
20
|
+
export type AnalyticsScanScope = {
|
|
21
|
+
readonly persistence: AnalyticsPersistence;
|
|
22
|
+
readonly cutoffMs: number;
|
|
23
|
+
readonly lowerBoundMs?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type EventActivityRequest = {
|
|
27
|
+
readonly rows: readonly BundleEventPersistenceRow[];
|
|
28
|
+
readonly window: BundleEventAnalyticsWindow;
|
|
29
|
+
readonly cutoffMs: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const compareCodePoints = (left: string, right: string): number => {
|
|
33
|
+
if (left < right) return -1;
|
|
34
|
+
if (left > right) return 1;
|
|
35
|
+
return 0;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const compareEventNewest = (
|
|
39
|
+
left: BundleEventPersistenceRow,
|
|
40
|
+
right: BundleEventPersistenceRow,
|
|
41
|
+
): number =>
|
|
42
|
+
right.received_at_ms - left.received_at_ms ||
|
|
43
|
+
compareCodePoints(right.id, left.id);
|
|
44
|
+
|
|
45
|
+
const compareEventOldest = (
|
|
46
|
+
left: { readonly received_at_ms: number; readonly id: string },
|
|
47
|
+
right: { readonly received_at_ms: number; readonly id: string },
|
|
48
|
+
): number =>
|
|
49
|
+
left.received_at_ms - right.received_at_ms ||
|
|
50
|
+
compareCodePoints(left.id, right.id);
|
|
51
|
+
|
|
52
|
+
export const materializeEventRows = async (
|
|
53
|
+
scope: AnalyticsScanScope,
|
|
54
|
+
): Promise<readonly BundleEventPersistenceRow[]> => {
|
|
55
|
+
const rows: BundleEventPersistenceRow[] = [];
|
|
56
|
+
const seenIds = new Set<string>();
|
|
57
|
+
let after =
|
|
58
|
+
scope.lowerBoundMs === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: { receivedAtMs: scope.lowerBoundMs, id: ANALYTICS_LOWER_BOUND_ID };
|
|
61
|
+
while (rows.length < ANALYTICS_MATERIALIZATION_LIMIT) {
|
|
62
|
+
const limit = Math.min(
|
|
63
|
+
ANALYTICS_SCAN_PAGE_SIZE,
|
|
64
|
+
ANALYTICS_MATERIALIZATION_LIMIT - rows.length,
|
|
65
|
+
);
|
|
66
|
+
const page = await scope.persistence.scan({
|
|
67
|
+
beforeReceivedAtMs: scope.cutoffMs,
|
|
68
|
+
...(after === undefined ? {} : { after }),
|
|
69
|
+
limit,
|
|
70
|
+
});
|
|
71
|
+
if (page.length === 0) break;
|
|
72
|
+
if (page.length > limit) throw new AnalyticsPersistenceOrderError();
|
|
73
|
+
let previous =
|
|
74
|
+
after === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
: { received_at_ms: after.receivedAtMs, id: after.id };
|
|
77
|
+
for (const row of page) {
|
|
78
|
+
if (
|
|
79
|
+
row.received_at_ms >= scope.cutoffMs ||
|
|
80
|
+
(previous !== undefined && compareEventOldest(previous, row) >= 0) ||
|
|
81
|
+
seenIds.has(row.id)
|
|
82
|
+
) {
|
|
83
|
+
throw new AnalyticsPersistenceOrderError();
|
|
84
|
+
}
|
|
85
|
+
seenIds.add(row.id);
|
|
86
|
+
rows.push(row);
|
|
87
|
+
previous = row;
|
|
88
|
+
}
|
|
89
|
+
const last = page.at(-1);
|
|
90
|
+
if (last !== undefined) {
|
|
91
|
+
after = { receivedAtMs: last.received_at_ms, id: last.id };
|
|
92
|
+
}
|
|
93
|
+
if (rows.length > ANALYTICS_SCAN_MAX_ROWS) {
|
|
94
|
+
throw new AnalyticsScanLimitExceededError(ANALYTICS_SCAN_MAX_ROWS);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return rows;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export class AnalyticsPersistenceOrderError extends Error {
|
|
101
|
+
readonly name = "AnalyticsPersistenceOrderError";
|
|
102
|
+
|
|
103
|
+
constructor() {
|
|
104
|
+
super("Analytics persistence did not advance its scan cursor.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const materializeActiveRows = async (
|
|
109
|
+
scope: AnalyticsScanScope,
|
|
110
|
+
window: ActiveInstallationWindow,
|
|
111
|
+
): Promise<readonly BundleEventPersistenceRow[]> => {
|
|
112
|
+
const definition = getActiveWindowDefinition(window);
|
|
113
|
+
const durationMs = definition.bucketCount * definition.bucketSizeMs;
|
|
114
|
+
return (
|
|
115
|
+
await materializeEventRows({
|
|
116
|
+
...scope,
|
|
117
|
+
lowerBoundMs: scope.cutoffMs - durationMs,
|
|
118
|
+
})
|
|
119
|
+
).filter(
|
|
120
|
+
(row) =>
|
|
121
|
+
row.received_at_ms >= scope.cutoffMs - durationMs &&
|
|
122
|
+
ACTIVE_BUNDLE_EVENT_TYPES.includes(row.type),
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const startOfUtcHour = (value: number): number => {
|
|
127
|
+
const date = new Date(value);
|
|
128
|
+
return Date.UTC(
|
|
129
|
+
date.getUTCFullYear(),
|
|
130
|
+
date.getUTCMonth(),
|
|
131
|
+
date.getUTCDate(),
|
|
132
|
+
date.getUTCHours(),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const startOfUtcDay = (value: number): number => {
|
|
137
|
+
const date = new Date(value);
|
|
138
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const getWindowRange = (
|
|
142
|
+
window: Exclude<BundleEventAnalyticsWindow, "all">,
|
|
143
|
+
now: number,
|
|
144
|
+
): { readonly sizeMs: number; readonly rangeStart: number } => {
|
|
145
|
+
if (window === "24h") {
|
|
146
|
+
return {
|
|
147
|
+
sizeMs: 60 * 60 * 1_000,
|
|
148
|
+
rangeStart: startOfUtcHour(now) - 23 * 60 * 60 * 1_000,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const days = window === "7d" ? 7 : 30;
|
|
152
|
+
return {
|
|
153
|
+
sizeMs: 24 * 60 * 60 * 1_000,
|
|
154
|
+
rangeStart: startOfUtcDay(now) - (days - 1) * 24 * 60 * 60 * 1_000,
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const materializeRowsForWindow = async (
|
|
159
|
+
scope: AnalyticsScanScope,
|
|
160
|
+
window: BundleEventAnalyticsWindow,
|
|
161
|
+
): Promise<readonly BundleEventPersistenceRow[]> => {
|
|
162
|
+
if (window === "all") return materializeEventRows(scope);
|
|
163
|
+
const range = getWindowRange(window, scope.cutoffMs);
|
|
164
|
+
const rows = await materializeEventRows({
|
|
165
|
+
...scope,
|
|
166
|
+
lowerBoundMs: range.rangeStart,
|
|
167
|
+
});
|
|
168
|
+
return rows.filter(
|
|
169
|
+
({ received_at_ms }) => received_at_ms >= range.rangeStart,
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const bucketStart = (receivedAtMs: number, sizeMs: number): number =>
|
|
174
|
+
sizeMs === 60 * 60 * 1_000
|
|
175
|
+
? startOfUtcHour(receivedAtMs)
|
|
176
|
+
: startOfUtcDay(receivedAtMs);
|
|
177
|
+
|
|
178
|
+
const createSeries = (request: EventActivityRequest) => {
|
|
179
|
+
const range =
|
|
180
|
+
request.window === "all"
|
|
181
|
+
? undefined
|
|
182
|
+
: getWindowRange(request.window, request.cutoffMs);
|
|
183
|
+
const sizeMs = range?.sizeMs ?? 24 * 60 * 60 * 1_000;
|
|
184
|
+
const installIdsByBucket = new Map<number, Set<string>>();
|
|
185
|
+
let oldestMs = request.cutoffMs;
|
|
186
|
+
for (const row of request.rows) {
|
|
187
|
+
oldestMs = Math.min(oldestMs, row.received_at_ms);
|
|
188
|
+
const start = bucketStart(row.received_at_ms, sizeMs);
|
|
189
|
+
const installIds = installIdsByBucket.get(start) ?? new Set<string>();
|
|
190
|
+
installIds.add(row.install_id);
|
|
191
|
+
installIdsByBucket.set(start, installIds);
|
|
192
|
+
}
|
|
193
|
+
const first = range?.rangeStart ?? startOfUtcDay(oldestMs);
|
|
194
|
+
const last = bucketStart(request.cutoffMs, sizeMs);
|
|
195
|
+
return Array.from(
|
|
196
|
+
{ length: Math.floor((last - first) / sizeMs) + 1 },
|
|
197
|
+
(_, index) => {
|
|
198
|
+
const start = first + index * sizeMs;
|
|
199
|
+
return {
|
|
200
|
+
bucketStartMs: start,
|
|
201
|
+
value: installIdsByBucket.get(start)?.size ?? 0,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const collectEventActivity = (request: EventActivityRequest) => {
|
|
208
|
+
const range =
|
|
209
|
+
request.window === "all"
|
|
210
|
+
? undefined
|
|
211
|
+
: getWindowRange(request.window, request.cutoffMs);
|
|
212
|
+
const rows =
|
|
213
|
+
range === undefined
|
|
214
|
+
? request.rows
|
|
215
|
+
: request.rows.filter(
|
|
216
|
+
({ received_at_ms }) =>
|
|
217
|
+
received_at_ms >= range.rangeStart &&
|
|
218
|
+
received_at_ms < request.cutoffMs,
|
|
219
|
+
);
|
|
220
|
+
const installs = new Set<string>();
|
|
221
|
+
const installsByCohort = new Map<string, Set<string>>();
|
|
222
|
+
for (const row of rows) {
|
|
223
|
+
installs.add(row.install_id);
|
|
224
|
+
const cohort = installsByCohort.get(row.cohort) ?? new Set<string>();
|
|
225
|
+
cohort.add(row.install_id);
|
|
226
|
+
installsByCohort.set(row.cohort, cohort);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
summary: installs.size,
|
|
230
|
+
cohorts: [...installsByCohort]
|
|
231
|
+
.sort(([left], [right]) => compareCodePoints(left, right))
|
|
232
|
+
.map(([cohort, installIds]) => ({ cohort, value: installIds.size })),
|
|
233
|
+
series: createSeries({ ...request, rows }),
|
|
234
|
+
};
|
|
235
|
+
};
|