@hot-updater/server 1.0.0-rc.1 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) 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 +10 -3
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +11 -3
  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 +24 -9
  48. package/dist/createHotUpdaterCore.d.cts +1 -1
  49. package/dist/createHotUpdaterCore.d.mts +1 -1
  50. package/dist/createHotUpdaterCore.mjs +23 -9
  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.d.cts +1 -2
  82. package/dist/handler.d.mts +1 -2
  83. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  84. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  85. package/dist/handlerTypes.d.cts +0 -1
  86. package/dist/handlerTypes.d.mts +0 -1
  87. package/dist/handlerVersionRoutes.d.cts +1 -0
  88. package/dist/handlerVersionRoutes.d.mts +1 -0
  89. package/dist/index.cjs +1 -1
  90. package/dist/index.d.cts +4 -4
  91. package/dist/index.d.mts +4 -4
  92. package/dist/index.mjs +1 -1
  93. package/dist/insights/domain.d.cts +37 -69
  94. package/dist/insights/domain.d.mts +37 -69
  95. package/dist/insights/errors.cjs +1 -8
  96. package/dist/insights/errors.mjs +2 -8
  97. package/dist/insights/eventInput.cjs +49 -5
  98. package/dist/insights/eventInput.mjs +48 -6
  99. package/dist/insights/provider.cjs +314 -0
  100. package/dist/insights/provider.d.cts +6 -0
  101. package/dist/insights/provider.d.mts +6 -0
  102. package/dist/insights/provider.mjs +314 -0
  103. package/dist/insights/queryInput.cjs +78 -43
  104. package/dist/insights/queryInput.mjs +76 -40
  105. package/dist/insights/routes.cjs +20 -25
  106. package/dist/insights/routes.mjs +22 -27
  107. package/dist/insights/types.d.cts +27 -12
  108. package/dist/insights/types.d.mts +27 -12
  109. package/dist/node.d.cts +0 -1
  110. package/dist/node.d.mts +0 -1
  111. package/dist/package.cjs +1 -1
  112. package/dist/package.mjs +1 -1
  113. package/dist/schema/index.cjs +17 -3
  114. package/dist/schema/index.mjs +4 -4
  115. package/dist/schema/v1_0_0.cjs +72 -30
  116. package/dist/schema/v1_0_0.mjs +72 -31
  117. package/dist/storageAccess.cjs +0 -1
  118. package/dist/types/index.d.cts +0 -1
  119. package/dist/types/index.d.mts +0 -1
  120. package/package.json +7 -7
  121. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  122. package/src/adapters/drizzle.spec.ts +99 -0
  123. package/src/adapters/drizzle.ts +21 -3
  124. package/src/adapters/drizzleCrud.ts +154 -2
  125. package/src/adapters/drizzleLazyDB.ts +17 -3
  126. package/src/adapters/insightsTransaction.ts +32 -0
  127. package/src/adapters/kysely.spec.ts +29 -0
  128. package/src/adapters/kysely.ts +10 -1
  129. package/src/adapters/kyselyCrud.ts +121 -0
  130. package/src/adapters/mongodb.integration.spec.ts +210 -0
  131. package/src/adapters/mongodb.spec.ts +69 -1
  132. package/src/adapters/mongodb.ts +66 -2
  133. package/src/adapters/mongodbCollections.ts +5 -0
  134. package/src/adapters/mongodbQuery.ts +32 -3
  135. package/src/adapters/mongodbReads.ts +69 -0
  136. package/src/adapters/mongodbTestClient.ts +41 -6
  137. package/src/adapters/mongodbTestFilter.ts +4 -1
  138. package/src/adapters/mongodbWrites.ts +27 -0
  139. package/src/adapters/prisma.spec.ts +97 -0
  140. package/src/adapters/prisma.ts +103 -3
  141. package/src/adapters/prismaRows.ts +37 -1
  142. package/src/adapters/prismaTestClient.ts +22 -5
  143. package/src/apiKeys.integration.spec.ts +1 -1
  144. package/src/createHotUpdaterCore.ts +21 -7
  145. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  146. package/src/db/fixedMigrator.spec.ts +65 -0
  147. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  148. package/src/db/fixedMigratorMongo.ts +10 -10
  149. package/src/db/index.spec.ts +8 -1
  150. package/src/db/schema/insightsCollation.ts +21 -0
  151. package/src/db/schema/sql.ts +8 -5
  152. package/src/db/schemaGenerators.ts +41 -13
  153. package/src/db/types.spec.ts +8 -0
  154. package/src/db/types.ts +10 -4
  155. package/src/handler-standalone.integration.spec.ts +5 -7
  156. package/src/index.ts +2 -2
  157. package/src/insights/domain.ts +39 -75
  158. package/src/insights/errors.ts +0 -7
  159. package/src/insights/eventInput.ts +60 -3
  160. package/src/insights/insights.integration.spec.ts +254 -17
  161. package/src/insights/provider.spec.ts +385 -0
  162. package/src/insights/provider.ts +591 -0
  163. package/src/insights/queryInput.ts +112 -73
  164. package/src/insights/routes.ts +37 -56
  165. package/src/insights/types.ts +47 -36
  166. package/src/schema/dsl-all-versions.spec.ts +3 -1
  167. package/src/schema/v1_0_0.spec.ts +77 -3
  168. package/src/schema/v1_0_0.ts +94 -30
  169. package/dist/insights/bounded/activeOverview.cjs +0 -87
  170. package/dist/insights/bounded/activeOverview.mjs +0 -85
  171. package/dist/insights/bounded/installationSearch.cjs +0 -42
  172. package/dist/insights/bounded/installationSearch.mjs +0 -42
  173. package/dist/insights/bounded/persistence.cjs +0 -39
  174. package/dist/insights/bounded/persistence.mjs +0 -38
  175. package/dist/insights/bounded/provider.cjs +0 -170
  176. package/dist/insights/bounded/provider.d.cts +0 -7
  177. package/dist/insights/bounded/provider.d.mts +0 -7
  178. package/dist/insights/bounded/provider.mjs +0 -170
  179. package/dist/insights/bounded/scan.cjs +0 -144
  180. package/dist/insights/bounded/scan.mjs +0 -138
  181. package/dist/insights/persistence.d.cts +0 -6
  182. package/dist/insights/persistence.d.mts +0 -6
  183. package/src/insights/bounded/activeOverview.spec.ts +0 -189
  184. package/src/insights/bounded/activeOverview.ts +0 -148
  185. package/src/insights/bounded/installationSearch.spec.ts +0 -130
  186. package/src/insights/bounded/installationSearch.ts +0 -69
  187. package/src/insights/bounded/persistence.ts +0 -43
  188. package/src/insights/bounded/provider.spec.ts +0 -219
  189. package/src/insights/bounded/provider.ts +0 -229
  190. package/src/insights/bounded/scan.ts +0 -235
  191. package/src/insights/persistence.ts +0 -10
