@hot-updater/cloudflare 0.28.0 → 0.29.1

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.
@@ -0,0 +1,360 @@
1
+ import {
2
+ DEFAULT_ROLLOUT_COHORT_COUNT,
3
+ type SnakeCaseBundle,
4
+ } from "@hot-updater/core";
5
+ import type {
6
+ Bundle,
7
+ DatabaseBundleQueryWhere,
8
+ HotUpdaterContext,
9
+ PaginationOptions,
10
+ RequestEnvContext,
11
+ } from "@hot-updater/plugin-core";
12
+ import {
13
+ calculatePagination,
14
+ createDatabasePlugin,
15
+ } from "@hot-updater/plugin-core";
16
+
17
+ type D1Result<T> = {
18
+ results?: T[];
19
+ };
20
+
21
+ type D1PreparedStatement = {
22
+ bind: (...values: unknown[]) => {
23
+ all: <T>() => Promise<D1Result<T>>;
24
+ first: <T>() => Promise<T | null>;
25
+ run: () => Promise<unknown>;
26
+ };
27
+ };
28
+
29
+ type D1Like = {
30
+ prepare: (sql: string) => D1PreparedStatement;
31
+ };
32
+
33
+ export interface CloudflareWorkerDatabaseEnv {
34
+ DB: D1Like;
35
+ }
36
+
37
+ interface CloudflareWorkerDatabaseConfig<
38
+ TContext extends RequestEnvContext<CloudflareWorkerDatabaseEnv>,
39
+ > {
40
+ getDb: (context?: HotUpdaterContext<TContext>) => D1Like;
41
+ }
42
+
43
+ type QueryConditions = DatabaseBundleQueryWhere;
44
+
45
+ interface BuildQueryResult {
46
+ sql: string;
47
+ params: unknown[];
48
+ }
49
+
50
+ function buildWhereClause(
51
+ conditions: QueryConditions | undefined,
52
+ ): BuildQueryResult {
53
+ if (!conditions) {
54
+ return { sql: "", params: [] };
55
+ }
56
+
57
+ const clauses: string[] = [];
58
+ const params: unknown[] = [];
59
+
60
+ if (conditions.channel) {
61
+ clauses.push("channel = ?");
62
+ params.push(conditions.channel);
63
+ }
64
+
65
+ if (conditions.platform) {
66
+ clauses.push("platform = ?");
67
+ params.push(conditions.platform);
68
+ }
69
+
70
+ if (conditions.enabled !== undefined) {
71
+ clauses.push("enabled = ?");
72
+ params.push(conditions.enabled ? 1 : 0);
73
+ }
74
+
75
+ if (conditions.id?.in) {
76
+ if (conditions.id.in.length === 0) {
77
+ clauses.push("1 = 0");
78
+ } else {
79
+ clauses.push(`id IN (${conditions.id.in.map(() => "?").join(", ")})`);
80
+ params.push(...conditions.id.in);
81
+ }
82
+ }
83
+
84
+ if (conditions.id?.eq) {
85
+ clauses.push("id = ?");
86
+ params.push(conditions.id.eq);
87
+ }
88
+
89
+ if (conditions.id?.gt) {
90
+ clauses.push("id > ?");
91
+ params.push(conditions.id.gt);
92
+ }
93
+
94
+ if (conditions.id?.gte) {
95
+ clauses.push("id >= ?");
96
+ params.push(conditions.id.gte);
97
+ }
98
+
99
+ if (conditions.id?.lt) {
100
+ clauses.push("id < ?");
101
+ params.push(conditions.id.lt);
102
+ }
103
+
104
+ if (conditions.id?.lte) {
105
+ clauses.push("id <= ?");
106
+ params.push(conditions.id.lte);
107
+ }
108
+
109
+ if (conditions.targetAppVersionNotNull) {
110
+ clauses.push("target_app_version IS NOT NULL");
111
+ }
112
+
113
+ if (conditions.targetAppVersion !== undefined) {
114
+ if (conditions.targetAppVersion === null) {
115
+ clauses.push("target_app_version IS NULL");
116
+ } else {
117
+ clauses.push("target_app_version = ?");
118
+ params.push(conditions.targetAppVersion);
119
+ }
120
+ }
121
+
122
+ if (conditions.targetAppVersionIn) {
123
+ if (conditions.targetAppVersionIn.length === 0) {
124
+ clauses.push("1 = 0");
125
+ } else {
126
+ clauses.push(
127
+ `target_app_version IN (${conditions.targetAppVersionIn
128
+ .map(() => "?")
129
+ .join(", ")})`,
130
+ );
131
+ params.push(...conditions.targetAppVersionIn);
132
+ }
133
+ }
134
+
135
+ if (conditions.fingerprintHash !== undefined) {
136
+ if (conditions.fingerprintHash === null) {
137
+ clauses.push("fingerprint_hash IS NULL");
138
+ } else {
139
+ clauses.push("fingerprint_hash = ?");
140
+ params.push(conditions.fingerprintHash);
141
+ }
142
+ }
143
+
144
+ const whereClause =
145
+ clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
146
+
147
+ return { sql: whereClause, params };
148
+ }
149
+
150
+ function parseTargetCohorts(value: unknown): string[] | null {
151
+ if (!value) return null;
152
+ if (Array.isArray(value)) {
153
+ return value.filter((item): item is string => typeof item === "string");
154
+ }
155
+ if (typeof value === "string") {
156
+ try {
157
+ const parsed = JSON.parse(value) as unknown;
158
+ if (Array.isArray(parsed)) {
159
+ return parsed.filter(
160
+ (item): item is string => typeof item === "string",
161
+ );
162
+ }
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function transformRowToBundle(row: SnakeCaseBundle): Bundle {
171
+ return {
172
+ id: row.id,
173
+ channel: row.channel,
174
+ enabled: Boolean(row.enabled),
175
+ shouldForceUpdate: Boolean(row.should_force_update),
176
+ fileHash: row.file_hash,
177
+ gitCommitHash: row.git_commit_hash,
178
+ message: row.message,
179
+ platform: row.platform,
180
+ targetAppVersion: row.target_app_version,
181
+ storageUri: row.storage_uri,
182
+ fingerprintHash: row.fingerprint_hash,
183
+ metadata: row?.metadata ? JSON.parse(row.metadata as string) : {},
184
+ rolloutCohortCount:
185
+ (row.rollout_cohort_count as number | null) ??
186
+ DEFAULT_ROLLOUT_COHORT_COUNT,
187
+ targetCohorts: parseTargetCohorts(row.target_cohorts as unknown),
188
+ };
189
+ }
190
+
191
+ const resolveDbFromContext = (
192
+ context?: RequestEnvContext<CloudflareWorkerDatabaseEnv>,
193
+ ) => {
194
+ const db = context?.env?.DB;
195
+
196
+ if (!db) {
197
+ throw new Error(
198
+ "d1WorkerDatabase requires env.DB in the hot updater context.",
199
+ );
200
+ }
201
+
202
+ return db;
203
+ };
204
+
205
+ export const d1WorkerDatabase = <
206
+ TContext extends
207
+ RequestEnvContext<CloudflareWorkerDatabaseEnv> = RequestEnvContext<CloudflareWorkerDatabaseEnv>,
208
+ >() =>
209
+ createDatabasePlugin<CloudflareWorkerDatabaseConfig<TContext>, TContext>({
210
+ name: "d1WorkerDatabase",
211
+ factory: (config) => {
212
+ let bundles: Bundle[] = [];
213
+
214
+ const queryAll = async <TRow>(
215
+ sql: string,
216
+ params: unknown[] = [],
217
+ context?: HotUpdaterContext<TContext>,
218
+ ): Promise<TRow[]> => {
219
+ const result = await config
220
+ .getDb(context)
221
+ .prepare(sql)
222
+ .bind(...params)
223
+ .all<TRow>();
224
+ return result.results ?? [];
225
+ };
226
+
227
+ const queryFirst = async <TRow>(
228
+ sql: string,
229
+ params: unknown[] = [],
230
+ context?: HotUpdaterContext<TContext>,
231
+ ): Promise<TRow | null> => {
232
+ const result = await config
233
+ .getDb(context)
234
+ .prepare(sql)
235
+ .bind(...params)
236
+ .first<TRow>();
237
+ return result ?? null;
238
+ };
239
+
240
+ return {
241
+ async getBundleById(bundleId, context) {
242
+ const found = bundles.find((bundle) => bundle.id === bundleId);
243
+ if (found) {
244
+ return found;
245
+ }
246
+
247
+ const row = await queryFirst<SnakeCaseBundle>(
248
+ "SELECT * FROM bundles WHERE id = ? LIMIT 1",
249
+ [bundleId],
250
+ context,
251
+ );
252
+
253
+ return row ? transformRowToBundle(row) : null;
254
+ },
255
+
256
+ async getBundles(options, context) {
257
+ const { where, limit, offset, orderBy } = options;
258
+ const { sql: whereClause, params } = buildWhereClause(where);
259
+ const orderSql =
260
+ orderBy?.direction === "asc"
261
+ ? "ORDER BY id ASC"
262
+ : "ORDER BY id DESC";
263
+
264
+ const countRows = await queryAll<{ total: number }>(
265
+ `SELECT COUNT(*) as total FROM bundles${whereClause}`,
266
+ params,
267
+ context,
268
+ );
269
+ const total = countRows[0]?.total ?? 0;
270
+
271
+ const rows = await queryAll<SnakeCaseBundle>(
272
+ `SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`,
273
+ [...params, limit, offset],
274
+ context,
275
+ );
276
+
277
+ bundles = rows.map(transformRowToBundle);
278
+
279
+ const paginationOptions: PaginationOptions = { limit, offset };
280
+ return {
281
+ data: bundles,
282
+ pagination: calculatePagination(total, paginationOptions),
283
+ };
284
+ },
285
+
286
+ async getChannels(context) {
287
+ const rows = await queryAll<{ channel: string }>(
288
+ "SELECT channel FROM bundles GROUP BY channel",
289
+ [],
290
+ context,
291
+ );
292
+ return rows.map((row) => row.channel);
293
+ },
294
+
295
+ async commitBundle({ changedSets }, context) {
296
+ if (changedSets.length === 0) {
297
+ return;
298
+ }
299
+
300
+ const db = config.getDb(context);
301
+
302
+ for (const operation of changedSets) {
303
+ if (operation.operation === "delete") {
304
+ await db
305
+ .prepare("DELETE FROM bundles WHERE id = ?")
306
+ .bind(operation.data.id)
307
+ .run();
308
+ bundles = bundles.filter(
309
+ (bundle) => bundle.id !== operation.data.id,
310
+ );
311
+ continue;
312
+ }
313
+
314
+ const bundle = operation.data;
315
+ await db
316
+ .prepare(`
317
+ INSERT OR REPLACE INTO bundles (
318
+ id,
319
+ channel,
320
+ enabled,
321
+ should_force_update,
322
+ file_hash,
323
+ git_commit_hash,
324
+ message,
325
+ platform,
326
+ target_app_version,
327
+ storage_uri,
328
+ fingerprint_hash,
329
+ metadata,
330
+ rollout_cohort_count,
331
+ target_cohorts
332
+ )
333
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
334
+ `)
335
+ .bind(
336
+ bundle.id,
337
+ bundle.channel,
338
+ bundle.enabled ? 1 : 0,
339
+ bundle.shouldForceUpdate ? 1 : 0,
340
+ bundle.fileHash,
341
+ bundle.gitCommitHash || null,
342
+ bundle.message || null,
343
+ bundle.platform,
344
+ bundle.targetAppVersion,
345
+ bundle.storageUri,
346
+ bundle.fingerprintHash,
347
+ JSON.stringify(bundle.metadata ?? {}),
348
+ bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
349
+ bundle.targetCohorts
350
+ ? JSON.stringify(bundle.targetCohorts)
351
+ : null,
352
+ )
353
+ .run();
354
+ }
355
+ },
356
+ };
357
+ },
358
+ })({
359
+ getDb: resolveDbFromContext,
360
+ });
@@ -0,0 +1,173 @@
1
+ import type { DatabasePlugin } from "@hot-updater/plugin-core";
2
+ import { setupBundleMethodsTestSuite } from "@hot-updater/test-utils";
3
+ import { beforeEach, describe, vi } from "vitest";
4
+ import { d1Database } from "./d1Database";
5
+
6
+ type D1Row = {
7
+ id: string;
8
+ channel: string;
9
+ enabled: number | boolean;
10
+ should_force_update: number | boolean;
11
+ file_hash: string;
12
+ git_commit_hash: string | null;
13
+ message: string | null;
14
+ platform: "ios" | "android";
15
+ target_app_version: string | null;
16
+ storage_uri: string;
17
+ fingerprint_hash: string | null;
18
+ metadata: string;
19
+ rollout_cohort_count: number | null;
20
+ target_cohorts: string | null;
21
+ };
22
+
23
+ const { rows } = vi.hoisted(() => ({
24
+ rows: new Map<string, D1Row>(),
25
+ }));
26
+
27
+ vi.mock("pg-minify", () => ({
28
+ default: (sql: string) => sql.replace(/\s+/g, " ").trim(),
29
+ }));
30
+
31
+ const createPage = <T>(results: T[]) => ({
32
+ async *iterPages() {
33
+ yield {
34
+ result: [{ results }],
35
+ };
36
+ },
37
+ });
38
+
39
+ const getFilteredRows = (sql: string, params: any[]) => {
40
+ let filteredRows = Array.from(rows.values());
41
+ let index = 0;
42
+
43
+ if (sql.includes("channel = ?")) {
44
+ const channel = params[index++];
45
+ filteredRows = filteredRows.filter((row) => row.channel === channel);
46
+ }
47
+
48
+ if (sql.includes("platform = ?")) {
49
+ const platform = params[index++];
50
+ filteredRows = filteredRows.filter((row) => row.platform === platform);
51
+ }
52
+
53
+ return { filteredRows, index };
54
+ };
55
+
56
+ vi.mock("cloudflare", () => ({
57
+ default: class MockCloudflare {
58
+ d1 = {
59
+ database: {
60
+ query: async (
61
+ _databaseId: string,
62
+ {
63
+ sql,
64
+ params = [],
65
+ }: {
66
+ sql: string;
67
+ params?: any[];
68
+ },
69
+ ) => {
70
+ const normalizedSql = sql.replace(/\s+/g, " ").trim().toLowerCase();
71
+
72
+ if (
73
+ normalizedSql.startsWith("select count(*) as total from bundles")
74
+ ) {
75
+ const { filteredRows } = getFilteredRows(sql, params);
76
+ return createPage([{ total: filteredRows.length }]);
77
+ }
78
+
79
+ if (normalizedSql.startsWith("select * from bundles where id = ?")) {
80
+ const bundleId = params[0];
81
+ const row = rows.get(bundleId);
82
+ return createPage(row ? [row] : []);
83
+ }
84
+
85
+ if (normalizedSql.startsWith("select * from bundles")) {
86
+ const { filteredRows, index } = getFilteredRows(sql, params);
87
+ const limit = Number(params[index] ?? filteredRows.length);
88
+ const offset = Number(params[index + 1] ?? 0);
89
+ const result = filteredRows
90
+ .sort((a, b) => b.id.localeCompare(a.id))
91
+ .slice(offset, offset + limit);
92
+
93
+ return createPage(result);
94
+ }
95
+
96
+ if (
97
+ normalizedSql.startsWith(
98
+ "select channel from bundles group by channel",
99
+ )
100
+ ) {
101
+ const channels = Array.from(
102
+ new Set(Array.from(rows.values()).map((row) => row.channel)),
103
+ ).map((channel) => ({ channel }));
104
+ return createPage(channels);
105
+ }
106
+
107
+ if (normalizedSql.startsWith("delete from bundles where id = ?")) {
108
+ rows.delete(params[0]);
109
+ return createPage([]);
110
+ }
111
+
112
+ if (normalizedSql.startsWith("insert or replace into bundles")) {
113
+ const row: D1Row = {
114
+ id: params[0],
115
+ channel: params[1],
116
+ enabled: params[2],
117
+ should_force_update: params[3],
118
+ file_hash: params[4],
119
+ git_commit_hash: params[5],
120
+ message: params[6],
121
+ platform: params[7],
122
+ target_app_version: params[8],
123
+ storage_uri: params[9],
124
+ fingerprint_hash: params[10],
125
+ metadata: params[11],
126
+ rollout_cohort_count: params[12],
127
+ target_cohorts: params[13],
128
+ };
129
+ rows.set(row.id, row);
130
+ return createPage([]);
131
+ }
132
+
133
+ throw new Error(`Unsupported SQL in D1 mock: ${sql}`);
134
+ },
135
+ },
136
+ };
137
+ },
138
+ }));
139
+
140
+ describe("d1Database plugin", () => {
141
+ let plugin: DatabasePlugin;
142
+
143
+ beforeEach(() => {
144
+ rows.clear();
145
+ plugin = d1Database({
146
+ databaseId: "test-db-id",
147
+ accountId: "test-account-id",
148
+ cloudflareApiToken: "test-token",
149
+ })();
150
+ });
151
+
152
+ setupBundleMethodsTestSuite({
153
+ getBundleById: (id) => plugin.getBundleById(id),
154
+ getChannels: () => plugin.getChannels(),
155
+ insertBundle: async (bundle) => {
156
+ await plugin.appendBundle(bundle);
157
+ await plugin.commitBundle();
158
+ },
159
+ getBundles: (options) => plugin.getBundles(options),
160
+ updateBundleById: async (bundleId, newBundle) => {
161
+ await plugin.updateBundle(bundleId, newBundle);
162
+ await plugin.commitBundle();
163
+ },
164
+ deleteBundleById: async (bundleId) => {
165
+ const bundle = await plugin.getBundleById(bundleId);
166
+ if (!bundle) {
167
+ return;
168
+ }
169
+ await plugin.deleteBundle(bundle);
170
+ await plugin.commitBundle();
171
+ },
172
+ });
173
+ });