@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.
Files changed (210) hide show
  1. package/dist/_virtual/_rolldown/runtime.cjs +1 -1
  2. package/dist/adapters/databaseConstraintErrors.cjs +0 -1
  3. package/dist/adapters/databasePluginUtils.cjs +0 -1
  4. package/dist/adapters/drizzle.cjs +11 -4
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +12 -4
  8. package/dist/adapters/drizzleCrud.cjs +62 -1
  9. package/dist/adapters/drizzleCrud.mjs +61 -2
  10. package/dist/adapters/drizzleLazyDB.cjs +6 -1
  11. package/dist/adapters/drizzleLazyDB.mjs +6 -1
  12. package/dist/adapters/drizzleQuery.cjs +3 -2
  13. package/dist/adapters/drizzleQuery.mjs +3 -1
  14. package/dist/adapters/insightsTransaction.cjs +22 -0
  15. package/dist/adapters/insightsTransaction.mjs +22 -0
  16. package/dist/adapters/kysely.cjs +2 -1
  17. package/dist/adapters/kysely.d.cts +0 -1
  18. package/dist/adapters/kysely.d.mts +0 -1
  19. package/dist/adapters/kysely.mjs +3 -1
  20. package/dist/adapters/kyselyCrud.cjs +54 -3
  21. package/dist/adapters/kyselyCrud.mjs +54 -3
  22. package/dist/adapters/kyselyQuery.cjs +2 -2
  23. package/dist/adapters/kyselyQuery.mjs +2 -1
  24. package/dist/adapters/mongodb.cjs +27 -1
  25. package/dist/adapters/mongodb.d.cts +1 -1
  26. package/dist/adapters/mongodb.d.mts +1 -1
  27. package/dist/adapters/mongodb.mjs +28 -1
  28. package/dist/adapters/mongodbCollections.cjs +2 -0
  29. package/dist/adapters/mongodbCollections.mjs +2 -0
  30. package/dist/adapters/mongodbQuery.cjs +21 -4
  31. package/dist/adapters/mongodbQuery.mjs +21 -5
  32. package/dist/adapters/mongodbReads.cjs +91 -29
  33. package/dist/adapters/mongodbReads.mjs +92 -29
  34. package/dist/adapters/mongodbWrites.cjs +16 -2
  35. package/dist/adapters/mongodbWrites.mjs +18 -3
  36. package/dist/adapters/prisma.cjs +55 -7
  37. package/dist/adapters/prisma.d.cts +0 -1
  38. package/dist/adapters/prisma.d.mts +0 -1
  39. package/dist/adapters/prisma.mjs +56 -7
  40. package/dist/adapters/prismaQuery.cjs +0 -1
  41. package/dist/adapters/prismaRows.cjs +22 -1
  42. package/dist/adapters/prismaRows.mjs +22 -1
  43. package/dist/apiKeys.cjs +6 -2
  44. package/dist/apiKeys.d.cts +0 -1
  45. package/dist/apiKeys.d.mts +0 -1
  46. package/dist/apiKeys.mjs +3 -3
  47. package/dist/createHotUpdaterCore.cjs +27 -12
  48. package/dist/createHotUpdaterCore.d.cts +8 -8
  49. package/dist/createHotUpdaterCore.d.mts +8 -8
  50. package/dist/createHotUpdaterCore.mjs +26 -12
  51. package/dist/db/createBundleDiff.cjs +2 -2
  52. package/dist/db/createBundleDiff.d.cts +1 -5
  53. package/dist/db/createBundleDiff.d.mts +1 -5
  54. package/dist/db/databasePluginCore.cjs +0 -1
  55. package/dist/db/fixedMigrator.cjs +2 -2
  56. package/dist/db/fixedMigrator.mjs +2 -2
  57. package/dist/db/fixedMigratorMongo.cjs +6 -4
  58. package/dist/db/fixedMigratorMongo.mjs +6 -4
  59. package/dist/db/index.d.cts +0 -1
  60. package/dist/db/index.d.mts +0 -1
  61. package/dist/db/releaseCatalog.cjs +0 -1
  62. package/dist/db/releaseCatalog.d.cts +0 -1
  63. package/dist/db/releaseCatalog.d.mts +0 -1
  64. package/dist/db/schema/insightsCollation.cjs +14 -0
  65. package/dist/db/schema/insightsCollation.mjs +14 -0
  66. package/dist/db/schema/sql.cjs +11 -7
  67. package/dist/db/schema/sql.mjs +8 -8
  68. package/dist/db/schemaGenerators.cjs +28 -9
  69. package/dist/db/schemaGenerators.mjs +28 -9
  70. package/dist/db/schemaReadiness.cjs +2 -0
  71. package/dist/db/schemaReadiness.d.cts +1 -0
  72. package/dist/db/schemaReadiness.d.mts +1 -0
  73. package/dist/db/schemaReadiness.mjs +2 -0
  74. package/dist/db/sqlMigrationExecution.cjs +1 -1
  75. package/dist/db/sqlMigrationExecution.mjs +1 -0
  76. package/dist/db/types.cjs +3 -1
  77. package/dist/db/types.d.cts +3 -6
  78. package/dist/db/types.d.mts +3 -6
  79. package/dist/db/types.mjs +2 -2
  80. package/dist/db/updateArtifacts.cjs +0 -1
  81. package/dist/handler.cjs +5 -5
  82. package/dist/handler.d.cts +1 -2
  83. package/dist/handler.d.mts +1 -2
  84. package/dist/handler.mjs +5 -5
  85. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  86. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  87. package/dist/handlerTypes.d.cts +0 -1
  88. package/dist/handlerTypes.d.mts +0 -1
  89. package/dist/handlerVersionRoutes.d.cts +1 -0
  90. package/dist/handlerVersionRoutes.d.mts +1 -0
  91. package/dist/index.cjs +2 -2
  92. package/dist/index.d.cts +4 -4
  93. package/dist/index.d.mts +4 -4
  94. package/dist/index.mjs +2 -2
  95. package/dist/insights/domain.d.cts +89 -0
  96. package/dist/insights/domain.d.mts +89 -0
  97. package/dist/insights/errors.cjs +15 -0
  98. package/dist/insights/errors.mjs +14 -0
  99. package/dist/{analytics → insights}/eventInput.cjs +58 -14
  100. package/dist/{analytics → insights}/eventInput.mjs +58 -16
  101. package/dist/insights/provider.cjs +314 -0
  102. package/dist/insights/provider.d.cts +6 -0
  103. package/dist/insights/provider.d.mts +6 -0
  104. package/dist/insights/provider.mjs +314 -0
  105. package/dist/insights/queryInput.cjs +93 -0
  106. package/dist/insights/queryInput.mjs +91 -0
  107. package/dist/insights/routes.cjs +58 -0
  108. package/dist/insights/routes.mjs +56 -0
  109. package/dist/insights/types.d.cts +32 -0
  110. package/dist/insights/types.d.mts +32 -0
  111. package/dist/node.d.cts +0 -1
  112. package/dist/node.d.mts +0 -1
  113. package/dist/package.cjs +1 -1
  114. package/dist/package.mjs +1 -1
  115. package/dist/schema/index.cjs +17 -3
  116. package/dist/schema/index.mjs +4 -4
  117. package/dist/schema/v1_0_0.cjs +72 -30
  118. package/dist/schema/v1_0_0.mjs +72 -31
  119. package/dist/storageAccess.cjs +0 -1
  120. package/dist/types/index.d.cts +0 -1
  121. package/dist/types/index.d.mts +0 -1
  122. package/package.json +7 -7
  123. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  124. package/src/adapters/drizzle.spec.ts +99 -0
  125. package/src/adapters/drizzle.ts +22 -4
  126. package/src/adapters/drizzleCrud.ts +154 -2
  127. package/src/adapters/drizzleLazyDB.ts +17 -3
  128. package/src/adapters/insightsTransaction.ts +32 -0
  129. package/src/adapters/kysely.spec.ts +29 -0
  130. package/src/adapters/kysely.ts +10 -1
  131. package/src/adapters/kyselyCrud.ts +121 -0
  132. package/src/adapters/mongodb.integration.spec.ts +210 -0
  133. package/src/adapters/mongodb.spec.ts +69 -1
  134. package/src/adapters/mongodb.ts +66 -2
  135. package/src/adapters/mongodbCollections.ts +5 -0
  136. package/src/adapters/mongodbQuery.ts +32 -3
  137. package/src/adapters/mongodbReads.ts +69 -0
  138. package/src/adapters/mongodbTestClient.ts +41 -6
  139. package/src/adapters/mongodbTestFilter.ts +4 -1
  140. package/src/adapters/mongodbWrites.ts +27 -0
  141. package/src/adapters/prisma.spec.ts +97 -0
  142. package/src/adapters/prisma.ts +103 -3
  143. package/src/adapters/prismaRows.ts +37 -1
  144. package/src/adapters/prismaTestClient.ts +22 -5
  145. package/src/apiKeys.integration.spec.ts +3 -3
  146. package/src/createHotUpdaterCore.ts +30 -16
  147. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  148. package/src/db/fixedMigrator.spec.ts +65 -0
  149. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  150. package/src/db/fixedMigratorMongo.ts +10 -10
  151. package/src/db/index.spec.ts +8 -1
  152. package/src/db/schema/insightsCollation.ts +21 -0
  153. package/src/db/schema/sql.ts +8 -5
  154. package/src/db/schemaGenerators.ts +41 -13
  155. package/src/db/types.spec.ts +11 -3
  156. package/src/db/types.ts +13 -7
  157. package/src/handler-standalone.integration.spec.ts +6 -8
  158. package/src/handler.ts +12 -12
  159. package/src/index.ts +3 -3
  160. package/src/insights/domain.ts +103 -0
  161. package/src/insights/errors.ts +11 -0
  162. package/src/{analytics → insights}/eventInput.ts +71 -14
  163. package/src/insights/insights.integration.spec.ts +381 -0
  164. package/src/insights/provider.spec.ts +385 -0
  165. package/src/insights/provider.ts +591 -0
  166. package/src/insights/queryInput.ts +141 -0
  167. package/src/insights/routes.ts +104 -0
  168. package/src/insights/types.ts +55 -0
  169. package/src/schema/dsl-all-versions.spec.ts +3 -1
  170. package/src/schema/v1_0_0.spec.ts +77 -3
  171. package/src/schema/v1_0_0.ts +94 -30
  172. package/dist/analytics/bounded/activeOverview.cjs +0 -87
  173. package/dist/analytics/bounded/activeOverview.mjs +0 -85
  174. package/dist/analytics/bounded/installationSearch.cjs +0 -42
  175. package/dist/analytics/bounded/installationSearch.mjs +0 -42
  176. package/dist/analytics/bounded/persistence.cjs +0 -39
  177. package/dist/analytics/bounded/persistence.mjs +0 -38
  178. package/dist/analytics/bounded/provider.cjs +0 -170
  179. package/dist/analytics/bounded/provider.d.cts +0 -7
  180. package/dist/analytics/bounded/provider.d.mts +0 -7
  181. package/dist/analytics/bounded/provider.mjs +0 -170
  182. package/dist/analytics/bounded/scan.cjs +0 -144
  183. package/dist/analytics/bounded/scan.mjs +0 -138
  184. package/dist/analytics/domain.d.cts +0 -121
  185. package/dist/analytics/domain.d.mts +0 -121
  186. package/dist/analytics/errors.cjs +0 -22
  187. package/dist/analytics/errors.mjs +0 -20
  188. package/dist/analytics/persistence.d.cts +0 -6
  189. package/dist/analytics/persistence.d.mts +0 -6
  190. package/dist/analytics/queryInput.cjs +0 -58
  191. package/dist/analytics/queryInput.mjs +0 -55
  192. package/dist/analytics/routes.cjs +0 -63
  193. package/dist/analytics/routes.mjs +0 -61
  194. package/dist/analytics/types.d.cts +0 -17
  195. package/dist/analytics/types.d.mts +0 -17
  196. package/src/analytics/analytics.integration.spec.ts +0 -144
  197. package/src/analytics/bounded/activeOverview.spec.ts +0 -189
  198. package/src/analytics/bounded/activeOverview.ts +0 -148
  199. package/src/analytics/bounded/installationSearch.spec.ts +0 -130
  200. package/src/analytics/bounded/installationSearch.ts +0 -69
  201. package/src/analytics/bounded/persistence.ts +0 -43
  202. package/src/analytics/bounded/provider.spec.ts +0 -219
  203. package/src/analytics/bounded/provider.ts +0 -229
  204. package/src/analytics/bounded/scan.ts +0 -235
  205. package/src/analytics/domain.ts +0 -139
  206. package/src/analytics/errors.ts +0 -18
  207. package/src/analytics/persistence.ts +0 -10
  208. package/src/analytics/queryInput.ts +0 -102
  209. package/src/analytics/routes.ts +0 -123
  210. package/src/analytics/types.ts +0 -44
