@hot-updater/cloudflare 1.0.0-rc.2 → 1.0.0-rc.4
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/iac/index.cjs +284 -162
- package/dist/iac/index.d.cts +1 -5
- package/dist/iac/index.d.mts +1 -5
- package/dist/iac/index.mjs +280 -158
- package/dist/index.cjs +69 -14
- package/dist/index.d.cts +1 -3
- package/dist/index.d.mts +1 -3
- package/dist/index.mjs +67 -12
- package/dist/worker/index.cjs +71 -15
- package/dist/worker/index.d.cts +2 -3
- package/dist/worker/index.d.mts +2 -3
- package/dist/worker/index.mjs +71 -15
- package/package.json +7 -7
- package/sql/bundles.sql +29 -7
- package/src/d1Database.spec.ts +99 -0
- package/src/d1Database.ts +7 -1
- package/src/d1Implementation.spec.ts +90 -0
- package/src/d1Implementation.ts +42 -3
- package/src/d1Query.ts +3 -0
- package/src/d1Rows.spec.ts +32 -0
- package/src/d1Rows.ts +35 -0
- package/src/worker/d1Database.spec.ts +36 -0
- package/src/worker/d1Database.ts +9 -1
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +997 -659
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0001_hot-updater_1.0.0.sql +30 -13
- package/worker/src/databaseMigration.integration.spec.ts +62 -1
- package/worker/src/databasePlugins.integration.spec.ts +35 -1
- package/worker/src/insightsMovement.integration.spec.ts +73 -0
package/dist/worker/index.d.mts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import * as _$_hot_updater_plugin_core0 from "@hot-updater/plugin-core";
|
|
2
1
|
import { StoragePluginWith } from "@hot-updater/plugin-core";
|
|
3
|
-
|
|
4
2
|
//#region src/worker/d1Database.d.ts
|
|
5
3
|
type D1Result = {
|
|
6
4
|
readonly results?: readonly unknown[];
|
|
5
|
+
readonly success?: boolean;
|
|
7
6
|
};
|
|
8
7
|
type D1BoundStatement = {
|
|
9
8
|
all: () => Promise<D1Result>;
|
|
@@ -15,7 +14,7 @@ type D1Like = {
|
|
|
15
14
|
prepare(sql: string): D1PreparedStatement;
|
|
16
15
|
batch(statements: D1BoundStatement[]): Promise<readonly D1Result[]>;
|
|
17
16
|
};
|
|
18
|
-
declare const d1Database: (database: D1Like) =>
|
|
17
|
+
declare const d1Database: (database: D1Like) => import("@hot-updater/plugin-core").DatabasePlugin;
|
|
19
18
|
//#endregion
|
|
20
19
|
//#region src/r2WorkerStorage.d.ts
|
|
21
20
|
interface CloudflareWorkerStorageConfig {
|
package/dist/worker/index.mjs
CHANGED
|
@@ -107,6 +107,29 @@ const eventRow = (row) => {
|
|
|
107
107
|
received_at_ms: numberValue(row, "received_at_ms", "bundle_events")
|
|
108
108
|
};
|
|
109
109
|
};
|
|
110
|
+
const installationRow = (row) => {
|
|
111
|
+
const type = stringValue(row, "type", "bundle_installations");
|
|
112
|
+
const platform = stringValue(row, "platform", "bundle_installations");
|
|
113
|
+
if (![
|
|
114
|
+
"UPDATE_APPLIED",
|
|
115
|
+
"RECOVERED",
|
|
116
|
+
"RELEASE_ADOPTED",
|
|
117
|
+
"UNCHANGED"
|
|
118
|
+
].includes(type) || platform !== "ios" && platform !== "android") throw new InvalidD1RowError("bundle_installations");
|
|
119
|
+
return {
|
|
120
|
+
id: stringValue(row, "id", "bundle_installations"),
|
|
121
|
+
install_id: stringValue(row, "install_id", "bundle_installations"),
|
|
122
|
+
user_id: nullableString(row, "user_id", "bundle_installations"),
|
|
123
|
+
username: nullableString(row, "username", "bundle_installations"),
|
|
124
|
+
to_bundle_id: stringValue(row, "to_bundle_id", "bundle_installations"),
|
|
125
|
+
type,
|
|
126
|
+
platform,
|
|
127
|
+
app_version: stringValue(row, "app_version", "bundle_installations"),
|
|
128
|
+
channel: stringValue(row, "channel", "bundle_installations"),
|
|
129
|
+
cohort: stringValue(row, "cohort", "bundle_installations"),
|
|
130
|
+
received_at_ms: numberValue(row, "received_at_ms", "bundle_installations")
|
|
131
|
+
};
|
|
132
|
+
};
|
|
110
133
|
const apiKeyRow = (row) => {
|
|
111
134
|
const role = stringValue(row, "role", "api_keys");
|
|
112
135
|
if (role !== "client") throw new InvalidD1RowError("api_keys");
|
|
@@ -181,6 +204,7 @@ function parseD1Row(model, value) {
|
|
|
181
204
|
case "bundle_patches": return patchRow(value);
|
|
182
205
|
case "channels": return channelRow(value);
|
|
183
206
|
case "bundle_events": return eventRow(value);
|
|
207
|
+
case "bundle_installations": return installationRow(value);
|
|
184
208
|
case "api_keys": return apiKeyRow(value);
|
|
185
209
|
case "releases": return releaseRow(value);
|
|
186
210
|
case "release_catalogs": return releaseCatalogRow(value);
|
|
@@ -217,7 +241,8 @@ const stringPredicate = (condition, operator) => {
|
|
|
217
241
|
};
|
|
218
242
|
}
|
|
219
243
|
const literal = escapeLikePattern(condition.value);
|
|
220
|
-
const
|
|
244
|
+
const pattern = operator === "contains" ? `%${literal}%` : operator === "starts_with" ? `${literal}%` : `%${literal}`;
|
|
245
|
+
const parameter = bind(pattern);
|
|
221
246
|
return {
|
|
222
247
|
sql: `lower(${condition.field}) LIKE lower(${parameter.sql}) ESCAPE '\\'`,
|
|
223
248
|
params: parameter.params
|
|
@@ -302,11 +327,13 @@ const d1TableNames = {
|
|
|
302
327
|
bundle_patches: "bundle_patches",
|
|
303
328
|
channels: "channels",
|
|
304
329
|
bundle_events: "bundle_events",
|
|
330
|
+
bundle_installations: "bundle_installations",
|
|
305
331
|
api_keys: "api_keys",
|
|
306
332
|
release_catalogs: "release_catalogs",
|
|
307
333
|
releases: "releases"
|
|
308
334
|
};
|
|
309
335
|
var D1QueryResultError = class extends Error {
|
|
336
|
+
result;
|
|
310
337
|
name = "D1QueryResultError";
|
|
311
338
|
operation = "count";
|
|
312
339
|
constructor(result) {
|
|
@@ -337,6 +364,7 @@ const findManyD1Rows = async (executor, input) => {
|
|
|
337
364
|
case "bundle_patches": return rows.map((row) => parseD1Row("bundle_patches", row));
|
|
338
365
|
case "channels": return rows.map((row) => parseD1Row("channels", row));
|
|
339
366
|
case "bundle_events": return rows.map((row) => parseD1Row("bundle_events", row));
|
|
367
|
+
case "bundle_installations": return rows.map((row) => parseD1Row("bundle_installations", row));
|
|
340
368
|
case "api_keys": return rows.map((row) => parseD1Row("api_keys", row));
|
|
341
369
|
case "releases": return rows.map((row) => parseD1Row("releases", row));
|
|
342
370
|
case "release_catalogs": return rows.map((row) => parseD1Row("release_catalogs", row));
|
|
@@ -344,6 +372,12 @@ const findManyD1Rows = async (executor, input) => {
|
|
|
344
372
|
};
|
|
345
373
|
//#endregion
|
|
346
374
|
//#region src/d1Implementation.ts
|
|
375
|
+
var D1ExecutionError = class extends Error {
|
|
376
|
+
name = "D1ExecutionError";
|
|
377
|
+
constructor() {
|
|
378
|
+
super("D1 did not successfully execute every requested statement");
|
|
379
|
+
}
|
|
380
|
+
};
|
|
347
381
|
const EXPECTATION_CONFLICT_MARKER = "HOT_UPDATER_COMMIT_EXPECTATION_CONFLICT";
|
|
348
382
|
const expectationGuard = (expectations) => {
|
|
349
383
|
const clauses = [];
|
|
@@ -381,7 +415,8 @@ const expectationConflict = async (executor, expectations) => {
|
|
|
381
415
|
const versionField = isRelease ? "revision" : "generation";
|
|
382
416
|
const key = isRelease ? expectation.id : expectation.scopeKey;
|
|
383
417
|
const expectedVersion = isRelease ? expectation.revision : expectation.generation;
|
|
384
|
-
const
|
|
418
|
+
const rows = await executor.query(`SELECT ${versionField} FROM ${table} WHERE ${keyField} = json_extract(?, '$') LIMIT 1`, encodeD1Values([key]));
|
|
419
|
+
const actualVersion = readVersion(rows[0], versionField);
|
|
385
420
|
if (actualVersion !== expectedVersion) return {
|
|
386
421
|
committed: false,
|
|
387
422
|
conflict: {
|
|
@@ -460,14 +495,14 @@ const insertQuery = (input, guard, conflictMode = "returnExisting") => {
|
|
|
460
495
|
values = channelValues(input.data);
|
|
461
496
|
break;
|
|
462
497
|
case "bundle_events":
|
|
498
|
+
case "bundle_installations":
|
|
463
499
|
case "api_keys":
|
|
464
500
|
case "release_catalogs":
|
|
465
501
|
case "releases":
|
|
466
502
|
columns = Object.keys(input.data);
|
|
467
503
|
values = Object.values(input.data);
|
|
468
|
-
break;
|
|
469
504
|
}
|
|
470
|
-
const conflict = input.onConflict !== "ignore" ? "" : input.model === "channels" ? conflictMode === "ignore" ? " ON CONFLICT(name) DO NOTHING" : " ON CONFLICT(name) DO UPDATE SET name = excluded.name" : input.model === "api_keys" ? conflictMode === "ignore" ? " ON CONFLICT(hash) DO NOTHING" : " ON CONFLICT(hash) DO UPDATE SET hash = excluded.hash" : "";
|
|
505
|
+
const conflict = input.onConflict !== "ignore" ? "" : input.model === "channels" ? conflictMode === "ignore" ? " ON CONFLICT(name) DO NOTHING" : " ON CONFLICT(name) DO UPDATE SET name = excluded.name" : input.model === "api_keys" ? conflictMode === "ignore" ? " ON CONFLICT(hash) DO NOTHING" : " ON CONFLICT(hash) DO UPDATE SET hash = excluded.hash" : " ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id";
|
|
471
506
|
return {
|
|
472
507
|
sql: `INSERT INTO ${d1TableNames[input.model]} (${columns.join(", ")}) ${guard === void 0 ? `VALUES (${d1Placeholders(values.length)})` : `SELECT ${d1Placeholders(values.length)} WHERE ${guard.sql}`}${conflict} RETURNING *`,
|
|
473
508
|
params: [...encodeD1Values(values), ...guard?.params ?? []]
|
|
@@ -531,9 +566,7 @@ const channelDeletePrecondition = (changes, changeIndex, channelId) => {
|
|
|
531
566
|
releaseEffects.set(change.row.id, change.row.channel_id);
|
|
532
567
|
break;
|
|
533
568
|
case "update": break;
|
|
534
|
-
case "delete":
|
|
535
|
-
releaseEffects.set(change.where.id, null);
|
|
536
|
-
break;
|
|
569
|
+
case "delete": releaseEffects.set(change.where.id, null);
|
|
537
570
|
}
|
|
538
571
|
}
|
|
539
572
|
return {
|
|
@@ -608,10 +641,6 @@ const changeQuery = (change, guard) => {
|
|
|
608
641
|
sql: `DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS (SELECT 1 FROM releases WHERE channel_id = json_extract(?, '$')) AND ${guard.sql}`,
|
|
609
642
|
params: [...encodeD1Values([change.where.id, change.where.id]), ...guard.params]
|
|
610
643
|
};
|
|
611
|
-
case "insights": return insertQuery({
|
|
612
|
-
model: "bundle_events",
|
|
613
|
-
data: change.row
|
|
614
|
-
}, guard);
|
|
615
644
|
case "apiKeys": return change.operation === "insert" ? insertQuery({
|
|
616
645
|
model: "api_keys",
|
|
617
646
|
data: change.row,
|
|
@@ -780,6 +809,25 @@ const deleteChannel = async (executor, input) => {
|
|
|
780
809
|
};
|
|
781
810
|
};
|
|
782
811
|
const createD1Implementation = (executor) => ({
|
|
812
|
+
async recordInsights({ event, installation }) {
|
|
813
|
+
const columns = Object.keys(installation);
|
|
814
|
+
const eventInsert = insertQuery({
|
|
815
|
+
model: "bundle_events",
|
|
816
|
+
data: event
|
|
817
|
+
});
|
|
818
|
+
await executor.batch([{
|
|
819
|
+
sql: `INSERT INTO bundle_installations (${columns.join(", ")})
|
|
820
|
+
SELECT ${d1Placeholders(columns.length)}
|
|
821
|
+
WHERE NOT EXISTS (SELECT 1 FROM bundle_events WHERE id = json_extract(?, '$'))
|
|
822
|
+
ON CONFLICT(install_id) DO UPDATE SET ${columns.filter((column) => column !== "install_id").map((column) => `${column} = excluded.${column}`).join(", ")}
|
|
823
|
+
WHERE (excluded.received_at_ms, excluded.id) >
|
|
824
|
+
(bundle_installations.received_at_ms, bundle_installations.id)`,
|
|
825
|
+
params: encodeD1Values([...Object.values(installation), event.id])
|
|
826
|
+
}, {
|
|
827
|
+
...eventInsert,
|
|
828
|
+
sql: eventInsert.sql.replace(" RETURNING *", " ON CONFLICT(id) DO NOTHING")
|
|
829
|
+
}]);
|
|
830
|
+
},
|
|
783
831
|
async create(input) {
|
|
784
832
|
const query = insertQuery(input);
|
|
785
833
|
const rows = await executor.query(query.sql, query.params);
|
|
@@ -788,6 +836,7 @@ const createD1Implementation = (executor) => ({
|
|
|
788
836
|
case "bundle_patches": return parseD1Row("bundle_patches", rows[0]);
|
|
789
837
|
case "channels": return parseD1Row("channels", rows[0]);
|
|
790
838
|
case "bundle_events": return parseD1Row("bundle_events", rows[0]);
|
|
839
|
+
case "bundle_installations": return parseD1Row("bundle_installations", rows[0]);
|
|
791
840
|
case "api_keys": return parseD1Row("api_keys", rows[0]);
|
|
792
841
|
case "releases": return parseD1Row("releases", rows[0]);
|
|
793
842
|
case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
|
|
@@ -802,6 +851,7 @@ const createD1Implementation = (executor) => ({
|
|
|
802
851
|
case "api_keys": return parseD1Row("api_keys", rows[0]);
|
|
803
852
|
case "releases": return parseD1Row("releases", rows[0]);
|
|
804
853
|
case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
|
|
854
|
+
case "bundle_installations": return parseD1Row("bundle_installations", rows[0]);
|
|
805
855
|
}
|
|
806
856
|
},
|
|
807
857
|
async delete(input) {
|
|
@@ -820,6 +870,7 @@ const createD1Implementation = (executor) => ({
|
|
|
820
870
|
case "api_keys": return parseD1Row("api_keys", rows[0]);
|
|
821
871
|
case "releases": return parseD1Row("releases", rows[0]);
|
|
822
872
|
case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
|
|
873
|
+
case "bundle_installations": return parseD1Row("bundle_installations", rows[0]);
|
|
823
874
|
}
|
|
824
875
|
},
|
|
825
876
|
findMany: (input) => findManyD1Rows(executor, input),
|
|
@@ -852,14 +903,19 @@ const createD1Implementation = (executor) => ({
|
|
|
852
903
|
//#endregion
|
|
853
904
|
//#region src/worker/d1Database.ts
|
|
854
905
|
const d1Database = (database) => {
|
|
855
|
-
const
|
|
906
|
+
const implementation = createD1Implementation({
|
|
856
907
|
async query(sql, params) {
|
|
857
|
-
|
|
908
|
+
const result = await database.prepare(sql).bind(...params).all();
|
|
909
|
+
if (result.success === false) throw new D1ExecutionError();
|
|
910
|
+
return result.results ?? [];
|
|
858
911
|
},
|
|
859
912
|
async batch(statements) {
|
|
860
|
-
|
|
913
|
+
const results = await database.batch(statements.map(({ sql, params }) => database.prepare(sql).bind(...params)));
|
|
914
|
+
if (results.length !== statements.length || results.some(({ success }) => success === false)) throw new D1ExecutionError();
|
|
915
|
+
return results.map(({ results }) => results ?? []);
|
|
861
916
|
}
|
|
862
|
-
})
|
|
917
|
+
});
|
|
918
|
+
const adapter = createDatabasePluginAdapter("d1Database", implementation);
|
|
863
919
|
return createDatabasePlugin({
|
|
864
920
|
name: "d1Database",
|
|
865
921
|
models: adapter.models,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/cloudflare",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.4",
|
|
5
5
|
"description": "React Native OTA solution for self-hosted",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"cloudflare": "4.2.0",
|
|
61
61
|
"hono": "4.12.34",
|
|
62
62
|
"uuidv7": "^1.0.2",
|
|
63
|
-
"@hot-updater/cli-tools": "1.0.0-rc.
|
|
64
|
-
"@hot-updater/plugin-core": "1.0.0-rc.
|
|
65
|
-
"@hot-updater/
|
|
66
|
-
"@hot-updater/
|
|
63
|
+
"@hot-updater/cli-tools": "1.0.0-rc.2",
|
|
64
|
+
"@hot-updater/plugin-core": "1.0.0-rc.2",
|
|
65
|
+
"@hot-updater/server": "1.0.0-rc.2",
|
|
66
|
+
"@hot-updater/core": "1.0.0-rc.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@cloudflare/vitest-pool-workers": "0.13.0",
|
|
@@ -75,11 +75,11 @@
|
|
|
75
75
|
"toml": "^3.0.0",
|
|
76
76
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
77
77
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
78
|
-
"verkit": "0.
|
|
78
|
+
"verkit": "0.4.0",
|
|
79
79
|
"vitest": "4.1.4",
|
|
80
80
|
"wrangler": "^4.69.0",
|
|
81
81
|
"xdg-app-paths": "^8.3.0",
|
|
82
|
-
"@hot-updater/test-utils": "1.0.0-rc.
|
|
82
|
+
"@hot-updater/test-utils": "1.0.0-rc.2"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"build": "tsdown && pnpm build:worker",
|
package/sql/bundles.sql
CHANGED
|
@@ -149,6 +149,27 @@ CREATE TABLE bundle_events (
|
|
|
149
149
|
CONSTRAINT bundle_events_received_at_check CHECK (received_at_ms >= 0)
|
|
150
150
|
);
|
|
151
151
|
|
|
152
|
+
CREATE TABLE bundle_installations (
|
|
153
|
+
install_id TEXT PRIMARY KEY NOT NULL,
|
|
154
|
+
id TEXT NOT NULL,
|
|
155
|
+
user_id TEXT,
|
|
156
|
+
username TEXT,
|
|
157
|
+
to_bundle_id TEXT NOT NULL,
|
|
158
|
+
type TEXT NOT NULL,
|
|
159
|
+
platform TEXT NOT NULL,
|
|
160
|
+
app_version TEXT NOT NULL,
|
|
161
|
+
channel TEXT NOT NULL,
|
|
162
|
+
cohort TEXT NOT NULL,
|
|
163
|
+
received_at_ms REAL NOT NULL,
|
|
164
|
+
CONSTRAINT bundle_installations_type_check CHECK (
|
|
165
|
+
type IN ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED', 'UNCHANGED')
|
|
166
|
+
),
|
|
167
|
+
CONSTRAINT bundle_installations_platform_check CHECK (
|
|
168
|
+
platform IN ('ios', 'android')
|
|
169
|
+
),
|
|
170
|
+
CONSTRAINT bundle_installations_received_at_check CHECK (received_at_ms >= 0)
|
|
171
|
+
);
|
|
172
|
+
|
|
152
173
|
CREATE TABLE api_keys (
|
|
153
174
|
id TEXT PRIMARY KEY NOT NULL,
|
|
154
175
|
hash TEXT NOT NULL,
|
|
@@ -179,12 +200,13 @@ CREATE INDEX releases_fingerprint_hash_idx ON releases(fingerprint_hash);
|
|
|
179
200
|
CREATE INDEX releases_enabled_idx ON releases(enabled);
|
|
180
201
|
CREATE INDEX release_catalogs_channel_idx ON release_catalogs(channel_id);
|
|
181
202
|
CREATE INDEX bundle_events_received_at_idx ON bundle_events(received_at_ms, id);
|
|
182
|
-
CREATE INDEX bundle_events_install_idx ON bundle_events(install_id, received_at_ms, id);
|
|
183
|
-
CREATE INDEX
|
|
184
|
-
CREATE INDEX
|
|
185
|
-
CREATE INDEX bundle_events_to_bundle_idx ON bundle_events(type, to_bundle_id, received_at_ms, id);
|
|
186
|
-
CREATE INDEX bundle_events_from_bundle_idx ON bundle_events(type, from_bundle_id, received_at_ms, id);
|
|
187
|
-
CREATE INDEX bundle_events_to_release_idx ON bundle_events(type, to_release_id, received_at_ms, id);
|
|
188
|
-
CREATE INDEX bundle_events_from_release_idx ON bundle_events(type, from_release_id, received_at_ms, id);
|
|
203
|
+
CREATE INDEX bundle_events_install_idx ON bundle_events(install_id, type, received_at_ms, id);
|
|
204
|
+
CREATE INDEX bundle_installations_user_id_idx ON bundle_installations(user_id, install_id);
|
|
205
|
+
CREATE INDEX bundle_installations_received_at_idx ON bundle_installations(received_at_ms);
|
|
189
206
|
CREATE UNIQUE INDEX api_keys_hash_key ON api_keys(hash);
|
|
190
207
|
CREATE INDEX api_keys_created_at_idx ON api_keys(created_at_ms, id);
|
|
208
|
+
|
|
209
|
+
CREATE INDEX bundle_events_from_bundle_idx ON bundle_events(type, platform, channel, from_bundle_id, received_at_ms, id);
|
|
210
|
+
CREATE INDEX bundle_events_to_bundle_idx ON bundle_events(type, platform, channel, to_bundle_id, received_at_ms, id);
|
|
211
|
+
CREATE INDEX bundle_installations_scope_idx ON bundle_installations(platform, channel, received_at_ms);
|
|
212
|
+
CREATE INDEX bundle_installations_bundle_idx ON bundle_installations(platform, channel, to_bundle_id, received_at_ms);
|
package/src/d1Database.spec.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
|
|
1
2
|
import { beforeEach, expect, it, vi } from "vitest";
|
|
2
3
|
|
|
3
4
|
import { d1Database } from "./d1Database";
|
|
@@ -26,6 +27,26 @@ const bundleD1Row = {
|
|
|
26
27
|
asset_base_storage_uri: null,
|
|
27
28
|
} as const;
|
|
28
29
|
|
|
30
|
+
const eventD1Row = {
|
|
31
|
+
id: "00000000-0000-7000-8000-000000000001",
|
|
32
|
+
type: "UPDATE_APPLIED",
|
|
33
|
+
install_id: "install-1",
|
|
34
|
+
user_id: "user-1",
|
|
35
|
+
username: "Demo User",
|
|
36
|
+
from_release_id: null,
|
|
37
|
+
from_bundle_id: "bundle-previous",
|
|
38
|
+
to_release_id: null,
|
|
39
|
+
to_bundle_id: "bundle-current",
|
|
40
|
+
platform: "ios",
|
|
41
|
+
app_version: "1.0.0",
|
|
42
|
+
channel: "production",
|
|
43
|
+
cohort: "cohort-1",
|
|
44
|
+
update_strategy: "appVersion",
|
|
45
|
+
fingerprint_hash: null,
|
|
46
|
+
sdk_version: "1.0.0",
|
|
47
|
+
received_at_ms: 100,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
29
50
|
vi.mock("cloudflare", () => ({
|
|
30
51
|
default: class MockCloudflare {
|
|
31
52
|
readonly d1 = {
|
|
@@ -176,3 +197,81 @@ it("sends parameterized commits through the D1 batch body", async () => {
|
|
|
176
197
|
expect(state.batches[0]?.[0]?.params.length).toBeGreaterThan(0);
|
|
177
198
|
expect(state.batches[0]?.[1]?.params.length).toBeGreaterThan(0);
|
|
178
199
|
});
|
|
200
|
+
|
|
201
|
+
it("stores the event and current installation in one parameterized atomic batch", async () => {
|
|
202
|
+
const plugin = d1Database({
|
|
203
|
+
accountId: "account",
|
|
204
|
+
cloudflareApiToken: "token",
|
|
205
|
+
databaseId: "database",
|
|
206
|
+
});
|
|
207
|
+
await expect(
|
|
208
|
+
plugin.models.insights.record({
|
|
209
|
+
event: eventD1Row,
|
|
210
|
+
installation: toInsightsInstallationRow(eventD1Row),
|
|
211
|
+
}),
|
|
212
|
+
).resolves.toBeUndefined();
|
|
213
|
+
expect(state.queries).toHaveLength(0);
|
|
214
|
+
expect(state.batches).toHaveLength(1);
|
|
215
|
+
expect(state.batches[0]).toHaveLength(2);
|
|
216
|
+
expect(state.batches[0]?.[0]?.sql).toContain(
|
|
217
|
+
"WHERE NOT EXISTS (SELECT 1 FROM bundle_events",
|
|
218
|
+
);
|
|
219
|
+
expect(state.batches[0]?.[1]?.sql).toContain("ON CONFLICT(id) DO NOTHING");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("queries each movement type through a bounded descending range", async () => {
|
|
223
|
+
const plugin = d1Database({
|
|
224
|
+
accountId: "account",
|
|
225
|
+
cloudflareApiToken: "token",
|
|
226
|
+
databaseId: "database",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
plugin.models.insights.listEvents({
|
|
231
|
+
filter: {
|
|
232
|
+
kind: "installationMovement",
|
|
233
|
+
installId: eventD1Row.install_id,
|
|
234
|
+
},
|
|
235
|
+
beforeReceivedAtMs: 200,
|
|
236
|
+
limit: 101,
|
|
237
|
+
}),
|
|
238
|
+
).resolves.toEqual([]);
|
|
239
|
+
|
|
240
|
+
expect(state.queries).toHaveLength(2);
|
|
241
|
+
for (const query of state.queries) {
|
|
242
|
+
expect(query.sql).toContain("SELECT * FROM bundle_events");
|
|
243
|
+
expect(query.sql).toContain("type = json_extract(?, '$')");
|
|
244
|
+
expect(query.sql).toContain("ORDER BY received_at_ms DESC, id DESC");
|
|
245
|
+
expect(query.params).toContain("101");
|
|
246
|
+
}
|
|
247
|
+
expect(state.queries.flatMap(({ params }) => params)).toEqual(
|
|
248
|
+
expect.arrayContaining([
|
|
249
|
+
JSON.stringify("UPDATE_APPLIED"),
|
|
250
|
+
JSON.stringify("RECOVERED"),
|
|
251
|
+
]),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("counts active installations without reading event history", async () => {
|
|
256
|
+
state.results.push({ count: 2 });
|
|
257
|
+
const plugin = d1Database({
|
|
258
|
+
accountId: "account",
|
|
259
|
+
cloudflareApiToken: "token",
|
|
260
|
+
databaseId: "database",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
plugin.models.insights.countInstallations({
|
|
265
|
+
platform: "ios",
|
|
266
|
+
channel: "production",
|
|
267
|
+
sinceMs: 100,
|
|
268
|
+
}),
|
|
269
|
+
).resolves.toBe(2);
|
|
270
|
+
|
|
271
|
+
expect(state.queries).toHaveLength(1);
|
|
272
|
+
expect(state.queries[0]?.sql).toContain(
|
|
273
|
+
"SELECT COUNT(*) AS count FROM bundle_installations",
|
|
274
|
+
);
|
|
275
|
+
expect(state.queries[0]?.sql).toContain("received_at_ms >=");
|
|
276
|
+
expect(state.queries[0]?.sql).not.toContain("bundle_events");
|
|
277
|
+
});
|
package/src/d1Database.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { createDatabasePlugin } from "@hot-updater/plugin-core";
|
|
|
2
2
|
import { createDatabasePluginAdapter } from "@hot-updater/plugin-core/internal";
|
|
3
3
|
import Cloudflare from "cloudflare";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createD1Implementation,
|
|
7
|
+
D1ExecutionError,
|
|
8
|
+
type D1Statement,
|
|
9
|
+
} from "./d1Implementation";
|
|
6
10
|
|
|
7
11
|
export interface D1DatabaseConfig {
|
|
8
12
|
readonly databaseId: string;
|
|
@@ -41,9 +45,11 @@ export const d1Database = (config: D1DatabaseConfig) => {
|
|
|
41
45
|
const results: unknown[][] = [];
|
|
42
46
|
for await (const resultPage of page.iterPages()) {
|
|
43
47
|
for (const result of resultPage.result) {
|
|
48
|
+
if (result.success === false) throw new D1ExecutionError();
|
|
44
49
|
results.push(result.results ?? []);
|
|
45
50
|
}
|
|
46
51
|
}
|
|
52
|
+
if (results.length !== statements.length) throw new D1ExecutionError();
|
|
47
53
|
return results;
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -2,6 +2,20 @@ import { expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { createD1Implementation, type D1Statement } from "./d1Implementation";
|
|
4
4
|
|
|
5
|
+
const installationRow = {
|
|
6
|
+
id: "event-1",
|
|
7
|
+
install_id: "install-1",
|
|
8
|
+
user_id: "user-1",
|
|
9
|
+
username: "Demo User",
|
|
10
|
+
to_bundle_id: "bundle-1",
|
|
11
|
+
type: "UPDATE_APPLIED" as const,
|
|
12
|
+
platform: "ios" as const,
|
|
13
|
+
app_version: "1.0.0",
|
|
14
|
+
channel: "production",
|
|
15
|
+
cohort: "cohort-1",
|
|
16
|
+
received_at_ms: 100,
|
|
17
|
+
};
|
|
18
|
+
|
|
5
19
|
it("guards every write and reports the missing change index", async () => {
|
|
6
20
|
let recorded: readonly D1Statement[] = [];
|
|
7
21
|
const implementation = createD1Implementation({
|
|
@@ -250,3 +264,79 @@ it("guards a generic Channel delete and reports a referenced conflict", async ()
|
|
|
250
264
|
expect(recorded[1]?.sql).toContain("DELETE FROM channels");
|
|
251
265
|
expect(recorded[1]?.sql).toContain("NOT EXISTS");
|
|
252
266
|
});
|
|
267
|
+
|
|
268
|
+
it("returns the canonical current installation on an idempotent create", async () => {
|
|
269
|
+
const queries: D1Statement[] = [];
|
|
270
|
+
const implementation = createD1Implementation({
|
|
271
|
+
async query(sql, params) {
|
|
272
|
+
queries.push({ sql, params });
|
|
273
|
+
return [installationRow];
|
|
274
|
+
},
|
|
275
|
+
batch: () => Promise.reject(new Error("unexpected batch")),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await expect(
|
|
279
|
+
implementation.create({
|
|
280
|
+
model: "bundle_installations",
|
|
281
|
+
data: installationRow,
|
|
282
|
+
onConflict: "ignore",
|
|
283
|
+
}),
|
|
284
|
+
).resolves.toEqual(installationRow);
|
|
285
|
+
|
|
286
|
+
expect(queries[0]?.sql).toContain("INSERT INTO bundle_installations");
|
|
287
|
+
expect(queries[0]?.sql).toContain(
|
|
288
|
+
"ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id",
|
|
289
|
+
);
|
|
290
|
+
expect(queries[0]?.sql).toContain("RETURNING *");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("updates a current installation only through the supplied latest-row condition", async () => {
|
|
294
|
+
const queries: D1Statement[] = [];
|
|
295
|
+
const implementation = createD1Implementation({
|
|
296
|
+
async query(sql, params) {
|
|
297
|
+
queries.push({ sql, params });
|
|
298
|
+
return [installationRow];
|
|
299
|
+
},
|
|
300
|
+
batch: () => Promise.reject(new Error("unexpected batch")),
|
|
301
|
+
});
|
|
302
|
+
const { install_id: _installId, ...update } = installationRow;
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
implementation.update({
|
|
306
|
+
model: "bundle_installations",
|
|
307
|
+
where: [
|
|
308
|
+
{ field: "install_id", value: installationRow.install_id },
|
|
309
|
+
{
|
|
310
|
+
field: "received_at_ms",
|
|
311
|
+
operator: "lt",
|
|
312
|
+
value: installationRow.received_at_ms,
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
update,
|
|
316
|
+
}),
|
|
317
|
+
).resolves.toEqual(installationRow);
|
|
318
|
+
|
|
319
|
+
await expect(
|
|
320
|
+
implementation.update({
|
|
321
|
+
model: "bundle_installations",
|
|
322
|
+
where: [
|
|
323
|
+
{ field: "install_id", value: installationRow.install_id },
|
|
324
|
+
{
|
|
325
|
+
field: "received_at_ms",
|
|
326
|
+
value: installationRow.received_at_ms,
|
|
327
|
+
},
|
|
328
|
+
{ field: "id", operator: "lt", value: installationRow.id },
|
|
329
|
+
],
|
|
330
|
+
update,
|
|
331
|
+
}),
|
|
332
|
+
).resolves.toEqual(installationRow);
|
|
333
|
+
|
|
334
|
+
expect(queries[0]?.sql).toContain("UPDATE bundle_installations SET");
|
|
335
|
+
expect(queries[0]?.sql).toContain(
|
|
336
|
+
"WHERE (install_id = json_extract(?, '$') AND received_at_ms < json_extract(?, '$'))",
|
|
337
|
+
);
|
|
338
|
+
expect(queries[0]?.sql).toContain("RETURNING *");
|
|
339
|
+
expect(queries[1]?.sql).toContain(
|
|
340
|
+
"WHERE ((install_id = json_extract(?, '$') AND received_at_ms = json_extract(?, '$')) AND id < json_extract(?, '$'))",
|
|
341
|
+
);
|
|
342
|
+
});
|
package/src/d1Implementation.ts
CHANGED
|
@@ -35,6 +35,13 @@ export interface D1Statement {
|
|
|
35
35
|
readonly params: readonly string[];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export class D1ExecutionError extends Error {
|
|
39
|
+
readonly name = "D1ExecutionError";
|
|
40
|
+
constructor() {
|
|
41
|
+
super("D1 did not successfully execute every requested statement");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
type D1Guard = {
|
|
39
46
|
readonly sql: string;
|
|
40
47
|
readonly params: readonly string[];
|
|
@@ -191,6 +198,7 @@ const insertQuery = (
|
|
|
191
198
|
values = channelValues(input.data);
|
|
192
199
|
break;
|
|
193
200
|
case "bundle_events":
|
|
201
|
+
case "bundle_installations":
|
|
194
202
|
case "api_keys":
|
|
195
203
|
case "release_catalogs":
|
|
196
204
|
case "releases":
|
|
@@ -210,7 +218,7 @@ const insertQuery = (
|
|
|
210
218
|
? conflictMode === "ignore"
|
|
211
219
|
? " ON CONFLICT(hash) DO NOTHING"
|
|
212
220
|
: " ON CONFLICT(hash) DO UPDATE SET hash = excluded.hash"
|
|
213
|
-
: "";
|
|
221
|
+
: " ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id";
|
|
214
222
|
|
|
215
223
|
return {
|
|
216
224
|
sql: `INSERT INTO ${d1TableNames[input.model]} (${columns.join(", ")}) ${
|
|
@@ -439,8 +447,6 @@ const changeQuery = (change: DatabaseChange, guard: D1Guard): D1Statement => {
|
|
|
439
447
|
...guard.params,
|
|
440
448
|
],
|
|
441
449
|
};
|
|
442
|
-
case "insights":
|
|
443
|
-
return insertQuery({ model: "bundle_events", data: change.row }, guard);
|
|
444
450
|
case "apiKeys":
|
|
445
451
|
return change.operation === "insert"
|
|
446
452
|
? insertQuery(
|
|
@@ -638,6 +644,33 @@ const deleteChannel = async (
|
|
|
638
644
|
export const createD1Implementation = (
|
|
639
645
|
executor: D1Executor,
|
|
640
646
|
): DatabasePluginImplementation => ({
|
|
647
|
+
async recordInsights({ event, installation }) {
|
|
648
|
+
const columns = Object.keys(installation);
|
|
649
|
+
const eventInsert = insertQuery({ model: "bundle_events", data: event });
|
|
650
|
+
// D1 executes the whole batch as one serial transaction. The snapshot
|
|
651
|
+
// guard runs before the event insert so a duplicate cannot mutate it.
|
|
652
|
+
await executor.batch([
|
|
653
|
+
{
|
|
654
|
+
sql: `INSERT INTO bundle_installations (${columns.join(", ")})
|
|
655
|
+
SELECT ${d1Placeholders(columns.length)}
|
|
656
|
+
WHERE NOT EXISTS (SELECT 1 FROM bundle_events WHERE id = json_extract(?, '$'))
|
|
657
|
+
ON CONFLICT(install_id) DO UPDATE SET ${columns
|
|
658
|
+
.filter((column) => column !== "install_id")
|
|
659
|
+
.map((column) => `${column} = excluded.${column}`)
|
|
660
|
+
.join(", ")}
|
|
661
|
+
WHERE (excluded.received_at_ms, excluded.id) >
|
|
662
|
+
(bundle_installations.received_at_ms, bundle_installations.id)`,
|
|
663
|
+
params: encodeD1Values([...Object.values(installation), event.id]),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
...eventInsert,
|
|
667
|
+
sql: eventInsert.sql.replace(
|
|
668
|
+
" RETURNING *",
|
|
669
|
+
" ON CONFLICT(id) DO NOTHING",
|
|
670
|
+
),
|
|
671
|
+
},
|
|
672
|
+
]);
|
|
673
|
+
},
|
|
641
674
|
async create(input) {
|
|
642
675
|
const query = insertQuery(input);
|
|
643
676
|
const rows = await executor.query(query.sql, query.params);
|
|
@@ -650,6 +683,8 @@ export const createD1Implementation = (
|
|
|
650
683
|
return parseD1Row("channels", rows[0]);
|
|
651
684
|
case "bundle_events":
|
|
652
685
|
return parseD1Row("bundle_events", rows[0]);
|
|
686
|
+
case "bundle_installations":
|
|
687
|
+
return parseD1Row("bundle_installations", rows[0]);
|
|
653
688
|
case "api_keys":
|
|
654
689
|
return parseD1Row("api_keys", rows[0]);
|
|
655
690
|
case "releases":
|
|
@@ -671,6 +706,8 @@ export const createD1Implementation = (
|
|
|
671
706
|
return parseD1Row("releases", rows[0]);
|
|
672
707
|
case "release_catalogs":
|
|
673
708
|
return parseD1Row("release_catalogs", rows[0]);
|
|
709
|
+
case "bundle_installations":
|
|
710
|
+
return parseD1Row("bundle_installations", rows[0]);
|
|
674
711
|
}
|
|
675
712
|
},
|
|
676
713
|
async delete(input) {
|
|
@@ -698,6 +735,8 @@ export const createD1Implementation = (
|
|
|
698
735
|
return parseD1Row("releases", rows[0]);
|
|
699
736
|
case "release_catalogs":
|
|
700
737
|
return parseD1Row("release_catalogs", rows[0]);
|
|
738
|
+
case "bundle_installations":
|
|
739
|
+
return parseD1Row("bundle_installations", rows[0]);
|
|
701
740
|
}
|
|
702
741
|
},
|
|
703
742
|
findMany: (input) => findManyD1Rows(executor, input),
|
package/src/d1Query.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const d1TableNames = {
|
|
|
14
14
|
bundle_patches: "bundle_patches",
|
|
15
15
|
channels: "channels",
|
|
16
16
|
bundle_events: "bundle_events",
|
|
17
|
+
bundle_installations: "bundle_installations",
|
|
17
18
|
api_keys: "api_keys",
|
|
18
19
|
release_catalogs: "release_catalogs",
|
|
19
20
|
releases: "releases",
|
|
@@ -79,6 +80,8 @@ export const findManyD1Rows = async (
|
|
|
79
80
|
return rows.map((row) => parseD1Row("channels", row));
|
|
80
81
|
case "bundle_events":
|
|
81
82
|
return rows.map((row) => parseD1Row("bundle_events", row));
|
|
83
|
+
case "bundle_installations":
|
|
84
|
+
return rows.map((row) => parseD1Row("bundle_installations", row));
|
|
82
85
|
case "api_keys":
|
|
83
86
|
return rows.map((row) => parseD1Row("api_keys", row));
|
|
84
87
|
case "releases":
|