@@ -1,102 +1,141 @@
1
1
  import type {
2
- ActiveInstallationInput,
3
- BundleEventInsightsWindow,
2
+ ActiveInstallationWindow,
3
+ InsightsBundleSelection,
4
+ InsightsScope,
4
5
  } from "./domain";
5
6
  import { InsightsBadRequestError } from "./errors";
7
+ import type {
8
+ InsightsEventPageInput,
9
+ InsightsUserInstallationPageInput,
10
+ } from "./types";
6
11
 
7
- type IntegerBounds = {
8
- readonly defaultValue: number;
9
- readonly minimum: number;
10
- readonly maximum?: number;
11
- };
12
+ const MAX_PAGE_LIMIT = 100;
13
+ const MAX_IDENTITY_LENGTH = 255;
14
+ const MAX_CURSOR_LENGTH = 8 * 1_024;
12
15
 
13
- const EVENT_LIST_BOUNDS = {
14
- defaultValue: 50,
15
- maximum: 100,
16
- minimum: 1,
17
- } as const satisfies IntegerBounds;
18
- const EVENT_LIST_OFFSET_BOUNDS = {
19
- defaultValue: 0,
20
- minimum: 0,
21
- } as const satisfies IntegerBounds;
22
- const MAX_USER_ID_LENGTH = 1_024;
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
23
 
24
- export type PaginationInput = {
25
- readonly limit: number;
26
- readonly offset: number;
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;
27
32
  };
28
33
 
29
- export type InsightsQueryInput = PaginationInput & {
30
- readonly window: BundleEventInsightsWindow;
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;
31
43
  };
32
44
 
33
- function parseInteger(url: URL, key: string, bounds: IntegerBounds): number {
34
- const value = url.searchParams.get(key);
35
- if (value === null) return bounds.defaultValue;
36
- const parsed = Number(value);
45
+ const readId = (
46
+ url: URL,
47
+ key: string,
48
+ maximumLength = MAX_IDENTITY_LENGTH,
49
+ ): string => {
50
+ const value = readSingle(url, key);
37
51
  if (
38
- !Number.isSafeInteger(parsed) ||
39
- parsed < bounds.minimum ||
40
- (bounds.maximum !== undefined && parsed > bounds.maximum)
52
+ value === undefined ||
53
+ value.length === 0 ||
54
+ value.length > maximumLength
41
55
  ) {
42
56
  throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
43
57
  }
44
- return parsed;
45
- }
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
+ };
46
70
 