@@ -0,0 +1,141 @@
1
+ import type {
2
+ ActiveInstallationWindow,
3
+ InsightsBundleSelection,
4
+ InsightsScope,
5
+ } from "./domain";
6
+ import { InsightsBadRequestError } from "./errors";
7
+ import type {
8
+ InsightsEventPageInput,
9
+ InsightsUserInstallationPageInput,
10
+ } from "./types";
11
+
12
+ const MAX_PAGE_LIMIT = 100;
13
+ const MAX_IDENTITY_LENGTH = 255;
14
+ const MAX_CURSOR_LENGTH = 8 * 1_024;
15
+
16
+ const readSingle = (url: URL, key: string): string | undefined => {
17
+ const values = url.searchParams.getAll(key);
18
+ if (values.length > 1) {
19
+ throw new InsightsBadRequestError(`Duplicate '${key}' query parameter.`);
20
+ }
21
+ return values[0];
22
+ };
23
+
24
+ const readPageLimit = (url: URL): number | undefined => {
25
+ const value = readSingle(url, "limit");
26
+ if (value === undefined) return undefined;
27
+ const limit = Number(value);
28
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT) {
29
+ throw new InsightsBadRequestError("Invalid 'limit' query parameter.");
30
+ }
31
+ return limit;
32
+ };
33
+
34
+ const readCursor = (url: URL): string | undefined => {
35
+ const cursor = readSingle(url, "cursor");
36
+ if (
37
+ cursor !== undefined &&
38
+ (cursor.length === 0 || cursor.length > MAX_CURSOR_LENGTH)
39
+ ) {
40
+ throw new InsightsBadRequestError("Invalid 'cursor' query parameter.");
41
+ }
42
+ return cursor;
43
+ };
44
+
45
+ const readId = (
46
+ url: URL,
47
+ key: string,
48
+ maximumLength = MAX_IDENTITY_LENGTH,
49
+ ): string => {
50
+ const value = readSingle(url, key);
51
+ if (
52
+ value === undefined ||
53
+ value.length === 0 ||
54
+ value.length > maximumLength
55
+ ) {
56
+ throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
57
+ }
58
+ return value;
59
+ };
60
+
61
+ const readTimestamp = (url: URL, key: string): number | undefined => {
62
+ const raw = readSingle(url, key);
63
+ if (raw === undefined) return undefined;
64
+ const value = Number(raw);
65
+ if (!raw.length || !Number.isSafeInteger(value) || value < 0) {
66
+ throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
67
+ }
68
+ return value;
69
+ };
70
+
71
+ const readScope = (url: URL): InsightsScope => {
72
+ const platform = readSingle(url, "platform");
73
+ if (platform !== "ios" && platform !== "android") {
74
+ throw new InsightsBadRequestError("Invalid 'platform' query parameter.");
75
+ }
76
+ return { platform, channel: readId(url, "channel", 1_024) };
77
+ };
78
+
79
+ export const parseEventPageInput = (
80
+ request: Request,
81
+ ): InsightsEventPageInput => {
82
+ const url = new URL(request.url);
83
+ const bundleFields = ["bundleId", "outcome", "platform", "channel"];
84
+ let bundle: InsightsBundleSelection | undefined;
85
+ if (bundleFields.some((key) => url.searchParams.has(key))) {
86
+ const outcome = readSingle(url, "outcome");
87
+ if (
88
+ outcome !== "applied" &&
89
+ outcome !== "recovered" &&
90
+ outcome !== "adopted"
91
+ ) {
92
+ throw new InsightsBadRequestError("Invalid 'outcome' query parameter.");
93
+ }
94
+ bundle = {
95
+ ...readScope(url),
96
+ bundleId: readId(url, "bundleId", 1_024),
97
+ outcome,
98
+ };
99
+ }
100
+ return {
101
+ beforeReceivedAtMs: readTimestamp(url, "beforeReceivedAtMs"),
102
+ sinceMs: readTimestamp(url, "sinceMs"),
103
+ cursor: readCursor(url),
104
+ limit: readPageLimit(url),
105
+ ...(bundle === undefined ? {} : { bundle }),
106
+ };
107
+ };
108
+
109
+ export const parseUserInstallationPageInput = (
110
+ request: Request,
111
+ ): InsightsUserInstallationPageInput => {
112
+ const url = new URL(request.url);
113
+ const cursor = readCursor(url);
114
+ const limit = readPageLimit(url);
115
+ return {
116
+ userId: readId(url, "userId"),
117
+ ...(cursor === undefined ? {} : { cursor }),
118
+ ...(limit === undefined ? {} : { limit }),
119
+ };
120
+ };
121
+
122
+ export const parseReportingOverviewInput = (
123
+ request: Request,
124
+ ): InsightsScope & {
125
+ readonly window: ActiveInstallationWindow;
126
+ readonly bundleId?: string;
127
+ } => {
128
+ const url = new URL(request.url);
129
+ const window = readSingle(url, "window") ?? "30d";
130
+ if (window !== "24h" && window !== "7d" && window !== "30d") {
131
+ throw new InsightsBadRequestError("Invalid 'window' query parameter.");
132
+ }
133
+ const bundleId = readSingle(url, "bundleId");
134
+ return {
135
+ ...readScope(url),
136
+ window,
137
+ ...(bundleId === undefined
138
+ ? {}
139
+ : { bundleId: readId(url, "bundleId", 1_024) }),
140
+ };
141
+ };
@@ -0,0 +1,104 @@
1
+ import type { RouteHandler } from "../handlerTypes";
2
+ import {
3
+ InsightsBadRequestError,
4
+ InsightsPayloadTooLargeError,
5
+ } from "./errors";
6
+ import { parseBundleEventRequest } from "./eventInput";
7
+ import {
8
+ parseReportingOverviewInput,
9
+ parseEventPageInput,
10
+ parseUserInstallationPageInput,
11
+ } from "./queryInput";
12
+ import type { InsightsProvider } from "./types";
13
+
14
+ const json = (body: unknown, status: number): Response =>
15
+ Response.json(body, {
16
+ headers: { "cache-control": "private, no-store" },
17
+ status,
18
+ });
19
+
20
+ const requireParam = (
21
+ params: Readonly<Record<string, string>>,
22
+ key: string,
23
+ ): string => {
24
+ const value = params[key];
25
+ if (value === undefined || value.length === 0) {
26
+ throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
27
+ }
28
+ try {
29
+ return decodeURIComponent(value);
30
+ } catch {
31
+ throw new InsightsBadRequestError(`Invalid route parameter: ${key}`);
32
+ }
33
+ };
34
+
35
+ const run = async (operation: () => Promise<Response>): Promise<Response> => {
36
+ try {
37
+ return await operation();
38
+ } catch (error) {
39
+ if (error instanceof InsightsBadRequestError) {
40
+ return json({ error: error.message }, 400);
41
+ }
42
+ if (error instanceof InsightsPayloadTooLargeError) {
43
+ return json({ error: error.message }, 413);
44
+ }
45
+ throw error;
46
+ }
47
+ };
48
+
49
+ const query = (operation: () => Promise<unknown>): Promise<Response> =>
50
+ run(async () => json(await operation(), 200));
51
+
52
+ export const createInsightsRouteHandlers = (
53
+ provider: InsightsProvider,
54
+ ): Record<string, RouteHandler> => ({
55
+ appendBundleEvent: async (_params, request) =>
56
+ run(async () => {
57
+ await provider.appendBundleEvent(await parseBundleEventRequest(request));
58
+ return new Response(null, { status: 204 });
59
+ }),
60
+ getReportingOverview: (_params, request) =>
61
+ query(() =>
62
+ provider.getReportingOverview(parseReportingOverviewInput(request)),
63
+ ),
64
+ getInstallation: (params) =>
65
+ run(async () => {
66
+ const installation = await provider.getInstallation({
67
+ installId: requireParam(params, "installId"),
68
+ });
69
+ return installation === null
70
+ ? json({ error: "Installation not found" }, 404)
71
+ : json(installation, 200);
72
+ }),
73
+ listEvents: (_params, request) =>
74
+ query(() => provider.listEvents(parseEventPageInput(request))),
75
+ listInstallationEvents: (params, request) =>
76
+ query(() =>
77
+ provider.listInstallationEvents({
78
+ ...parseEventPageInput(request),
79
+ installId: requireParam(params, "installId"),
80
+ }),
81
+ ),
82
+ pageInstallationsByCurrentUserId: (_params, request) =>
83
+ query(() =>
84
+ provider.pageInstallationsByCurrentUserId(
85
+ parseUserInstallationPageInput(request),
86
+ ),
87
+ ),
88
+ });
89
+
90
+ export const registerInsightsClientRoutes = (
91
+ add: (method: string, path: string, handler: string) => void,
92
+ ): void => {
93
+ add("POST", "/events", "appendBundleEvent");
94
+ };
95
+
96
+ export const registerInsightsAdminRoutes = (
97
+ add: (method: string, path: string, handler: string) => void,
98
+ ): void => {
99
+ add("GET", "/events", "listEvents");
100
+ add("GET", "/overview", "getReportingOverview");
101
+ add("GET", "/installations", "pageInstallationsByCurrentUserId");
102
+ add("GET", "/installations/:installId/events", "listInstallationEvents");
103
+ add("GET", "/installations/:installId", "getInstallation");
104
+ };
@@ -0,0 +1,55 @@
1
+ import type {
2
+ ReportingOverview,
3
+ InsightsBundleSelection,
4
+ InsightsScope,
5
+ ActiveInstallationWindow,
6
+ CreateBundleEventRequest,
7
+ CursorPage,
8
+ EventCursorPage,
9
+ EventHistoryRow,
10
+ InstallationHistoryRow,
11
+ InstallationRow,
12
+ } from "./domain";
13
+
14
+ export type InsightsEventPageInput = {
15
+ readonly beforeReceivedAtMs?: number;
16
+ readonly sinceMs?: number;
17
+ readonly bundle?: InsightsBundleSelection;
18
+ readonly cursor?: string;
19
+ readonly limit?: number;
20
+ };
21
+
22
+ export type InsightsInstallationEventPageInput = Omit<
23
+ InsightsEventPageInput,
24
+ "bundle"
25
+ > & {
26
+ readonly installId: string;
27
+ };
28
+
29
+ export type InsightsUserInstallationPageInput = {
30
+ readonly userId: string;
31
+ readonly cursor?: string;
32
+ readonly limit?: number;
33
+ };
34
+
35
+ export type InsightsProvider = {
36
+ appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
37
+ listEvents(
38
+ input: InsightsEventPageInput,
39
+ ): Promise<EventCursorPage<EventHistoryRow>>;
40
+ listInstallationEvents(
41
+ input: InsightsInstallationEventPageInput,
42
+ ): Promise<EventCursorPage<InstallationHistoryRow>>;
43
+ getInstallation(input: {
44
+ readonly installId: string;
45
+ }): Promise<InstallationRow | null>;
46
+ pageInstallationsByCurrentUserId(
47
+ input: InsightsUserInstallationPageInput,
48
+ ): Promise<CursorPage<InstallationRow>>;
49
+ getReportingOverview(
50
+ input: InsightsScope & {
51
+ readonly window: ActiveInstallationWindow;
52
+ readonly bundleId?: string;
53
+ },
54
+ ): Promise<ReportingOverview>;
55
+ };
@@ -4,6 +4,7 @@ import { hotUpdaterSchemaVersions } from "./index";
4
4
  import { createSettingsTable } from "./settings";
