@hot-updater/server 1.0.0-rc.0 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/_rolldown/runtime.cjs +1 -1
- package/dist/adapters/databaseConstraintErrors.cjs +0 -1
- package/dist/adapters/databasePluginUtils.cjs +0 -1
- package/dist/adapters/drizzle.cjs +11 -4
- package/dist/adapters/drizzle.d.cts +0 -1
- package/dist/adapters/drizzle.d.mts +0 -1
- package/dist/adapters/drizzle.mjs +12 -4
- package/dist/adapters/drizzleCrud.cjs +62 -1
- package/dist/adapters/drizzleCrud.mjs +61 -2
- package/dist/adapters/drizzleLazyDB.cjs +6 -1
- package/dist/adapters/drizzleLazyDB.mjs +6 -1
- package/dist/adapters/drizzleQuery.cjs +3 -2
- package/dist/adapters/drizzleQuery.mjs +3 -1
- package/dist/adapters/insightsTransaction.cjs +22 -0
- package/dist/adapters/insightsTransaction.mjs +22 -0
- package/dist/adapters/kysely.cjs +2 -1
- package/dist/adapters/kysely.d.cts +0 -1
- package/dist/adapters/kysely.d.mts +0 -1
- package/dist/adapters/kysely.mjs +3 -1
- package/dist/adapters/kyselyCrud.cjs +54 -3
- package/dist/adapters/kyselyCrud.mjs +54 -3
- package/dist/adapters/kyselyQuery.cjs +2 -2
- package/dist/adapters/kyselyQuery.mjs +2 -1
- package/dist/adapters/mongodb.cjs +27 -1
- package/dist/adapters/mongodb.d.cts +1 -1
- package/dist/adapters/mongodb.d.mts +1 -1
- package/dist/adapters/mongodb.mjs +28 -1
- package/dist/adapters/mongodbCollections.cjs +2 -0
- package/dist/adapters/mongodbCollections.mjs +2 -0
- package/dist/adapters/mongodbQuery.cjs +21 -4
- package/dist/adapters/mongodbQuery.mjs +21 -5
- package/dist/adapters/mongodbReads.cjs +91 -29
- package/dist/adapters/mongodbReads.mjs +92 -29
- package/dist/adapters/mongodbWrites.cjs +16 -2
- package/dist/adapters/mongodbWrites.mjs +18 -3
- package/dist/adapters/prisma.cjs +55 -7
- package/dist/adapters/prisma.d.cts +0 -1
- package/dist/adapters/prisma.d.mts +0 -1
- package/dist/adapters/prisma.mjs +56 -7
- package/dist/adapters/prismaQuery.cjs +0 -1
- package/dist/adapters/prismaRows.cjs +22 -1
- package/dist/adapters/prismaRows.mjs +22 -1
- package/dist/apiKeys.cjs +6 -2
- package/dist/apiKeys.d.cts +0 -1
- package/dist/apiKeys.d.mts +0 -1
- package/dist/apiKeys.mjs +3 -3
- package/dist/createHotUpdaterCore.cjs +27 -12
- package/dist/createHotUpdaterCore.d.cts +8 -8
- package/dist/createHotUpdaterCore.d.mts +8 -8
- package/dist/createHotUpdaterCore.mjs +26 -12
- package/dist/db/createBundleDiff.cjs +2 -2
- package/dist/db/createBundleDiff.d.cts +1 -5
- package/dist/db/createBundleDiff.d.mts +1 -5
- package/dist/db/databasePluginCore.cjs +0 -1
- package/dist/db/fixedMigrator.cjs +2 -2
- package/dist/db/fixedMigrator.mjs +2 -2
- package/dist/db/fixedMigratorMongo.cjs +6 -4
- package/dist/db/fixedMigratorMongo.mjs +6 -4
- package/dist/db/index.d.cts +0 -1
- package/dist/db/index.d.mts +0 -1
- package/dist/db/releaseCatalog.cjs +0 -1
- package/dist/db/releaseCatalog.d.cts +0 -1
- package/dist/db/releaseCatalog.d.mts +0 -1
- package/dist/db/schema/insightsCollation.cjs +14 -0
- package/dist/db/schema/insightsCollation.mjs +14 -0
- package/dist/db/schema/sql.cjs +11 -7
- package/dist/db/schema/sql.mjs +8 -8
- package/dist/db/schemaGenerators.cjs +28 -9
- package/dist/db/schemaGenerators.mjs +28 -9
- package/dist/db/schemaReadiness.cjs +2 -0
- package/dist/db/schemaReadiness.d.cts +1 -0
- package/dist/db/schemaReadiness.d.mts +1 -0
- package/dist/db/schemaReadiness.mjs +2 -0
- package/dist/db/sqlMigrationExecution.cjs +1 -1
- package/dist/db/sqlMigrationExecution.mjs +1 -0
- package/dist/db/types.cjs +3 -1
- package/dist/db/types.d.cts +3 -6
- package/dist/db/types.d.mts +3 -6
- package/dist/db/types.mjs +2 -2
- package/dist/db/updateArtifacts.cjs +0 -1
- package/dist/handler.cjs +5 -5
- package/dist/handler.d.cts +1 -2
- package/dist/handler.d.mts +1 -2
- package/dist/handler.mjs +5 -5
- package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
- package/dist/handlerReleaseManagementRoutes.cjs +0 -1
- package/dist/handlerTypes.d.cts +0 -1
- package/dist/handlerTypes.d.mts +0 -1
- package/dist/handlerVersionRoutes.d.cts +1 -0
- package/dist/handlerVersionRoutes.d.mts +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +2 -2
- package/dist/insights/domain.d.cts +89 -0
- package/dist/insights/domain.d.mts +89 -0
- package/dist/insights/errors.cjs +15 -0
- package/dist/insights/errors.mjs +14 -0
- package/dist/{analytics → insights}/eventInput.cjs +58 -14
- package/dist/{analytics → insights}/eventInput.mjs +58 -16
- package/dist/insights/provider.cjs +314 -0
- package/dist/insights/provider.d.cts +6 -0
- package/dist/insights/provider.d.mts +6 -0
- package/dist/insights/provider.mjs +314 -0
- package/dist/insights/queryInput.cjs +93 -0
- package/dist/insights/queryInput.mjs +91 -0
- package/dist/insights/routes.cjs +58 -0
- package/dist/insights/routes.mjs +56 -0
- package/dist/insights/types.d.cts +32 -0
- package/dist/insights/types.d.mts +32 -0
- package/dist/node.d.cts +0 -1
- package/dist/node.d.mts +0 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/schema/index.cjs +17 -3
- package/dist/schema/index.mjs +4 -4
- package/dist/schema/v1_0_0.cjs +72 -30
- package/dist/schema/v1_0_0.mjs +72 -31
- package/dist/storageAccess.cjs +0 -1
- package/dist/types/index.d.cts +0 -1
- package/dist/types/index.d.mts +0 -1
- package/package.json +7 -7
- package/src/adapters/databasePluginTestDatabase.ts +14 -0
- package/src/adapters/drizzle.spec.ts +99 -0
- package/src/adapters/drizzle.ts +22 -4
- package/src/adapters/drizzleCrud.ts +154 -2
- package/src/adapters/drizzleLazyDB.ts +17 -3
- package/src/adapters/insightsTransaction.ts +32 -0
- package/src/adapters/kysely.spec.ts +29 -0
- package/src/adapters/kysely.ts +10 -1
- package/src/adapters/kyselyCrud.ts +121 -0
- package/src/adapters/mongodb.integration.spec.ts +210 -0
- package/src/adapters/mongodb.spec.ts +69 -1
- package/src/adapters/mongodb.ts +66 -2
- package/src/adapters/mongodbCollections.ts +5 -0
- package/src/adapters/mongodbQuery.ts +32 -3
- package/src/adapters/mongodbReads.ts +69 -0
- package/src/adapters/mongodbTestClient.ts +41 -6
- package/src/adapters/mongodbTestFilter.ts +4 -1
- package/src/adapters/mongodbWrites.ts +27 -0
- package/src/adapters/prisma.spec.ts +97 -0
- package/src/adapters/prisma.ts +103 -3
- package/src/adapters/prismaRows.ts +37 -1
- package/src/adapters/prismaTestClient.ts +22 -5
- package/src/apiKeys.integration.spec.ts +3 -3
- package/src/createHotUpdaterCore.ts +30 -16
- package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
- package/src/db/fixedMigrator.spec.ts +65 -0
- package/src/db/fixedMigratorMongo.spec.ts +56 -9
- package/src/db/fixedMigratorMongo.ts +10 -10
- package/src/db/index.spec.ts +8 -1
- package/src/db/schema/insightsCollation.ts +21 -0
- package/src/db/schema/sql.ts +8 -5
- package/src/db/schemaGenerators.ts +41 -13
- package/src/db/types.spec.ts +11 -3
- package/src/db/types.ts +13 -7
- package/src/handler-standalone.integration.spec.ts +6 -8
- package/src/handler.ts +12 -12
- package/src/index.ts +3 -3
- package/src/insights/domain.ts +103 -0
- package/src/insights/errors.ts +11 -0
- package/src/{analytics → insights}/eventInput.ts +71 -14
- package/src/insights/insights.integration.spec.ts +381 -0
- package/src/insights/provider.spec.ts +385 -0
- package/src/insights/provider.ts +591 -0
- package/src/insights/queryInput.ts +141 -0
- package/src/insights/routes.ts +104 -0
- package/src/insights/types.ts +55 -0
- package/src/schema/dsl-all-versions.spec.ts +3 -1
- package/src/schema/v1_0_0.spec.ts +77 -3
- package/src/schema/v1_0_0.ts +94 -30
- package/dist/analytics/bounded/activeOverview.cjs +0 -87
- package/dist/analytics/bounded/activeOverview.mjs +0 -85
- package/dist/analytics/bounded/installationSearch.cjs +0 -42
- package/dist/analytics/bounded/installationSearch.mjs +0 -42
- package/dist/analytics/bounded/persistence.cjs +0 -39
- package/dist/analytics/bounded/persistence.mjs +0 -38
- package/dist/analytics/bounded/provider.cjs +0 -170
- package/dist/analytics/bounded/provider.d.cts +0 -7
- package/dist/analytics/bounded/provider.d.mts +0 -7
- package/dist/analytics/bounded/provider.mjs +0 -170
- package/dist/analytics/bounded/scan.cjs +0 -144
- package/dist/analytics/bounded/scan.mjs +0 -138
- package/dist/analytics/domain.d.cts +0 -121
- package/dist/analytics/domain.d.mts +0 -121
- package/dist/analytics/errors.cjs +0 -22
- package/dist/analytics/errors.mjs +0 -20
- package/dist/analytics/persistence.d.cts +0 -6
- package/dist/analytics/persistence.d.mts +0 -6
- package/dist/analytics/queryInput.cjs +0 -58
- package/dist/analytics/queryInput.mjs +0 -55
- package/dist/analytics/routes.cjs +0 -63
- package/dist/analytics/routes.mjs +0 -61
- package/dist/analytics/types.d.cts +0 -17
- package/dist/analytics/types.d.mts +0 -17
- package/src/analytics/analytics.integration.spec.ts +0 -144
- package/src/analytics/bounded/activeOverview.spec.ts +0 -189
- package/src/analytics/bounded/activeOverview.ts +0 -148
- package/src/analytics/bounded/installationSearch.spec.ts +0 -130
- package/src/analytics/bounded/installationSearch.ts +0 -69
- package/src/analytics/bounded/persistence.ts +0 -43
- package/src/analytics/bounded/provider.spec.ts +0 -219
- package/src/analytics/bounded/provider.ts +0 -229
- package/src/analytics/bounded/scan.ts +0 -235
- package/src/analytics/domain.ts +0 -139
- package/src/analytics/errors.ts +0 -18
- package/src/analytics/persistence.ts +0 -10
- package/src/analytics/queryInput.ts +0 -102
- package/src/analytics/routes.ts +0 -123
- package/src/analytics/types.ts +0 -44
package/src/adapters/prisma.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createDatabasePlugin,
|
|
3
3
|
DatabasePluginInputError,
|
|
4
|
+
type InsightsRecordInput,
|
|
5
|
+
type InsightsModel,
|
|
4
6
|
} from "@hot-updater/plugin-core";
|
|
5
7
|
import {
|
|
6
8
|
createDatabasePluginAdapter,
|
|
@@ -30,6 +32,7 @@ import { createPrismaOrderBy, createPrismaWhere } from "./prismaQuery";
|
|
|
30
32
|
import {
|
|
31
33
|
getPrismaDelegate,
|
|
32
34
|
parsePrismaBundleEventRow,
|
|
35
|
+
parsePrismaInsightsInstallationRow,
|
|
33
36
|
parsePrismaBundleRow,
|
|
34
37
|
parsePrismaChannelRow,
|
|
35
38
|
parsePrismaApiKeyRow,
|
|
@@ -69,6 +72,7 @@ const runPrismaTransaction = <TResult>(
|
|
|
69
72
|
client: PrismaTransactionClient,
|
|
70
73
|
isolationLevel: "default" | "serializable",
|
|
71
74
|
callback: (client: object) => Promise<TResult>,
|
|
75
|
+
retryUniqueConflict = false,
|
|
72
76
|
): Promise<TResult> => {
|
|
73
77
|
const execute = () =>
|
|
74
78
|
isolationLevel === "serializable"
|
|
@@ -86,7 +90,8 @@ const runPrismaTransaction = <TResult>(
|
|
|
86
90
|
typeof error !== "object" ||
|
|
87
91
|
error === null ||
|
|
88
92
|
!("code" in error) ||
|
|
89
|
-
error.code !== "P2034"
|
|
93
|
+
(error.code !== "P2034" &&
|
|
94
|
+
!(retryUniqueConflict && error.code === "P2002"))
|
|
90
95
|
) {
|
|
91
96
|
throw error;
|
|
92
97
|
}
|
|
@@ -135,6 +140,8 @@ const findMany = async (
|
|
|
135
140
|
return parsePrismaRows(rows, parsePrismaPatchRow);
|
|
136
141
|
case "bundle_events":
|
|
137
142
|
return parsePrismaRows(rows, parsePrismaBundleEventRow);
|
|
143
|
+
case "bundle_installations":
|
|
144
|
+
return parsePrismaRows(rows, parsePrismaInsightsInstallationRow);
|
|
138
145
|
case "channels":
|
|
139
146
|
return parsePrismaRows(rows, parsePrismaChannelRow);
|
|
140
147
|
case "api_keys":
|
|
@@ -218,7 +225,9 @@ const createCrudImplementation = (
|
|
|
218
225
|
const where =
|
|
219
226
|
input.model === "channels"
|
|
220
227
|
? { name: input.data.name }
|
|
221
|
-
:
|
|
228
|
+
: input.model === "api_keys"
|
|
229
|
+
? { hash: input.data.hash }
|
|
230
|
+
: { install_id: input.data.install_id };
|
|
222
231
|
row = await delegate.upsert({
|
|
223
232
|
where,
|
|
224
233
|
create: input.data,
|
|
@@ -234,6 +243,8 @@ const createCrudImplementation = (
|
|
|
234
243
|
return parsePrismaPatchRow(row);
|
|
235
244
|
case "bundle_events":
|
|
236
245
|
return parsePrismaBundleEventRow(row);
|
|
246
|
+
case "bundle_installations":
|
|
247
|
+
return parsePrismaInsightsInstallationRow(row);
|
|
237
248
|
case "channels":
|
|
238
249
|
return parsePrismaChannelRow(row);
|
|
239
250
|
case "api_keys":
|
|
@@ -245,6 +256,36 @@ const createCrudImplementation = (
|
|
|
245
256
|
}
|
|
246
257
|
},
|
|
247
258
|
update: async (input) => {
|
|
259
|
+
if (input.model === "bundle_installations") {
|
|
260
|
+
const installId = input.where.find(
|
|
261
|
+
(item) =>
|
|
262
|
+
item.field === "install_id" &&
|
|
263
|
+
(item.operator === undefined || item.operator === "eq") &&
|
|
264
|
+
typeof item.value === "string",
|
|
265
|
+
)?.value;
|
|
266
|
+
if (typeof installId !== "string") {
|
|
267
|
+
throw new PrismaAdapterError(
|
|
268
|
+
"bundle_installations update requires install_id",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const delegate = getPrismaDelegate(client, "bundle_installations");
|
|
272
|
+
if (delegate.updateMany === undefined) {
|
|
273
|
+
throw new PrismaAdapterError(
|
|
274
|
+
'model delegate "bundle_installations" requires updateMany',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const result = await delegate.updateMany({
|
|
278
|
+
where: createPrismaWhere(input.where, provider),
|
|
279
|
+
data: input.update,
|
|
280
|
+
});
|
|
281
|
+
if (result.count === 0) return null;
|
|
282
|
+
const stored = await delegate.findFirst({
|
|
283
|
+
where: { install_id: installId },
|
|
284
|
+
});
|
|
285
|
+
return stored === null
|
|
286
|
+
? null
|
|
287
|
+
: parsePrismaInsightsInstallationRow(stored);
|
|
288
|
+
}
|
|
248
289
|
const id = input.where[0]?.value;
|
|
249
290
|
if (typeof id !== "string") {
|
|
250
291
|
throw new PrismaAdapterError(
|
|
@@ -343,6 +384,8 @@ const createCrudImplementation = (
|
|
|
343
384
|
return parsePrismaReleaseRow(row);
|
|
344
385
|
case "release_catalogs":
|
|
345
386
|
return parsePrismaReleaseCatalogRow(row);
|
|
387
|
+
case "bundle_installations":
|
|
388
|
+
return parsePrismaInsightsInstallationRow(row);
|
|
346
389
|
}
|
|
347
390
|
},
|
|
348
391
|
findMany: (input) => findMany(client, input, provider),
|
|
@@ -356,6 +399,48 @@ const createPrismaImplementation = (
|
|
|
356
399
|
const crud = createCrudImplementation(client, provider, relationMode);
|
|
357
400
|
const implementation: DatabasePluginImplementation = {
|
|
358
401
|
...crud,
|
|
402
|
+
recordInsights: (input: InsightsRecordInput) => {
|
|
403
|
+
if (!hasCallbackTransaction(client)) {
|
|
404
|
+
throw new PrismaAdapterError(
|
|
405
|
+
"Insights recording requires callback transactions",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return runPrismaTransaction(
|
|
409
|
+
client,
|
|
410
|
+
"serializable",
|
|
411
|
+
async (transactionClient) => {
|
|
412
|
+
const events = getPrismaDelegate(transactionClient, "bundle_events");
|
|
413
|
+
if (
|
|
414
|
+
(await events.findFirst({ where: { id: input.event.id } })) !== null
|
|
415
|
+
)
|
|
416
|
+
return;
|
|
417
|
+
await events.create({ data: input.event });
|
|
418
|
+
const installations = getPrismaDelegate(
|
|
419
|
+
transactionClient,
|
|
420
|
+
"bundle_installations",
|
|
421
|
+
);
|
|
422
|
+
const existing = await installations.findFirst({
|
|
423
|
+
where: { install_id: input.installation.install_id },
|
|
424
|
+
});
|
|
425
|
+
if (existing === null) {
|
|
426
|
+
await installations.create({ data: input.installation });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const current = parsePrismaInsightsInstallationRow(existing);
|
|
430
|
+
if (
|
|
431
|
+
current.received_at_ms > input.installation.received_at_ms ||
|
|
432
|
+
(current.received_at_ms === input.installation.received_at_ms &&
|
|
433
|
+
current.id >= input.installation.id)
|
|
434
|
+
)
|
|
435
|
+
return;
|
|
436
|
+
await installations.update({
|
|
437
|
+
where: { install_id: input.installation.install_id },
|
|
438
|
+
data: input.installation,
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
true,
|
|
442
|
+
);
|
|
443
|
+
},
|
|
359
444
|
deleteChannel: (input) => {
|
|
360
445
|
if (!hasCallbackTransaction(client)) {
|
|
361
446
|
throw new PrismaAdapterError(
|
|
@@ -442,10 +527,25 @@ export const prismaAdapter = (
|
|
|
442
527
|
config.provider,
|
|
443
528
|
),
|
|
444
529
|
);
|
|
530
|
+
const unsupportedMssqlInsights = async (): Promise<never> => {
|
|
531
|
+
throw new PrismaAdapterError(
|
|
532
|
+
"SQL Server Insights is unsupported: string keys pad trailing spaces and do not provide the required exact UTF-8 identity ordering",
|
|
533
|
+
);
|
|
534
|
+
};
|
|
535
|
+
const insights: InsightsModel =
|
|
536
|
+
config.provider === "mssql"
|
|
537
|
+
? {
|
|
538
|
+
record: unsupportedMssqlInsights,
|
|
539
|
+
listEvents: unsupportedMssqlInsights,
|
|
540
|
+
findInstallations: unsupportedMssqlInsights,
|
|
541
|
+
countInstallations: unsupportedMssqlInsights,
|
|
542
|
+
countEvents: unsupportedMssqlInsights,
|
|
543
|
+
}
|
|
544
|
+
: adapter.models.insights;
|
|
445
545
|
return Object.assign(
|
|
446
546
|
createDatabasePlugin({
|
|
447
547
|
name: "prisma",
|
|
448
|
-
models: adapter.models,
|
|
548
|
+
models: { ...adapter.models, insights },
|
|
449
549
|
commit: adapter.commit,
|
|
450
550
|
}),
|
|
451
551
|
{
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
BundleRow,
|
|
5
5
|
ChannelRow,
|
|
6
6
|
ApiKeyRow,
|
|
7
|
+
InsightsInstallationRow,
|
|
7
8
|
ReleaseCatalogRow,
|
|
8
9
|
ReleaseRow,
|
|
9
10
|
} from "@hot-updater/plugin-core";
|
|
@@ -19,7 +20,9 @@ export type PrismaDelegate = {
|
|
|
19
20
|
readonly findFirst: (args?: PrismaQuery) => Promise<unknown>;
|
|
20
21
|
readonly findMany: (args?: PrismaQuery) => Promise<readonly unknown[]>;
|
|
21
22
|
readonly update: (args: PrismaQuery) => Promise<unknown>;
|
|
22
|
-
readonly updateMany?: (
|
|
23
|
+
readonly updateMany?: (
|
|
24
|
+
args: PrismaQuery,
|
|
25
|
+
) => Promise<{ readonly count: number }>;
|
|
23
26
|
readonly upsert: (args: PrismaQuery) => Promise<unknown>;
|
|
24
27
|
};
|
|
25
28
|
|
|
@@ -48,6 +51,7 @@ const modelDelegates = {
|
|
|
48
51
|
bundles: "bundles",
|
|
49
52
|
bundle_patches: "bundle_patches",
|
|
50
53
|
bundle_events: "bundle_events",
|
|
54
|
+
bundle_installations: "bundle_installations",
|
|
51
55
|
channels: "channels",
|
|
52
56
|
api_keys: "api_keys",
|
|
53
57
|
releases: "releases",
|
|
@@ -196,6 +200,38 @@ export const parsePrismaBundleEventRow = (value: unknown): BundleEventRow => {
|
|
|
196
200
|
} as BundleEventRow;
|
|
197
201
|
};
|
|
198
202
|
|
|
203
|
+
export const parsePrismaInsightsInstallationRow = (
|
|
204
|
+
value: unknown,
|
|
205
|
+
): InsightsInstallationRow => {
|
|
206
|
+
if (!isRecord(value)) {
|
|
207
|
+
throw new PrismaAdapterError("invalid installation row");
|
|
208
|
+
}
|
|
209
|
+
const platform = readString(value, "platform");
|
|
210
|
+
const type = readString(value, "type");
|
|
211
|
+
if (
|
|
212
|
+
(platform !== "ios" && platform !== "android") ||
|
|
213
|
+
(type !== "UPDATE_APPLIED" &&
|
|
214
|
+
type !== "RECOVERED" &&
|
|
215
|
+
type !== "RELEASE_ADOPTED" &&
|
|
216
|
+
type !== "UNCHANGED")
|
|
217
|
+
) {
|
|
218
|
+
throw new PrismaAdapterError("invalid installation fields");
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
install_id: readString(value, "install_id"),
|
|
222
|
+
id: readString(value, "id"),
|
|
223
|
+
user_id: readNullableString(value, "user_id"),
|
|
224
|
+
username: readNullableString(value, "username"),
|
|
225
|
+
to_bundle_id: readString(value, "to_bundle_id"),
|
|
226
|
+
type,
|
|
227
|
+
platform,
|
|
228
|
+
app_version: readString(value, "app_version"),
|
|
229
|
+
channel: readString(value, "channel"),
|
|
230
|
+
cohort: readString(value, "cohort"),
|
|
231
|
+
received_at_ms: readNumber(value, "received_at_ms"),
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
|
|
199
235
|
export const parsePrismaApiKeyRow = (value: unknown): ApiKeyRow => {
|
|
200
236
|
if (!isRecord(value)) {
|
|
201
237
|
throw new PrismaAdapterError("invalid API key row");
|
|
@@ -4,9 +4,11 @@ import type {
|
|
|
4
4
|
BundleRow,
|
|
5
5
|
ChannelRow,
|
|
6
6
|
ApiKeyRow,
|
|
7
|
+
InsightsInstallationRow,
|
|
7
8
|
ReleaseCatalogRow,
|
|
8
9
|
ReleaseRow,
|
|
9
10
|
} from "@hot-updater/plugin-core";
|
|
11
|
+
import { compareInsightsText } from "@hot-updater/plugin-core";
|
|
10
12
|
|
|
11
13
|
type Row =
|
|
12
14
|
| BundleEventRow
|
|
@@ -14,6 +16,7 @@ type Row =
|
|
|
14
16
|
| BundleRow
|
|
15
17
|
| ChannelRow
|
|
16
18
|
| ApiKeyRow
|
|
19
|
+
| InsightsInstallationRow
|
|
17
20
|
| ReleaseRow
|
|
18
21
|
| ReleaseCatalogRow;
|
|
19
22
|
type Table = Row[];
|
|
@@ -21,6 +24,7 @@ type Tables = {
|
|
|
21
24
|
bundle_patches: Table;
|
|
22
25
|
bundles: Table;
|
|
23
26
|
bundle_events: Table;
|
|
27
|
+
bundle_installations: Table;
|
|
24
28
|
channels: Table;
|
|
25
29
|
api_keys: Table;
|
|
26
30
|
releases: Table;
|
|
@@ -60,7 +64,7 @@ const compare = (left: unknown, right: unknown): number => {
|
|
|
60
64
|
return left - right;
|
|
61
65
|
}
|
|
62
66
|
if (typeof left === "string" && typeof right === "string") {
|
|
63
|
-
return left
|
|
67
|
+
return compareInsightsText(left, right);
|
|
64
68
|
}
|
|
65
69
|
return 0;
|
|
66
70
|
};
|
|
@@ -71,7 +75,12 @@ const normalize = (value: unknown, insensitive: boolean): unknown =>
|
|
|
71
75
|
const readField = (row: Row, field: string): unknown =>
|
|
72
76
|
Object.entries(row).find(([key]) => key === field)?.[1];
|
|
73
77
|
|
|
74
|
-
const rowKey = (row: Row): string =>
|
|
78
|
+
const rowKey = (model: keyof Tables, row: Row): string =>
|
|
79
|
+
model === "bundle_installations" && "install_id" in row
|
|
80
|
+
? row.install_id
|
|
81
|
+
: "id" in row
|
|
82
|
+
? row.id
|
|
83
|
+
: row.scope_key;
|
|
75
84
|
|
|
76
85
|
const matchesCondition = (current: unknown, condition: unknown): boolean => {
|
|
77
86
|
if (!isRecord(condition)) return Object.is(current, condition);
|
|
@@ -190,7 +199,9 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
|
|
|
190
199
|
count: async (args?: QueryArgs): Promise<number> =>
|
|
191
200
|
tables[model].filter((row) => matchesWhere(row, args?.where)).length,
|
|
192
201
|
create: async ({ data }: CreateArgs): Promise<Row> => {
|
|
193
|
-
if (
|
|
202
|
+
if (
|
|
203
|
+
tables[model].some((row) => rowKey(model, row) === rowKey(model, data))
|
|
204
|
+
) {
|
|
194
205
|
throw new PrismaTestConstraintError("duplicate id");
|
|
195
206
|
}
|
|
196
207
|
if (
|
|
@@ -220,7 +231,7 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
|
|
|
220
231
|
matchesWhere(row, args?.where),
|
|
221
232
|
);
|
|
222
233
|
if (model === "bundles") {
|
|
223
|
-
const ids = new Set(selected.map(rowKey));
|
|
234
|
+
const ids = new Set(selected.map((row) => rowKey(model, row)));
|
|
224
235
|
if (
|
|
225
236
|
tables.releases.some(
|
|
226
237
|
(row) =>
|
|
@@ -293,7 +304,9 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
|
|
|
293
304
|
tables[model][index] = updated;
|
|
294
305
|
return structuredClone(updated);
|
|
295
306
|
}
|
|
296
|
-
if (
|
|
307
|
+
if (
|
|
308
|
+
tables[model].some((row) => rowKey(model, row) === rowKey(model, create))
|
|
309
|
+
) {
|
|
297
310
|
throw new PrismaTestConstraintError("duplicate id");
|
|
298
311
|
}
|
|
299
312
|
assertReferences(tables, model, create);
|
|
@@ -304,6 +317,7 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
|
|
|
304
317
|
|
|
305
318
|
const createClient = (tables: Tables, hooks: Hooks) => ({
|
|
306
319
|
bundle_events: createDelegate(tables, "bundle_events", hooks),
|
|
320
|
+
bundle_installations: createDelegate(tables, "bundle_installations", hooks),
|
|
307
321
|
bundle_patches: createDelegate(tables, "bundle_patches", hooks),
|
|
308
322
|
bundles: createDelegate(tables, "bundles", hooks),
|
|
309
323
|
channels: createDelegate(tables, "channels", hooks),
|
|
@@ -317,6 +331,7 @@ export const createPrismaTestHarness = () => {
|
|
|
317
331
|
bundle_patches: [],
|
|
318
332
|
bundles: [],
|
|
319
333
|
bundle_events: [],
|
|
334
|
+
bundle_installations: [],
|
|
320
335
|
channels: [],
|
|
321
336
|
api_keys: [],
|
|
322
337
|
releases: [],
|
|
@@ -347,6 +362,7 @@ export const createPrismaTestHarness = () => {
|
|
|
347
362
|
tables.bundle_patches = transactionTables.bundle_patches;
|
|
348
363
|
tables.bundles = transactionTables.bundles;
|
|
349
364
|
tables.bundle_events = transactionTables.bundle_events;
|
|
365
|
+
tables.bundle_installations = transactionTables.bundle_installations;
|
|
350
366
|
tables.channels = transactionTables.channels;
|
|
351
367
|
tables.api_keys = transactionTables.api_keys;
|
|
352
368
|
tables.releases = transactionTables.releases;
|
|
@@ -387,6 +403,7 @@ export const createPrismaTestHarness = () => {
|
|
|
387
403
|
tables.bundle_patches = [];
|
|
388
404
|
tables.bundles = [];
|
|
389
405
|
tables.bundle_events = [];
|
|
406
|
+
tables.bundle_installations = [];
|
|
390
407
|
tables.channels = [];
|
|
391
408
|
tables.api_keys = [];
|
|
392
409
|
tables.releases = [];
|
|
@@ -31,7 +31,7 @@ describe("createHotUpdater API keys", () => {
|
|
|
31
31
|
).toThrow("clientAccess.headerName must be a valid header name.");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it("protects only client OTA and
|
|
34
|
+
it("protects only client OTA and Insights write routes", async () => {
|
|
35
35
|
const database = createInMemoryDatabasePlugin();
|
|
36
36
|
await registerApiKey({
|
|
37
37
|
apiKeys: database.models.apiKeys,
|
|
@@ -59,7 +59,7 @@ describe("createHotUpdater API keys", () => {
|
|
|
59
59
|
expect(
|
|
60
60
|
(
|
|
61
61
|
await hotUpdater.handlers.admin(
|
|
62
|
-
withApiKey("https://example.com/
|
|
62
|
+
withApiKey("https://example.com/events"),
|
|
63
63
|
)
|
|
64
64
|
).status,
|
|
65
65
|
).toBe(200);
|
|
@@ -86,7 +86,7 @@ describe("createHotUpdater API keys", () => {
|
|
|
86
86
|
expect(revoked).not.toHaveProperty("hash");
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it("authenticates before parsing
|
|
89
|
+
it("authenticates before parsing Insights event bodies", async () => {
|
|
90
90
|
const database = createInMemoryDatabasePlugin();
|
|
91
91
|
await registerApiKey({
|
|
92
92
|
apiKeys: database.models.apiKeys,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertStorageOperations,
|
|
3
|
+
type InsightsModel,
|
|
3
4
|
type StoragePlugin,
|
|
4
5
|
} from "@hot-updater/plugin-core";
|
|
5
6
|
|
|
6
|
-
import { createAnalyticsProvider } from "./analytics/bounded/provider";
|
|
7
|
-
import type { AnalyticsProvider } from "./analytics/types";
|
|
8
7
|
import {
|
|
9
8
|
authenticateApiKey,
|
|
10
9
|
createApiKeyManagement,
|
|
@@ -20,17 +19,19 @@ import {
|
|
|
20
19
|
isDatabasePlugin,
|
|
21
20
|
} from "./db/types";
|
|
22
21
|
import { createHotUpdaterHandlers, type HotUpdaterHandlers } from "./handler";
|
|
22
|
+
import { createInsightsProvider } from "./insights/provider";
|
|
23
|
+
import type { InsightsProvider } from "./insights/types";
|
|
23
24
|
import { createStorageAccess } from "./storageAccess";
|
|
24
25
|
|
|
25
26
|
export type RuntimeHotUpdaterAPI = DatabaseAPI & {
|
|
26
27
|
readonly handlers: HotUpdaterHandlers;
|
|
27
28
|
readonly adapterName: string;
|
|
28
29
|
/**
|
|
29
|
-
* Built-in
|
|
30
|
-
* always mounted; React Native clients
|
|
31
|
-
*
|
|
30
|
+
* Built-in Insights provider. Client ingestion and admin query routes are
|
|
31
|
+
* always mounted; React Native clients report lifecycle events by default
|
|
32
|
+
* and can opt out with `HotUpdater.init({ insights: false })`.
|
|
32
33
|
*/
|
|
33
|
-
readonly
|
|
34
|
+
readonly insights: InsightsProvider;
|
|
34
35
|
/**
|
|
35
36
|
* In-process API key lifecycle operations for trusted server tooling.
|
|
36
37
|
* Creation returns the plaintext once; list and revoke expose only metadata.
|
|
@@ -44,7 +45,7 @@ export type HotUpdaterAPI = RuntimeHotUpdaterAPI;
|
|
|
44
45
|
export type ClientAccessPolicy =
|
|
45
46
|
| {
|
|
46
47
|
/**
|
|
47
|
-
* Leaves Release Catalog, artifact, and
|
|
48
|
+
* Leaves Release Catalog, artifact, and Insights ingestion routes
|
|
48
49
|
* publicly accessible without a client credential.
|
|
49
50
|
*/
|
|
50
51
|
readonly type: "public";
|
|
@@ -52,7 +53,7 @@ export type ClientAccessPolicy =
|
|
|
52
53
|
| {
|
|
53
54
|
/**
|
|
54
55
|
* Requires a key registered in `database.models.apiKeys` for
|
|
55
|
-
* Release Catalog, artifact, and
|
|
56
|
+
* Release Catalog, artifact, and Insights ingestion requests.
|
|
56
57
|
*/
|
|
57
58
|
readonly type: "api-key";
|
|
58
59
|
/**
|
|
@@ -168,16 +169,29 @@ export function createHotUpdaterCore(
|
|
|
168
169
|
readStorageText,
|
|
169
170
|
});
|
|
170
171
|
const clientAccess = normalizeClientAccess(options.clientAccess);
|
|
171
|
-
const
|
|
172
|
-
async
|
|
172
|
+
const insightsModel: InsightsModel = {
|
|
173
|
+
async record(input) {
|
|
173
174
|
await assertSchemaReady();
|
|
174
|
-
return plugin.models.
|
|
175
|
+
return plugin.models.insights.record(input);
|
|
175
176
|
},
|
|
176
|
-
async
|
|
177
|
+
async listEvents(input) {
|
|
177
178
|
await assertSchemaReady();
|
|
178
|
-
return plugin.models.
|
|
179
|
+
return plugin.models.insights.listEvents(input);
|
|
179
180
|
},
|
|
180
|
-
|
|
181
|
+
async findInstallations(input) {
|
|
182
|
+
await assertSchemaReady();
|
|
183
|
+
return plugin.models.insights.findInstallations(input);
|
|
184
|
+
},
|
|
185
|
+
async countInstallations(input) {
|
|
186
|
+
await assertSchemaReady();
|
|
187
|
+
return plugin.models.insights.countInstallations(input);
|
|
188
|
+
},
|
|
189
|
+
async countEvents(input) {
|
|
190
|
+
await assertSchemaReady();
|
|
191
|
+
return plugin.models.insights.countEvents(input);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const insights = createInsightsProvider(insightsModel);
|
|
181
195
|
const apiKeys = createApiKeyManagement({
|
|
182
196
|
apiKeys: plugin.models.apiKeys,
|
|
183
197
|
beforeOperation: assertSchemaReady,
|
|
@@ -185,7 +199,7 @@ export function createHotUpdaterCore(
|
|
|
185
199
|
|
|
186
200
|
const handlers = createHotUpdaterHandlers(
|
|
187
201
|
core.api,
|
|
188
|
-
|
|
202
|
+
insights,
|
|
189
203
|
clientAccess.type === "api-key"
|
|
190
204
|
? {
|
|
191
205
|
authenticate: (request) =>
|
|
@@ -204,7 +218,7 @@ export function createHotUpdaterCore(
|
|
|
204
218
|
const api: RuntimeHotUpdaterAPI = Object.assign(
|
|
205
219
|
{
|
|
206
220
|
adapterName: adapterCapabilities.adapterName ?? core.adapterName,
|
|
207
|
-
|
|
221
|
+
insights,
|
|
208
222
|
apiKeys,
|
|
209
223
|
handlers,
|
|
210
224
|
},
|
|
@@ -54,6 +54,9 @@ class BenchmarkMutationError extends Error {
|
|
|
54
54
|
|
|
55
55
|
const name = "release-catalog-benchmark";
|
|
56
56
|
const adapter = createDatabasePluginAdapter(name, {
|
|
57
|
+
async recordInsights() {
|
|
58
|
+
throw new BenchmarkMutationError();
|
|
59
|
+
},
|
|
57
60
|
async count() {
|
|
58
61
|
return 0;
|
|
59
62
|
},
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { DatabaseSync, type SqliteValue } from "node:sqlite";
|
|
2
2
|
|
|
3
3
|
import { PGlite } from "@electric-sql/pglite";
|
|
4
|
+
import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
|
|
4
5
|
import { Kysely, SqliteDialect } from "kysely";
|
|
5
6
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
6
7
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
8
|
|
|
9
|
+
import { createBundleEventRowFixture } from "../../../test-utils/src/databaseTestFixtures";
|
|
8
10
|
import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
|
|
11
|
+
import { kyselyAdapter } from "../adapters/kysely";
|
|
9
12
|
import { createDatabasePluginCore } from "./databasePluginCore";
|
|
10
13
|
import { createKyselyMigrator } from "./fixedMigrator";
|
|
11
14
|
import { createSchemaReadinessChecker } from "./schemaReadiness";
|
|
@@ -54,6 +57,68 @@ describe("Kysely migrator", () => {
|
|
|
54
57
|
await Promise.all(databases.splice(0).map((db) => db.close()));
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
it("creates the final Insights access paths in the initial schema", async () => {
|
|
61
|
+
const database = new PGlite();
|
|
62
|
+
databases.push(database);
|
|
63
|
+
const kysely = new Kysely<SettingsDatabase>({
|
|
64
|
+
dialect: new PGliteDialect(database),
|
|
65
|
+
});
|
|
66
|
+
kyselyInstances.push(kysely);
|
|
67
|
+
const migrator = createKyselyMigrator({
|
|
68
|
+
db: kysely,
|
|
69
|
+
provider: "postgresql",
|
|
70
|
+
});
|
|
71
|
+
await expect(migrator.next()).resolves.toEqual({ version: "1.0.0" });
|
|
72
|
+
await expect(migrator.previous()).resolves.toBeUndefined();
|
|
73
|
+
const migration = await migrator.migrateToLatest();
|
|
74
|
+
await migration.execute();
|
|
75
|
+
await expect(migrator.getVersion()).resolves.toBe("1.0.0");
|
|
76
|
+
const plugin = kyselyAdapter({ db: kysely, provider: "postgresql" });
|
|
77
|
+
const event = createBundleEventRowFixture("706", 100);
|
|
78
|
+
const input = { event, installation: toInsightsInstallationRow(event) };
|
|
79
|
+
await plugin.models.insights.record(input);
|
|
80
|
+
|
|
81
|
+
const repeated = await migrator.migrateToLatest();
|
|
82
|
+
expect(repeated.operations).toEqual([]);
|
|
83
|
+
expect(repeated.getSQL?.()).toBe("");
|
|
84
|
+
await repeated.execute();
|
|
85
|
+
await expect(migrator.next()).resolves.toBeUndefined();
|
|
86
|
+
await expect(
|
|
87
|
+
plugin.models.insights.findInstallations({ installId: event.install_id }),
|
|
88
|
+
).resolves.toEqual([input.installation]);
|
|
89
|
+
await expect(
|
|
90
|
+
plugin.models.insights.countEvents({
|
|
91
|
+
filter: {
|
|
92
|
+
platform: "ios",
|
|
93
|
+
channel: "production",
|
|
94
|
+
type: "UPDATE_APPLIED",
|
|
95
|
+
toBundleId: event.to_bundle_id,
|
|
96
|
+
},
|
|
97
|
+
sinceMs: 0,
|
|
98
|
+
beforeReceivedAtMs: 101,
|
|
99
|
+
}),
|
|
100
|
+
).resolves.toBe(1);
|
|
101
|
+
const indexes = await database.query<{ indexname: string }>(
|
|
102
|
+
"select indexname from pg_indexes where tablename in ('bundle_events', 'bundle_installations')",
|
|
103
|
+
);
|
|
104
|
+
expect(indexes.rows.map(({ indexname }) => indexname)).toEqual(
|
|
105
|
+
expect.arrayContaining([
|
|
106
|
+
"bundle_events_from_bundle_idx",
|
|
107
|
+
"bundle_events_to_bundle_idx",
|
|
108
|
+
"bundle_installations_scope_idx",
|
|
109
|
+
"bundle_installations_bundle_idx",
|
|
110
|
+
]),
|
|
111
|
+
);
|
|
112
|
+
await database.exec("set enable_seqscan = off");
|
|
113
|
+
const plan = await database.query<{ "QUERY PLAN": string }>(
|
|
114
|
+
"explain select * from bundle_events where type = 'UPDATE_APPLIED' and platform = 'ios' and channel = 'production' and to_bundle_id = $1 and received_at_ms < 101 order by received_at_ms desc, id desc limit 10",
|
|
115
|
+
[event.to_bundle_id],
|
|
116
|
+
);
|
|
117
|
+
expect(plan.rows.map((row) => row["QUERY PLAN"]).join("\n")).toContain(
|
|
118
|
+
"bundle_events_to_bundle_idx",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
57
122
|
it("creates schema 1.0.0 from an empty database", async () => {
|
|
58
123
|
const database = new PGlite();
|
|
59
124
|
databases.push(database);
|