47
- export const parsePagination = (request: Request): PaginationInput => {
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 => {
48
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
+ }
49
100
  return {
50
- limit: parseInteger(url, "limit", EVENT_LIST_BOUNDS),
51
- offset: parseInteger(url, "offset", EVENT_LIST_OFFSET_BOUNDS),
101
+ beforeReceivedAtMs: readTimestamp(url, "beforeReceivedAtMs"),
102
+ sinceMs: readTimestamp(url, "sinceMs"),
103
+ cursor: readCursor(url),
104
+ limit: readPageLimit(url),
105
+ ...(bundle === undefined ? {} : { bundle }),
52
106
  };
53
107
  };
54
108
 
55
- export const parseInsightsQuery = (request: Request): InsightsQueryInput => {
109
+ export const parseUserInstallationPageInput = (
110
+ request: Request,
111
+ ): InsightsUserInstallationPageInput => {
56
112
  const url = new URL(request.url);
57
- const window = url.searchParams.get("window") ?? "24h";
58
- if (
59
- window !== "24h" &&
60
- window !== "7d" &&
61
- window !== "30d" &&
62
- window !== "all"
63
- ) {
64
- throw new InsightsBadRequestError("Invalid 'window' query parameter.");
65
- }
66
- return { ...parsePagination(request), window };
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
+ };
67
120
  };
68
121
 
69
- export const parseActiveInstallationInput = (
122
+ export const parseReportingOverviewInput = (
70
123
  request: Request,
71
- ): ActiveInstallationInput => {
124
+ ): InsightsScope & {
125
+ readonly window: ActiveInstallationWindow;
126
+ readonly bundleId?: string;
127
+ } => {
72
128
  const url = new URL(request.url);
73
- const windows = url.searchParams.getAll("window");
74
- if (windows.length > 1) {
75
- throw new InsightsBadRequestError("Duplicate 'window' query parameter.");
76
- }
77
- const window = windows[0] ?? "30d";
129
+ const window = readSingle(url, "window") ?? "30d";
78
130
  if (window !== "24h" && window !== "7d" && window !== "30d") {
79
131
  throw new InsightsBadRequestError("Invalid 'window' query parameter.");
80
132
  }
81
- const userIds = url.searchParams.getAll("userId");
82
- if (userIds.length > 1) {
83
- throw new InsightsBadRequestError("Duplicate 'userId' query parameter.");
84
- }
85
- const userId = userIds[0];
86
- if (
87
- userId !== undefined &&
88
- (userId.length === 0 || userId.length > MAX_USER_ID_LENGTH)
89
- ) {
90
- throw new InsightsBadRequestError("Invalid 'userId' query parameter.");
91
- }
92
- return userId === undefined ? { window } : { window, userId };
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
+ };
93
141
  };
94
-
95
- export const parseSearchInput = (
96
- request: Request,
97
- ): PaginationInput & { readonly query: string } => ({
98
- ...parsePagination(request),
99
- query: new URL(request.url).searchParams.get("query")?.trim() ?? "",
100
- });
101
-
102
- export const parseEmptyInput = (): undefined => undefined;
@@ -2,14 +2,12 @@ import type { RouteHandler } from "../handlerTypes";
2
2
  import {
3
3
  InsightsBadRequestError,
4
4
  InsightsPayloadTooLargeError,
5
- InsightsScanLimitExceededError,
6
5
  } from "./errors";
7
6
  import { parseBundleEventRequest } from "./eventInput";
8
7
  import {
9
- parseActiveInstallationInput,
10
- parseInsightsQuery,
11
- parsePagination,
12
- parseSearchInput,
8
+ parseReportingOverviewInput,
9
+ parseEventPageInput,
10
+ parseUserInstallationPageInput,
13
11
  } from "./queryInput";
14
12
  import type { InsightsProvider } from "./types";
15
13
 
@@ -27,7 +25,11 @@ const requireParam = (
27
25
  if (value === undefined || value.length === 0) {
28
26
  throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
29
27
  }
30
- return value;
28
+ try {
29
+ return decodeURIComponent(value);
30
+ } catch {
31
+ throw new InsightsBadRequestError(`Invalid route parameter: ${key}`);
32
+ }
31
33
  };
32
34
 