5
5
  import {
6
6
  bundlePatchesV100,
7
+ bundleInstallationsV100,
7
8
  bundlesV100,
8
9
  channelsV100,
9
10
  releaseCatalogsV100,
@@ -17,6 +18,7 @@ describe("versioned schema DSL", () => {
17
18
  expect(channelsV100.dsl).toBe("table");
18
19
  expect(bundlesV100.dsl).toBe("table");
19
20
  expect(bundlePatchesV100.dsl).toBe("table");
21
+ expect(bundleInstallationsV100.dsl).toBe("table");
20
22
  expect(releasesV100.dsl).toBe("table");
21
23
  expect(releaseCatalogsV100.dsl).toBe("table");
22
24
  expect(createSettingsTable("1.0.0").dsl).toBe("table");
@@ -27,7 +29,7 @@ describe("versioned schema DSL", () => {
27
29
  }
28
30
  });
29
31
 
30
- it("registers only schema 1.0.0", () => {
32
+ it("registers only the initial schema 1.0.0", () => {
31
33
  expect(hotUpdaterSchemaVersions.map((item) => item.version)).toEqual([
32
34
  "1.0.0",
33
35
  ]);
@@ -7,6 +7,7 @@ import {
7
7
  } from "../db/schemaGenerators";
8
8
  import {
9
9
  bundleEventsV100,
10
+ bundleInstallationsV100,
10
11
  bundlePatchesV100,
11
12
  bundlesV100,
12
13
  releaseCatalogsV100,
@@ -57,7 +58,7 @@ describe("v1.0.0 Release Catalog schema", () => {
57
58
  const patchSize = bundlePatchesV100.columns.find(
58
59
  ({ ormName }) => ormName === "byte_size",
59
60
  );
60
- const sql = createTableSql("postgresql").join("\n");
61
+ const sql = createTableSql("postgresql", "foreign-keys", v1_0_0).join("\n");
61
62
  const prisma = generatePrismaSchema("postgresql", v1_0_0);
62
63
  const drizzle = generateDrizzleSchema("postgresql", v1_0_0);
63
64
 
@@ -96,6 +97,79 @@ describe("v1.0.0 Release Catalog schema", () => {
96
97
  ).toBeUndefined();
97
98
  });
98
99
 
100
+ it("stores one current row per installation with the required lookup indexes", () => {
101
+ expect(
102
+ bundleInstallationsV100.columns.find(
103
+ ({ ormName }) => ormName === "install_id",
104
+ )?.primaryKey,
105
+ ).toBe(true);
106
+ expect(bundleInstallationsV100.indexes).toEqual(
107
+ expect.arrayContaining([
108
+ {
109
+ columns: ["user_id", "install_id"],
110
+ name: "bundle_installations_user_id_idx",
111
+ },
112
+ {
113
+ columns: ["received_at_ms"],
114
+ name: "bundle_installations_received_at_idx",
115
+ },
116
+ ]),
117
+ );
118
+
119
+ const sql = createTableSql("postgresql", "foreign-keys", v1_0_0).join("\n");
120
+ const prisma = generatePrismaSchema("postgresql", v1_0_0);
121
+ const drizzle = generateDrizzleSchema("postgresql", v1_0_0);
122
+ expect(sql).toContain("create table bundle_installations");
123
+ expect(sql).toContain(
124
+ 'install_id varchar(255) collate "C" primary key not null',
125
+ );
126
+ expect(sql).toContain(
127
+ "create index bundle_installations_user_id_idx on bundle_installations(user_id, install_id)",
128
+ );
129
+ expect(sql).toContain(
130
+ "create index bundle_installations_received_at_idx on bundle_installations(received_at_ms)",
131
+ );
132
+ expect(sql).toContain(
133
+ "create index bundle_installations_bundle_idx on bundle_installations(platform, channel, to_bundle_id, received_at_ms)",
134
+ );
135
+ expect(sql).toContain(
136
+ "create index bundle_events_from_bundle_idx on bundle_events(type, platform, channel, from_bundle_id, received_at_ms, id)",
137
+ );
138
+ expect(prisma).toContain("initial Prisma SQL migration");
139
+ expect(prisma).toContain(
140
+ 'alter table bundle_installations alter column install_id type varchar(255) collate "C"',
141
+ );
142
+ expect(prisma).toContain("model bundle_installations {");
143
+ expect(prisma).toContain("install_id String @db.VarChar(255) @id");
144
+ expect(drizzle).toContain("export const bundle_installations = pgTable(");
145
+ expect(drizzle).toContain(
146
+ 'index("bundle_installations_user_id_idx").on(table.user_id, table.install_id)',
147
+ );
148
+ });
149
+
150
+ it("keeps identity indexes valid on MySQL and MSSQL", () => {
151
+ const mysql = createTableSql("mysql", "foreign-keys", v1_0_0).join("\n");
152
+ const mssql = createTableSql("mssql", "foreign-keys", v1_0_0).join("\n");
153
+
154
+ expect(mysql).toContain(
155
+ "install_id varchar(255) character set utf8mb4 collate utf8mb4_0900_bin not null",
156
+ );
157
+ expect(mysql).toContain("user_id varchar(255)");
158
+ expect(mssql).toContain("install_id nvarchar(255) not null");
159
+ expect(mssql).toContain("user_id nvarchar(255)");
160
+
161
+ for (const sql of [mysql, mssql]) {
162
+ expect(sql).toContain(
163
+ "create index bundle_events_install_idx on bundle_events(install_id, type, received_at_ms, id)",
164
+ );
165
+ expect(sql).toContain(
166
+ "create index bundle_installations_user_id_idx on bundle_installations(user_id, install_id)",
167
+ );
168
+ }
169
+ expect(mssql).not.toContain("install_id nvarchar(max)");
170
+ expect(mssql).not.toContain("user_id nvarchar(max)");
171
+ });
172
+
99
173
  it("generates nullable source and artifact relations with the intended deletion rules", () => {
100
174
  const prisma = generatePrismaSchema("postgresql", v1_0_0);
101
175
  const drizzle = generateDrizzleSchema("postgresql", v1_0_0);
@@ -111,7 +185,7 @@ describe("v1.0.0 Release Catalog schema", () => {
111
185
  });
112
186
 
113
187
  it("creates both projection tables and their constraints from empty", () => {
114
- const sql = createTableSql("postgresql").join("\n");
188
+ const sql = createTableSql("postgresql", "foreign-keys", v1_0_0).join("\n");
115
189
 
116
190
  expect(sql).toContain("create table releases");
117
191
  expect(sql).toContain("create table release_catalogs");
@@ -124,7 +198,7 @@ describe("v1.0.0 Release Catalog schema", () => {
124
198
  });
125
199
 
126
200
  it("uses byte-bounded MySQL catalog keys", () => {
127
- const sql = createTableSql("mysql").join("\n");
201
+ const sql = createTableSql("mysql", "foreign-keys", v1_0_0).join("\n");
128
202
 
129
203
  expect(sql).toContain(
130
204
  "scope_key varchar(2048) character set ascii collate ascii_bin",
@@ -366,21 +366,37 @@ export const releaseCatalogsV100 = table(
366
366
  },
367
367
  );
368
368
 
369
+ const insightsTextCollations = {
370
+ postgresql: '"C"',
371
+ mysql: "utf8mb4_0900_bin",
372
+ } as const;
373
+ const insightsProviders = [
374
+ "postgresql",
375
+ "mysql",
376
+ "sqlite",
377
+ "cockroachdb",
378
+ "mongodb",
379
+ ] as const;
380
+
369
381
  export const bundleEventsV100 = table(
370
382
  "bundle_events",
371
383
  {
372
384
  id: idColumn("id", "uuid"),
373
- type: stringColumn("type"),
374
- install_id: stringColumn("install_id"),
375
- user_id: stringColumn("user_id").nullable(),
385
+ type: column("type", varchar(32)),
386
+ install_id: column("install_id", varchar(255)).collate(
387
+ insightsTextCollations,
388
+ ),
389
+ user_id: column("user_id", varchar(255))
390
+ .collate(insightsTextCollations)
391
+ .nullable(),
376
392
  username: stringColumn("username").nullable(),
377
393
  from_release_id: uuid("from_release_id").nullable(),
378
394
  from_bundle_id: uuid("from_bundle_id").nullable(),
379
395
  to_release_id: uuid("to_release_id").nullable(),
380
396
  to_bundle_id: uuid("to_bundle_id"),
381
- platform: stringColumn("platform"),
397
+ platform: stringColumn("platform").collate(insightsTextCollations),
382
398
  app_version: stringColumn("app_version"),
383
- channel: stringColumn("channel"),
399
+ channel: stringColumn("channel").collate(insightsTextCollations),
384
400
  cohort: stringColumn("cohort"),
385
401
  update_strategy: stringColumn("update_strategy").nullable(),
386
402
  fingerprint_hash: stringColumn("fingerprint_hash").nullable(),
@@ -392,35 +408,27 @@ export const bundleEventsV100 = table(
392
408
  index("bundle_events_received_at_idx", ["received_at_ms", "id"]),
393
409
  index("bundle_events_install_idx", [
394
410
  "install_id",
395
- "received_at_ms",
396
- "id",
397
- ]),
398
- index("bundle_events_user_id_idx", ["user_id", "received_at_ms", "id"]),
399
- index("bundle_events_username_idx", ["username", "received_at_ms", "id"]),
400
- index("bundle_events_to_bundle_idx", [
401
- "type",
402
- "to_bundle_id",
403
- "received_at_ms",
404
- "id",
405
- ]),
406
- index("bundle_events_from_bundle_idx", [
407
- "type",
408
- "from_bundle_id",
409
- "received_at_ms",
410
- "id",
411
- ]),
412
- index("bundle_events_to_release_idx", [
413
411
  "type",
414
- "to_release_id",
415
- "received_at_ms",
416
- "id",
417
- ]),
418
- index("bundle_events_from_release_idx", [
419
- "type",
420
- "from_release_id",
421
412
  "received_at_ms",
422
413
  "id",
423
414
  ]),
415
+ index(
416
+ "bundle_events_from_bundle_idx",
417
+ [
418
+ "type",
419
+ "platform",
420
+ "channel",
421
+ "from_bundle_id",
422
+ "received_at_ms",
423
+ "id",
424
+ ],
425
+ insightsProviders,
426
+ ),
427
+ index(
428
+ "bundle_events_to_bundle_idx",
429
+ ["type", "platform", "channel", "to_bundle_id", "received_at_ms", "id"],
430
+ insightsProviders,
431
+ ),
424
432
  ],
425
433
  checks: [
426
434
  check({
@@ -449,6 +457,61 @@ export const bundleEventsV100 = table(
449
457
  },
450
458
  );
451
459
 
460
+ export const bundleInstallationsV100 = table(
461
+ "bundle_installations",
462
+ {
463
+ install_id: idColumn("install_id", varchar(255)).collate(
464
+ insightsTextCollations,
465
+ ),
466
+ id: uuid("id"),
467
+ user_id: column("user_id", varchar(255))
468
+ .collate(insightsTextCollations)
469
+ .nullable(),
470
+ username: stringColumn("username").nullable(),
471
+ to_bundle_id: uuid("to_bundle_id"),
472
+ type: column("type", varchar(32)),
473
+ platform: stringColumn("platform").collate(insightsTextCollations),
474
+ app_version: stringColumn("app_version"),
475
+ channel: stringColumn("channel").collate(insightsTextCollations),
476
+ cohort: stringColumn("cohort"),
477
+ received_at_ms: float("received_at_ms"),
478
+ },
479
+ {
480
+ indexes: [
481
+ index("bundle_installations_user_id_idx", ["user_id", "install_id"]),
482
+ index("bundle_installations_received_at_idx", ["received_at_ms"]),
483
+ index(
484
+ "bundle_installations_scope_idx",
485
+ ["platform", "channel", "received_at_ms"],
486
+ insightsProviders,
487
+ ),
488
+ index(
489
+ "bundle_installations_bundle_idx",
490
+ ["platform", "channel", "to_bundle_id", "received_at_ms"],
491
+ insightsProviders,
492
+ ),
493
+ ],
494
+ checks: [
495
+ check({
496
+ name: "bundle_installations_type_check",
497
+ expression:
498
+ "type in ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED', 'UNCHANGED')",
499
+ sqliteInline: true,
500
+ }),
501
+ check({
502
+ name: "bundle_installations_platform_check",
503
+ expression: "platform in ('ios', 'android')",
504
+ sqliteInline: true,
505
+ }),
506
+ check({
507
+ name: "bundle_installations_received_at_check",
508
+ expression: "received_at_ms >= 0",
509
+ sqliteInline: true,
510
+ }),
511
+ ],
512
+ },
513
+ );
514
+
452
515
  export const v1_0_0 = schema({
453
516
  version: "1.0.0",
454
517
  settingsTable: HOT_UPDATER_SETTINGS_TABLE,
@@ -459,6 +522,7 @@ export const v1_0_0 = schema({
459
522
  releasesV100,
460
523
  releaseCatalogsV100,
461
524
  bundleEventsV100,
525
+ bundleInstallationsV100,
462
526
  apiKeysV100,
463
527
  createSettingsTable("1.0.0"),
464
528
  ],
@@ -1,87 +0,0 @@
1
- //#region src/analytics/bounded/activeOverview.ts
2
- const HOUR_MS = 3600 * 1e3;
3
- const DAY_MS = 24 * HOUR_MS;
4
- const ACTIVE_BUNDLE_EVENT_TYPES = [
5
- "UPDATE_APPLIED",
6
- "RECOVERED",
7
- "RELEASE_ADOPTED",
8
- "UNCHANGED"
9
- ];
10
- const activeWindowDefinitions = {
11
- "24h": {
12
- bucketCount: 24,
13
- bucketSizeMs: HOUR_MS
14
- },
15
- "7d": {
16
- bucketCount: 7,
17
- bucketSizeMs: DAY_MS
18
- },
19
- "30d": {
20
- bucketCount: 30,
21
- bucketSizeMs: DAY_MS
22
- }
23
- };
24
- function getActiveWindowDefinition(window) {
25
- return activeWindowDefinitions[window];
26
- }
27
- function compareCodePoints(left, right) {
28
- if (left < right) return -1;
29
- if (left > right) return 1;
30
- return 0;
31
- }
32
- function isNewer(candidate, current) {
33
- return candidate.received_at_ms > current.received_at_ms || candidate.received_at_ms === current.received_at_ms && compareCodePoints(candidate.id, current.id) > 0;
34
- }
35
- function collectActiveInstallationOverview(request) {
36
- const definition = getActiveWindowDefinition(request.window);
37
- const windowStartMs = request.asOfMs - definition.bucketCount * definition.bucketSizeMs;
38
- const rows = request.rows.filter((row) => row.received_at_ms >= windowStartMs && row.received_at_ms < request.asOfMs);
39
- const latestByInstall = /* @__PURE__ */ new Map();
40
- for (const row of rows) {
41
- const current = latestByInstall.get(row.install_id);
42
- if (current === void 0 || isNewer(row, current)) latestByInstall.set(row.install_id, row);
43
- }
44
- const selectedRows = [...latestByInstall.values()].filter((row) => request.userId === void 0 || row.user_id === request.userId);
45
- const selectedInstallIds = new Set(selectedRows.map((row) => row.install_id));
46
- const bundleCounts = /* @__PURE__ */ new Map();
47
- for (const row of selectedRows) bundleCounts.set(row.to_bundle_id, (bundleCounts.get(row.to_bundle_id) ?? 0) + 1);
48
- const latestByBucket = Array.from({ length: definition.bucketCount }, () => /* @__PURE__ */ new Map());
49
- for (const row of rows) {
50
- if (!selectedInstallIds.has(row.install_id)) continue;
51
- const bucket = latestByBucket[Math.floor((row.received_at_ms - windowStartMs) / definition.bucketSizeMs)];
52
- if (bucket === void 0) continue;
53
- const current = bucket.get(row.install_id);
54
- if (current === void 0 || isNewer(row, current)) bucket.set(row.install_id, row);
55
- }
56
- const bundleCountsByBucket = latestByBucket.map((bucket) => {
57
- const counts = /* @__PURE__ */ new Map();
58
- for (const row of bucket.values()) counts.set(row.to_bundle_id, (counts.get(row.to_bundle_id) ?? 0) + 1);
59
- return counts;
60
- });
61
- const bundleObservationTotals = /* @__PURE__ */ new Map();
62
- for (const counts of bundleCountsByBucket) for (const [bundleId, count] of counts) bundleObservationTotals.set(bundleId, (bundleObservationTotals.get(bundleId) ?? 0) + count);
63
- return {
64
- asOfMs: request.asOfMs,
65
- window: request.window,
66
- activeInstallations: selectedInstallIds.size,
67
- series: latestByBucket.map((bucket, index) => ({
68
- bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
69
- value: bucket.size
70
- })),
71
- bundleSeries: [...bundleObservationTotals].sort(([leftId, leftTotal], [rightId, rightTotal]) => rightTotal - leftTotal || compareCodePoints(leftId, rightId)).map(([bundleId]) => ({
72
- bundleId,
73
- series: bundleCountsByBucket.map((counts, index) => ({
74
- bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
75
- value: counts.get(bundleId) ?? 0
76
- }))
77
- })),
78
- bundles: [...bundleCounts].map(([bundleId, installations]) => ({
79
- bundleId,
80
- installations
81
- })).sort((left, right) => right.installations - left.installations || compareCodePoints(left.bundleId, right.bundleId))
82
- };
83
- }
84
- //#endregion
85
- exports.ACTIVE_BUNDLE_EVENT_TYPES = ACTIVE_BUNDLE_EVENT_TYPES;
86
- exports.collectActiveInstallationOverview = collectActiveInstallationOverview;
87
- exports.getActiveWindowDefinition = getActiveWindowDefinition;