@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.
- 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 +10 -3
- package/dist/adapters/drizzle.d.cts +0 -1
- package/dist/adapters/drizzle.d.mts +0 -1
- package/dist/adapters/drizzle.mjs +11 -3
- 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 +24 -9
- package/dist/createHotUpdaterCore.d.cts +1 -1
- package/dist/createHotUpdaterCore.d.mts +1 -1
- package/dist/createHotUpdaterCore.mjs +23 -9
- 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.d.cts +1 -2
- package/dist/handler.d.mts +1 -2
- 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 +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +1 -1
- package/dist/insights/domain.d.cts +37 -69
- package/dist/insights/domain.d.mts +37 -69
- package/dist/insights/errors.cjs +1 -8
- package/dist/insights/errors.mjs +2 -8
- package/dist/insights/eventInput.cjs +49 -5
- package/dist/insights/eventInput.mjs +48 -6
- 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 +78 -43
- package/dist/insights/queryInput.mjs +76 -40
- package/dist/insights/routes.cjs +20 -25
- package/dist/insights/routes.mjs +22 -27
- package/dist/insights/types.d.cts +27 -12
- package/dist/insights/types.d.mts +27 -12
- 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 +21 -3
- 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 +1 -1
- package/src/createHotUpdaterCore.ts +21 -7
- 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 +8 -0
- package/src/db/types.ts +10 -4
- package/src/handler-standalone.integration.spec.ts +5 -7
- package/src/index.ts +2 -2
- package/src/insights/domain.ts +39 -75
- package/src/insights/errors.ts +0 -7
- package/src/insights/eventInput.ts +60 -3
- package/src/insights/insights.integration.spec.ts +254 -17
- package/src/insights/provider.spec.ts +385 -0
- package/src/insights/provider.ts +591 -0
- package/src/insights/queryInput.ts +112 -73
- package/src/insights/routes.ts +37 -56
- package/src/insights/types.ts +47 -36
- 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/insights/bounded/activeOverview.cjs +0 -87
- package/dist/insights/bounded/activeOverview.mjs +0 -85
- package/dist/insights/bounded/installationSearch.cjs +0 -42
- package/dist/insights/bounded/installationSearch.mjs +0 -42
- package/dist/insights/bounded/persistence.cjs +0 -39
- package/dist/insights/bounded/persistence.mjs +0 -38
- package/dist/insights/bounded/provider.cjs +0 -170
- package/dist/insights/bounded/provider.d.cts +0 -7
- package/dist/insights/bounded/provider.d.mts +0 -7
- package/dist/insights/bounded/provider.mjs +0 -170
- package/dist/insights/bounded/scan.cjs +0 -144
- package/dist/insights/bounded/scan.mjs +0 -138
- package/dist/insights/persistence.d.cts +0 -6
- package/dist/insights/persistence.d.mts +0 -6
- package/src/insights/bounded/activeOverview.spec.ts +0 -189
- package/src/insights/bounded/activeOverview.ts +0 -148
- package/src/insights/bounded/installationSearch.spec.ts +0 -130
- package/src/insights/bounded/installationSearch.ts +0 -69
- package/src/insights/bounded/persistence.ts +0 -43
- package/src/insights/bounded/provider.spec.ts +0 -219
- package/src/insights/bounded/provider.ts +0 -229
- package/src/insights/bounded/scan.ts +0 -235
- package/src/insights/persistence.ts +0 -10
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DatabasePluginInputError,
|
|
3
|
+
type InsightsRecordInput,
|
|
4
|
+
} from "@hot-updater/plugin-core";
|
|
2
5
|
import type {
|
|
3
6
|
DatabasePluginImplementation,
|
|
4
7
|
TransactionDatabasePluginImplementation,
|
|
5
8
|
} from "@hot-updater/plugin-core/internal";
|
|
6
|
-
import { asc, desc, eq, sql, type SQLWrapper } from "drizzle-orm";
|
|
9
|
+
import { and, asc, desc, eq, lt, or, sql, type SQLWrapper } from "drizzle-orm";
|
|
7
10
|
|
|
8
11
|
import {
|
|
9
12
|
isChannelDeleteReferencedError,
|
|
@@ -78,9 +81,98 @@ const executeInsert = async (
|
|
|
78
81
|
await ignored.execute();
|
|
79
82
|
};
|
|
80
83
|
|
|
84
|
+
export const recordDrizzleInsights = (
|
|
85
|
+
db: DrizzleDB,
|
|
86
|
+
provider: DrizzleProvider,
|
|
87
|
+
input: InsightsRecordInput,
|
|
88
|
+
): void | Promise<void> => {
|
|
89
|
+
if (db.resultKind !== "sync")
|
|
90
|
+
return recordAsyncDrizzleInsights(db, provider, input);
|
|
91
|
+
const events = getDrizzleTable(db, "bundle_events");
|
|
92
|
+
const installations = getDrizzleTable(db, "bundle_installations");
|
|
93
|
+
const eventInsert = db
|
|
94
|
+
.insert(events)
|
|
95
|
+
.values(input.event)
|
|
96
|
+
.onConflictDoNothing?.()
|
|
97
|
+
.returning?.({ id: getDrizzleColumn(events, "id") });
|
|
98
|
+
const installationInsert = db
|
|
99
|
+
.insert(installations)
|
|
100
|
+
.values(input.installation)
|
|
101
|
+
.onConflictDoNothing?.();
|
|
102
|
+
const installationUpdate = db
|
|
103
|
+
.update(installations)
|
|
104
|
+
.set(input.installation)
|
|
105
|
+
.where(newerInstallationWhere(installations, input.installation));
|
|
106
|
+
if (
|
|
107
|
+
eventInsert?.all === undefined ||
|
|
108
|
+
installationInsert?.run === undefined ||
|
|
109
|
+
installationUpdate.run === undefined
|
|
110
|
+
)
|
|
111
|
+
throw new DrizzleAdapterInvariantError();
|
|
112
|
+
if (eventInsert.all().length === 0) return;
|
|
113
|
+
installationInsert.run();
|
|
114
|
+
installationUpdate.run();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const newerInstallationWhere = (
|
|
118
|
+
installations: DrizzleTable,
|
|
119
|
+
installation: InsightsRecordInput["installation"],
|
|
120
|
+
) => {
|
|
121
|
+
const receivedAt = getDrizzleColumn(installations, "received_at_ms");
|
|
122
|
+
return and(
|
|
123
|
+
eq(getDrizzleColumn(installations, "install_id"), installation.install_id),
|
|
124
|
+
or(
|
|
125
|
+
lt(receivedAt, installation.received_at_ms),
|
|
126
|
+
and(
|
|
127
|
+
eq(receivedAt, installation.received_at_ms),
|
|
128
|
+
lt(getDrizzleColumn(installations, "id"), installation.id),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const recordAsyncDrizzleInsights = async (
|
|
135
|
+
db: DrizzleDB,
|
|
136
|
+
provider: DrizzleProvider,
|
|
137
|
+
{ event, installation }: InsightsRecordInput,
|
|
138
|
+
): Promise<void> => {
|
|
139
|
+
const events = getDrizzleTable(db, "bundle_events");
|
|
140
|
+
const installations = getDrizzleTable(db, "bundle_installations");
|
|
141
|
+
if (provider === "mysql") {
|
|
142
|
+
const existing = await db.query.bundle_events.findFirst({
|
|
143
|
+
where: eq(getDrizzleColumn(events, "id"), event.id),
|
|
144
|
+
});
|
|
145
|
+
if (existing !== undefined) return;
|
|
146
|
+
await db.insert(events).values(event).execute();
|
|
147
|
+
const insertInstallation = db
|
|
148
|
+
.insert(installations)
|
|
149
|
+
.values(installation)
|
|
150
|
+
.onDuplicateKeyUpdate?.({ set: { install_id: installation.install_id } });
|
|
151
|
+
if (insertInstallation === undefined)
|
|
152
|
+
throw new DrizzleAdapterInvariantError();
|
|
153
|
+
await insertInstallation.execute();
|
|
154
|
+
} else {
|
|
155
|
+
const insert = db.insert(events).values(event).onConflictDoNothing?.();
|
|
156
|
+
const returning = insert?.returning?.({
|
|
157
|
+
id: getDrizzleColumn(events, "id"),
|
|
158
|
+
});
|
|
159
|
+
if (returning === undefined) throw new DrizzleAdapterInvariantError();
|
|
160
|
+
const rows = await returning.execute();
|
|
161
|
+
if (!Array.isArray(rows)) throw new DrizzleAdapterInvariantError();
|
|
162
|
+
if (rows.length === 0) return;
|
|
163
|
+
await executeInsert(db, provider, installations, installation, "ignore");
|
|
164
|
+
}
|
|
165
|
+
await db
|
|
166
|
+
.update(installations)
|
|
167
|
+
.set(installation)
|
|
168
|
+
.where(newerInstallationWhere(installations, installation))
|
|
169
|
+
.execute();
|
|
170
|
+
};
|
|
171
|
+
|
|
81
172
|
const toOrderBy = (
|
|
82
173
|
table: DrizzleTable,
|
|
83
174
|
input: {
|
|
175
|
+
model?: string;
|
|
84
176
|
orderBy?: readonly {
|
|
85
177
|
field: string;
|
|
86
178
|
direction: "asc" | "desc";
|
|
@@ -91,6 +183,12 @@ const toOrderBy = (
|
|
|
91
183
|
const clauses = input.orderBy;
|
|
92
184
|
return clauses?.flatMap((clause) => {
|
|
93
185
|
const column = getDrizzleColumn(table, clause.field);
|
|
186
|
+
if (
|
|
187
|
+
input.model === "bundle_events" ||
|
|
188
|
+
input.model === "bundle_installations"
|
|
189
|
+
) {
|
|
190
|
+
return [clause.direction === "asc" ? asc(column) : desc(column)];
|
|
191
|
+
}
|
|
94
192
|
const nulls =
|
|
95
193
|
clause.nulls ?? (clause.direction === "asc" ? "last" : "first");
|
|
96
194
|
return [
|
|
@@ -110,6 +208,7 @@ export const createDrizzleCrud = (
|
|
|
110
208
|
const bundles = getDrizzleTable(db, "bundles");
|
|
111
209
|
const patches = getDrizzleTable(db, "bundle_patches");
|
|
112
210
|
const events = getDrizzleTable(db, "bundle_events");
|
|
211
|
+
const installations = getDrizzleTable(db, "bundle_installations");
|
|
113
212
|
const releases = getDrizzleTable(db, "releases");
|
|
114
213
|
const releaseCatalogs = getDrizzleTable(db, "release_catalogs");
|
|
115
214
|
const channels = getDrizzleTable(db, "channels");
|
|
@@ -169,6 +268,15 @@ export const createDrizzleCrud = (
|
|
|
169
268
|
case "bundle_events":
|
|
170
269
|
await executeInsert(db, provider, events, input.data, undefined);
|
|
171
270
|
return input.data;
|
|
271
|
+
case "bundle_installations":
|
|
272
|
+
await executeInsert(
|
|
273
|
+
db,
|
|
274
|
+
provider,
|
|
275
|
+
installations,
|
|
276
|
+
input.data,
|
|
277
|
+
input.onConflict,
|
|
278
|
+
);
|
|
279
|
+
return input.data;
|
|
172
280
|
case "releases":
|
|
173
281
|
await executeInsert(
|
|
174
282
|
db,
|
|
@@ -208,6 +316,27 @@ export const createDrizzleCrud = (
|
|
|
208
316
|
}
|
|
209
317
|
},
|
|
210
318
|
async update(input) {
|
|
319
|
+
if (input.model === "bundle_installations") {
|
|
320
|
+
const installId = input.where.find(
|
|
321
|
+
(item) =>
|
|
322
|
+
item.field === "install_id" &&
|
|
323
|
+
(item.operator === undefined || item.operator === "eq") &&
|
|
324
|
+
typeof item.value === "string",
|
|
325
|
+
)?.value;
|
|
326
|
+
if (typeof installId !== "string") {
|
|
327
|
+
throw new DrizzleAdapterInvariantError();
|
|
328
|
+
}
|
|
329
|
+
const where = buildDrizzleWhere(provider, installations, input.where);
|
|
330
|
+
if (where === undefined) throw new DrizzleAdapterInvariantError();
|
|
331
|
+
await db.update(installations).set(input.update).where(where).execute();
|
|
332
|
+
const row = await db.query.bundle_installations.findFirst({
|
|
333
|
+
where: eq(getDrizzleColumn(installations, "install_id"), installId),
|
|
334
|
+
});
|
|
335
|
+
return row?.id === input.update.id &&
|
|
336
|
+
row.received_at_ms === input.update.received_at_ms
|
|
337
|
+
? row
|
|
338
|
+
: null;
|
|
339
|
+
}
|
|
211
340
|
const selector = input.where[0];
|
|
212
341
|
if (selector === undefined || typeof selector.value !== "string") {
|
|
213
342
|
throw new DrizzleAdapterInvariantError();
|
|
@@ -296,6 +425,11 @@ export const createDrizzleCrud = (
|
|
|
296
425
|
throw new DatabasePluginInputError("invalid-operation");
|
|
297
426
|
}
|
|
298
427
|
switch (input.model) {
|
|
428
|
+
case "bundle_events":
|
|
429
|
+
return db.$count(
|
|
430
|
+
events,
|
|
431
|
+
buildDrizzleWhere(provider, events, input.where),
|
|
432
|
+
);
|
|
299
433
|
case "bundles":
|
|
300
434
|
return db.$count(
|
|
301
435
|
bundles,
|
|
@@ -311,6 +445,11 @@ export const createDrizzleCrud = (
|
|
|
311
445
|
releases,
|
|
312
446
|
buildDrizzleWhere(provider, releases, input.where),
|
|
313
447
|
);
|
|
448
|
+
case "bundle_installations":
|
|
449
|
+
return db.$count(
|
|
450
|
+
installations,
|
|
451
|
+
buildDrizzleWhere(provider, installations, input.where),
|
|
452
|
+
);
|
|
314
453
|
}
|
|
315
454
|
},
|
|
316
455
|
async findOne(input) {
|
|
@@ -351,6 +490,12 @@ export const createDrizzleCrud = (
|
|
|
351
490
|
});
|
|
352
491
|
return row === undefined ? null : fromStoredReleaseCatalogRow(row);
|
|
353
492
|
}
|
|
493
|
+
case "bundle_installations":
|
|
494
|
+
return (
|
|
495
|
+
(await db.query.bundle_installations.findFirst({
|
|
496
|
+
where: buildDrizzleWhere(provider, installations, input.where),
|
|
497
|
+
})) ?? null
|
|
498
|
+
);
|
|
354
499
|
}
|
|
355
500
|
},
|
|
356
501
|
async findMany(input) {
|
|
@@ -374,6 +519,13 @@ export const createDrizzleCrud = (
|
|
|
374
519
|
limit: input.limit,
|
|
375
520
|
offset: input.offset,
|
|
376
521
|
});
|
|
522
|
+
case "bundle_installations":
|
|
523
|
+
return db.query.bundle_installations.findMany({
|
|
524
|
+
where: buildDrizzleWhere(provider, installations, input.where),
|
|
525
|
+
orderBy: toOrderBy(installations, input),
|
|
526
|
+
limit: input.limit,
|
|
527
|
+
offset: input.offset,
|
|
528
|
+
});
|
|
377
529
|
case "api_keys":
|
|
378
530
|
return db.query.api_keys.findMany({
|
|
379
531
|
where: buildDrizzleWhere(provider, apiKeys, input.where),
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
BundlePatchRow,
|
|
4
4
|
ChannelRow,
|
|
5
5
|
ApiKeyRow,
|
|
6
|
+
InsightsInstallationRow,
|
|
6
7
|
ReleaseCatalogRow,
|
|
7
8
|
} from "@hot-updater/plugin-core";
|
|
8
9
|
|
|
@@ -13,10 +14,14 @@ export type DrizzleTable = Record<string, unknown>;
|
|
|
13
14
|
|
|
14
15
|
type DrizzleMutation = {
|
|
15
16
|
readonly execute: () => Promise<unknown>;
|
|
17
|
+
readonly all?: () => unknown[];
|
|
18
|
+
readonly run?: () => unknown;
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
export type DrizzleInsertMutation = DrizzleMutation & {
|
|
19
|
-
readonly
|
|
22
|
+
readonly onDuplicateKeyUpdate?: (config: { set: object }) => DrizzleMutation;
|
|
23
|
+
readonly onConflictDoNothing?: () => DrizzleInsertMutation;
|
|
24
|
+
readonly returning?: (fields: Record<string, unknown>) => DrizzleMutation;
|
|
20
25
|
};
|
|
21
26
|
|
|
22
27
|
type DrizzleInsertBuilder = {
|
|
@@ -30,6 +35,7 @@ type DrizzleQuery<TRow> = {
|
|
|
30
35
|
};
|
|
31
36
|
|
|
32
37
|
export type DrizzleDB = {
|
|
38
|
+
readonly resultKind?: "sync" | "async";
|
|
33
39
|
readonly _: { readonly fullSchema: Record<string, DrizzleTable> };
|
|
34
40
|
readonly $count: (table: DrizzleTable, where?: unknown) => Promise<number>;
|
|
35
41
|
readonly delete: (table: DrizzleTable) => {
|
|
@@ -41,6 +47,7 @@ export type DrizzleDB = {
|
|
|
41
47
|
readonly channels: DrizzleQuery<ChannelRow>;
|
|
42
48
|
readonly bundle_patches: DrizzleQuery<BundlePatchRow>;
|
|
43
49
|
readonly bundle_events: DrizzleQuery<BundleEventRow>;
|
|
50
|
+
readonly bundle_installations: DrizzleQuery<InsightsInstallationRow>;
|
|
44
51
|
readonly api_keys: DrizzleQuery<ApiKeyRow>;
|
|
45
52
|
readonly releases: DrizzleQuery<StoredReleaseRow>;
|
|
46
53
|
readonly release_catalogs: DrizzleQuery<ReleaseCatalogRow>;
|
|
@@ -51,7 +58,7 @@ export type DrizzleDB = {
|
|
|
51
58
|
};
|
|
52
59
|
};
|
|
53
60
|
readonly transaction?: <TResult>(
|
|
54
|
-
operation: (transaction: DrizzleDB) => Promise<TResult>,
|
|
61
|
+
operation: (transaction: DrizzleDB) => TResult | Promise<TResult>,
|
|
55
62
|
) => Promise<TResult>;
|
|
56
63
|
};
|
|
57
64
|
|
|
@@ -82,6 +89,7 @@ const isDrizzleDB = (value: unknown): value is DrizzleDB => {
|
|
|
82
89
|
if (
|
|
83
90
|
!isRecord(query) ||
|
|
84
91
|
!isDrizzleQuery(query["bundle_events"]) ||
|
|
92
|
+
!isDrizzleQuery(query["bundle_installations"]) ||
|
|
85
93
|
!isDrizzleQuery(query["bundle_patches"]) ||
|
|
86
94
|
!isDrizzleQuery(query["bundles"]) ||
|
|
87
95
|
!isDrizzleQuery(query["channels"]) ||
|
|
@@ -193,6 +201,12 @@ export const createLazyDB = (config: DrizzleConfig): DrizzleDB => {
|
|
|
193
201
|
findMany: async (args) =>
|
|
194
202
|
(await getDB()).query.bundle_events.findMany(args),
|
|
195
203
|
},
|
|
204
|
+
bundle_installations: {
|
|
205
|
+
findFirst: async (args) =>
|
|
206
|
+
(await getDB()).query.bundle_installations.findFirst(args),
|
|
207
|
+
findMany: async (args) =>
|
|
208
|
+
(await getDB()).query.bundle_installations.findMany(args),
|
|
209
|
+
},
|
|
196
210
|
bundle_patches: {
|
|
197
211
|
findFirst: async (args) =>
|
|
198
212
|
(await getDB()).query.bundle_patches.findFirst(args),
|
|
@@ -241,7 +255,7 @@ export const createLazyDB = (config: DrizzleConfig): DrizzleDB => {
|
|
|
241
255
|
...(config.transaction === true
|
|
242
256
|
? {
|
|
243
257
|
transaction: async <TResult>(
|
|
244
|
-
operation: (transaction: DrizzleDB) => Promise<TResult>,
|
|
258
|
+
operation: (transaction: DrizzleDB) => TResult | Promise<TResult>,
|
|
245
259
|
): Promise<TResult> => {
|
|
246
260
|
const db = await getDB();
|
|
247
261
|
if (db.transaction === undefined) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const isRetryableConflict = (error: unknown): boolean => {
|
|
2
|
+
if (typeof error !== "object" || error === null) return false;
|
|
3
|
+
if (
|
|
4
|
+
"code" in error &&
|
|
5
|
+
[
|
|
6
|
+
"ER_DUP_ENTRY",
|
|
7
|
+
"ER_LOCK_DEADLOCK",
|
|
8
|
+
"ER_LOCK_WAIT_TIMEOUT",
|
|
9
|
+
"40001",
|
|
10
|
+
"40P01",
|
|
11
|
+
].includes(String(error.code))
|
|
12
|
+
)
|
|
13
|
+
return true;
|
|
14
|
+
return (
|
|
15
|
+
"cause" in error &&
|
|
16
|
+
error.cause !== error &&
|
|
17
|
+
isRetryableConflict(error.cause)
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const runInsightsTransaction = async (
|
|
22
|
+
operation: () => Promise<void>,
|
|
23
|
+
): Promise<void> => {
|
|
24
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
25
|
+
try {
|
|
26
|
+
await operation();
|
|
27
|
+
return;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (attempt >= 2 || !isRetryableConflict(error)) throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
|
|
2
3
|
import { Kysely } from "kysely";
|
|
3
4
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
4
5
|
import { describe, expect, it } from "vitest";
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
8
|
createBundlePatchRowFixture,
|
|
9
|
+
createBundleEventRowFixture,
|
|
8
10
|
createBundleRowFixture,
|
|
9
11
|
createChannelRowFixture,
|
|
10
12
|
} from "../../../test-utils/src/databaseTestFixtures";
|
|
@@ -54,6 +56,33 @@ setupDatabasePluginTestSuite({
|
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
describe("kyselyAdapter SQLite JSON storage", () => {
|
|
59
|
+
it("rolls back the event when the installation write fails and permits retry", async () => {
|
|
60
|
+
const db = new PGlite();
|
|
61
|
+
const kysely = new Kysely<object>({ dialect: new PGliteDialect(db) });
|
|
62
|
+
await db.exec(DATABASE_PLUGIN_TEST_SCHEMA_SQL);
|
|
63
|
+
await db.exec(
|
|
64
|
+
"alter table bundle_installations add constraint reject_snapshot check (install_id <> 'install-703')",
|
|
65
|
+
);
|
|
66
|
+
const plugin = kyselyAdapter({ db: kysely, provider: "postgresql" });
|
|
67
|
+
const event = createBundleEventRowFixture("703", 100);
|
|
68
|
+
const input = { event, installation: toInsightsInstallationRow(event) };
|
|
69
|
+
try {
|
|
70
|
+
await expect(plugin.models.insights.record(input)).rejects.toThrow();
|
|
71
|
+
expect((await db.query("select id from bundle_events")).rows).toEqual([]);
|
|
72
|
+
await db.exec(
|
|
73
|
+
"alter table bundle_installations drop constraint reject_snapshot",
|
|
74
|
+
);
|
|
75
|
+
await plugin.models.insights.record(input);
|
|
76
|
+
await expect(
|
|
77
|
+
plugin.models.insights.findInstallations({
|
|
78
|
+
installId: event.install_id,
|
|
79
|
+
}),
|
|
80
|
+
).resolves.toEqual([input.installation]);
|
|
81
|
+
} finally {
|
|
82
|
+
await kysely.destroy();
|
|
83
|
+
await db.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
57
86
|
it("round-trips JSON values through text columns", async () => {
|
|
58
87
|
const sqliteClient = new PGlite();
|
|
59
88
|
const sqliteDatabase = new Kysely<object>({
|
package/src/adapters/kysely.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type {
|
|
|
11
11
|
ORMSQLProvider,
|
|
12
12
|
RelationMode,
|
|
13
13
|
} from "../db/types";
|
|
14
|
-
import {
|
|
14
|
+
import { runInsightsTransaction } from "./insightsTransaction";
|
|
15
|
+
import { createKyselyCrud, recordKyselyInsights } from "./kyselyCrud";
|
|
15
16
|
|
|
16
17
|
type KyselySQLProvider = Exclude<ORMSQLProvider, "mssql">;
|
|
17
18
|
|
|
@@ -31,6 +32,14 @@ const createImplementation = <TDatabase extends object>(
|
|
|
31
32
|
const crud = createKyselyCrud(db, config.provider, relationMode);
|
|
32
33
|
return {
|
|
33
34
|
...crud,
|
|
35
|
+
recordInsights: (input) =>
|
|
36
|
+
runInsightsTransaction(() =>
|
|
37
|
+
db
|
|
38
|
+
.transaction()
|
|
39
|
+
.execute((transaction) =>
|
|
40
|
+
recordKyselyInsights(transaction, config.provider, input),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
34
43
|
deleteChannel: (input) =>
|
|
35
44
|
db
|
|
36
45
|
.transaction()
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type BundlePatchRow,
|
|
5
5
|
type ChannelRow,
|
|
6
6
|
type ApiKeyRow,
|
|
7
|
+
type InsightsInstallationRow,
|
|
8
|
+
type InsightsRecordInput,
|
|
7
9
|
type ReleaseCatalogRow,
|
|
8
10
|
} from "@hot-updater/plugin-core";
|
|
9
11
|
import type {
|
|
@@ -54,6 +56,7 @@ const whereClause = (
|
|
|
54
56
|
const orderClause = (
|
|
55
57
|
input:
|
|
56
58
|
| {
|
|
59
|
+
readonly model?: string;
|
|
57
60
|
readonly orderBy?: readonly {
|
|
58
61
|
readonly direction: "asc" | "desc";
|
|
59
62
|
readonly field: string;
|
|
@@ -67,6 +70,14 @@ const orderClause = (
|
|
|
67
70
|
return sql` order by ${sql.join(
|
|
68
71
|
clauses.map((clause) => {
|
|
69
72
|
const field = sql.ref(clause.field);
|
|
73
|
+
if (
|
|
74
|
+
input?.model === "bundle_events" ||
|
|
75
|
+
input?.model === "bundle_installations"
|
|
76
|
+
) {
|
|
77
|
+
return clause.direction === "asc"
|
|
78
|
+
? sql`${field} asc`
|
|
79
|
+
: sql`${field} desc`;
|
|
80
|
+
}
|
|
70
81
|
const nulls =
|
|
71
82
|
clause.nulls ?? (clause.direction === "asc" ? "last" : "first");
|
|
72
83
|
const nullOrder =
|
|
@@ -110,6 +121,46 @@ const insertRow = async (
|
|
|
110
121
|
return Number(result.numAffectedRows ?? 0) > 0;
|
|
111
122
|
};
|
|
112
123
|
|
|
124
|
+
export const recordKyselyInsights = async (
|
|
125
|
+
executor: QueryExecutorProvider,
|
|
126
|
+
provider: Exclude<ORMSQLProvider, "mssql">,
|
|
127
|
+
{ event, installation }: InsightsRecordInput,
|
|
128
|
+
): Promise<void> => {
|
|
129
|
+
const insert = (table: string, row: object) => {
|
|
130
|
+
const entries = Object.entries(row);
|
|
131
|
+
return sql`insert into ${sql.table(table)} (${sql.join(entries.map(([field]) => sql.ref(field)))}) values (${sql.join(entries.map(([, value]) => value))})`;
|
|
132
|
+
};
|
|
133
|
+
if (provider === "mysql") {
|
|
134
|
+
const existing = await sql<{
|
|
135
|
+
id: string;
|
|
136
|
+
}>`select id from bundle_events where id = ${event.id} for update`.execute(
|
|
137
|
+
executor,
|
|
138
|
+
);
|
|
139
|
+
if (existing.rows.length > 0) return;
|
|
140
|
+
await insert("bundle_events", event).execute(executor);
|
|
141
|
+
await sql`${insert("bundle_installations", installation)} on duplicate key update install_id = ${installation.install_id}`.execute(
|
|
142
|
+
executor,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
const result =
|
|
146
|
+
await sql`${insert("bundle_events", event)} on conflict (id) do nothing returning id`.execute(
|
|
147
|
+
executor,
|
|
148
|
+
);
|
|
149
|
+
if (result.rows.length === 0) return;
|
|
150
|
+
await sql`${insert("bundle_installations", installation)} on conflict (install_id) do nothing`.execute(
|
|
151
|
+
executor,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const assignments = Object.entries(installation).map(
|
|
155
|
+
([field, value]) => sql`${sql.ref(field)} = ${value}`,
|
|
156
|
+
);
|
|
157
|
+
await sql`update ${sql.table("bundle_installations")} set ${sql.join(assignments)}
|
|
158
|
+
where ${sql.ref("install_id")} = ${installation.install_id}
|
|
159
|
+
and (${sql.ref("received_at_ms")} < ${installation.received_at_ms}
|
|
160
|
+
or (${sql.ref("received_at_ms")} = ${installation.received_at_ms}
|
|
161
|
+
and ${sql.ref("id")} < ${installation.id}))`.execute(executor);
|
|
162
|
+
};
|
|
163
|
+
|
|
113
164
|
const updateBundle = async (
|
|
114
165
|
executor: QueryExecutorProvider,
|
|
115
166
|
id: string,
|
|
@@ -298,6 +349,15 @@ export const createKyselyCrud = (
|
|
|
298
349
|
case "bundle_events":
|
|
299
350
|
await insertRow(executor, "bundle_events", input.data, provider);
|
|
300
351
|
return input.data;
|
|
352
|
+
case "bundle_installations":
|
|
353
|
+
await insertRow(
|
|
354
|
+
executor,
|
|
355
|
+
"bundle_installations",
|
|
356
|
+
input.data,
|
|
357
|
+
provider,
|
|
358
|
+
input.onConflict,
|
|
359
|
+
);
|
|
360
|
+
return input.data;
|
|
301
361
|
case "releases":
|
|
302
362
|
await insertRow(
|
|
303
363
|
executor,
|
|
@@ -330,6 +390,38 @@ export const createKyselyCrud = (
|
|
|
330
390
|
}
|
|
331
391
|
},
|
|
332
392
|
async update(input) {
|
|
393
|
+
if (input.model === "bundle_installations") {
|
|
394
|
+
const installId = input.where.find(
|
|
395
|
+
(item) =>
|
|
396
|
+
item.field === "install_id" &&
|
|
397
|
+
(item.operator === undefined || item.operator === "eq") &&
|
|
398
|
+
typeof item.value === "string",
|
|
399
|
+
)?.value;
|
|
400
|
+
if (typeof installId !== "string") {
|
|
401
|
+
throw new KyselyAdapterInvariantError(
|
|
402
|
+
"bundle_installations.update.selector",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
const assignments = Object.entries(input.update)
|
|
406
|
+
.filter(([, value]) => value !== undefined)
|
|
407
|
+
.map(([field, value]) => sql`${sql.ref(field)} = ${value}`);
|
|
408
|
+
const where = buildKyselyWhere(provider, input.where);
|
|
409
|
+
let updated = false;
|
|
410
|
+
if (assignments.length > 0 && where !== undefined) {
|
|
411
|
+
const result = await sql`update ${sql.table(
|
|
412
|
+
"bundle_installations",
|
|
413
|
+
)} set ${sql.join(assignments)} where ${where}`.execute(executor);
|
|
414
|
+
updated = Number(result.numAffectedRows ?? 0) > 0;
|
|
415
|
+
}
|
|
416
|
+
if (!updated) return null;
|
|
417
|
+
const result =
|
|
418
|
+
await sql<InsightsInstallationRow>`select * from ${sql.table(
|
|
419
|
+
"bundle_installations",
|
|
420
|
+
)} where ${sql.ref("install_id")} = ${installId} limit 1`.execute(
|
|
421
|
+
executor,
|
|
422
|
+
);
|
|
423
|
+
return result.rows[0] ?? null;
|
|
424
|
+
}
|
|
333
425
|
const selector = input.where[0];
|
|
334
426
|
if (selector === undefined || typeof selector.value !== "string") {
|
|
335
427
|
throw new KyselyAdapterInvariantError(`${input.model}.update.selector`);
|
|
@@ -452,6 +544,12 @@ export const createKyselyCrud = (
|
|
|
452
544
|
throw new DatabasePluginInputError("invalid-operation");
|
|
453
545
|
}
|
|
454
546
|
switch (input.model) {
|
|
547
|
+
case "bundle_events":
|
|
548
|
+
return countRows(
|
|
549
|
+
executor,
|
|
550
|
+
"bundle_events",
|
|
551
|
+
buildKyselyWhere(provider, input.where),
|
|
552
|
+
);
|
|
455
553
|
case "bundles":
|
|
456
554
|
return countRows(
|
|
457
555
|
executor,
|
|
@@ -470,6 +568,12 @@ export const createKyselyCrud = (
|
|
|
470
568
|
"releases",
|
|
471
569
|
buildKyselyWhere(provider, input.where),
|
|
472
570
|
);
|
|
571
|
+
case "bundle_installations":
|
|
572
|
+
return countRows(
|
|
573
|
+
executor,
|
|
574
|
+
"bundle_installations",
|
|
575
|
+
buildKyselyWhere(provider, input.where),
|
|
576
|
+
);
|
|
473
577
|
}
|
|
474
578
|
},
|
|
475
579
|
async findOne(input) {
|
|
@@ -519,6 +623,14 @@ export const createKyselyCrud = (
|
|
|
519
623
|
const row = result.rows[0];
|
|
520
624
|
return row === undefined ? null : fromStoredReleaseCatalogRow(row);
|
|
521
625
|
}
|
|
626
|
+
case "bundle_installations": {
|
|
627
|
+
const where = whereClause(buildKyselyWhere(provider, input.where));
|
|
628
|
+
const result =
|
|
629
|
+
await sql<InsightsInstallationRow>`select * from ${sql.table(
|
|
630
|
+
"bundle_installations",
|
|
631
|
+
)}${where} limit 1`.execute(executor);
|
|
632
|
+
return result.rows[0] ?? null;
|
|
633
|
+
}
|
|
522
634
|
}
|
|
523
635
|
},
|
|
524
636
|
async findMany(input) {
|
|
@@ -551,6 +663,15 @@ export const createKyselyCrud = (
|
|
|
551
663
|
)}${where}${order}${pagination}`.execute(executor);
|
|
552
664
|
return [...result.rows];
|
|
553
665
|
}
|
|
666
|
+
case "bundle_installations": {
|
|
667
|
+
const where = whereClause(buildKyselyWhere(provider, input.where));
|
|
668
|
+
const order = orderClause(input);
|
|
669
|
+
const result =
|
|
670
|
+
await sql<InsightsInstallationRow>`select * from ${sql.table(
|
|
671
|
+
"bundle_installations",
|
|
672
|
+
)}${where}${order}${pagination}`.execute(executor);
|
|
673
|
+
return [...result.rows];
|
|
674
|
+
}
|
|
554
675
|
case "api_keys": {
|
|
555
676
|
const where = whereClause(buildKyselyWhere(provider, input.where));
|
|
556
677
|
const order = orderClause(input);
|