33
35
  const run = async (operation: () => Promise<Response>): Promise<Response> => {
@@ -40,17 +42,6 @@ const run = async (operation: () => Promise<Response>): Promise<Response> => {
40
42
  if (error instanceof InsightsPayloadTooLargeError) {
41
43
  return json({ error: error.message }, 413);
42
44
  }
43
- if (error instanceof InsightsScanLimitExceededError) {
44
- return json(
45
- {
46
- error: {
47
- code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
48
- limit: error.limit,
49
- },
50
- },
51
- 503,
52
- );
53
- }
54
45
  throw error;
55
46
  }
56
47
  };
@@ -66,43 +57,34 @@ export const createInsightsRouteHandlers = (
66
57
  await provider.appendBundleEvent(await parseBundleEventRequest(request));
67
58
  return new Response(null, { status: 204 });
68
59
  }),
69
- getBundleEventSummary: (params) =>
70
- query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
71
- getBundleEventInsights: (params, request) =>
72
- query(() => {
73
- const input = parseInsightsQuery(request);
74
- return provider.getBundleEventInsights(
75
- requireParam(params, "id"),
76
- input.window,
77
- input.limit,
78
- input.offset,
79
- );
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);
80
72
  }),
81
- getBundleEventOverview: () => query(() => provider.getBundleEventOverview()),
82
- getActiveInstallationOverview: (_params, request) =>
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
83
  query(() =>
84
- provider.getActiveInstallationOverview(
85
- parseActiveInstallationInput(request),
84
+ provider.pageInstallationsByCurrentUserId(
85
+ parseUserInstallationPageInput(request),
86
86
  ),
87
87
  ),
88
- searchInstallations: (_params, request) =>
89
- query(() => {
90
- const input = parseSearchInput(request);
91
- return provider.searchInstallations(
92
- input.query,
93
- input.limit,
94
- input.offset,
95
- );
96
- }),
97
- getInstallationHistory: (params, request) =>
98
- query(() => {
99
- const input = parsePagination(request);
100
- return provider.getInstallationHistory(
101
- requireParam(params, "installId"),
102
- input.limit,
103
- input.offset,
104
- );
105
- }),
106
88
  });
107
89
 
108
90
  export const registerInsightsClientRoutes = (
@@ -114,10 +96,9 @@ export const registerInsightsClientRoutes = (
114
96
  export const registerInsightsAdminRoutes = (
115
97
  add: (method: string, path: string, handler: string) => void,
116
98
  ): void => {
117
- add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
118
- add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
119
- add("GET", "/installations/overview", "getBundleEventOverview");
120
- add("GET", "/installations/active", "getActiveInstallationOverview");
121
- add("GET", "/installations", "searchInstallations");
122
- add("GET", "/installations/:installId/events", "getInstallationHistory");
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");
123
104
  };
@@ -1,44 +1,55 @@
1
1
  import type {
2
- ActiveInstallationInput,
3
- ActiveInstallationOverview,
4
- BundleEventInsightsResult,
5
- BundleEventInsightsWindow,
6
- BundleEventOverview,
7
- BundleEventSummary,
8
- BundleEventSummaryByBundle,
2
+ ReportingOverview,
3
+ InsightsBundleSelection,
4
+ InsightsScope,
5
+ ActiveInstallationWindow,
9
6
  CreateBundleEventRequest,
7
+ CursorPage,
8
+ EventCursorPage,
9
+ EventHistoryRow,
10
10
  InstallationHistoryRow,
11
- InstallationSearchRow,
12
- OffsetPaginationResult,
11
+ InstallationRow,
13
12
  } from "./domain";
14
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
+
15
35
  export type InsightsProvider = {
16
- readonly mode: "bounded";
17
- readonly maxMatchingRows: number;
18
36
  appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
19
- getBundleEventSummary(bundleId: string): Promise<BundleEventSummary>;
20
- getBundleEventSummaries(
21
- bundleIds: readonly string[],
22
- window: BundleEventInsightsWindow,
23
- ): Promise<readonly BundleEventSummaryByBundle[]>;
24
- getBundleEventInsights(
25
- bundleId: string,
26
- window: BundleEventInsightsWindow,
27
- limit: number,
28
- offset: number,
29
- ): Promise<BundleEventInsightsResult>;
30
- getBundleEventOverview(): Promise<BundleEventOverview>;
31
- getActiveInstallationOverview(
32
- input: ActiveInstallationInput,
33
- ): Promise<ActiveInstallationOverview>;
34
- searchInstallations(
35
- query: string,
36
- limit: number,
37
- offset: number,
38
- ): Promise<OffsetPaginationResult<InstallationSearchRow>>;
39
- getInstallationHistory(
40
- installId: string,
41
- limit: number,
42
- offset: number,
43
- ): Promise<OffsetPaginationResult<InstallationHistoryRow>>;
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>;
44
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",