@hot-updater/cloudflare 0.32.0 → 0.33.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,260 @@
1
+ import type { DatabasePlugin } from "@hot-updater/plugin-core";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+
4
+ import { d1Database, type RequestEnvContext } from "./worker";
5
+
6
+ type WorkerBundleRow = {
7
+ id: string;
8
+ channel: string;
9
+ enabled: number;
10
+ should_force_update: number;
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
+ manifest_storage_uri: string | null;
20
+ manifest_file_hash: string | null;
21
+ asset_base_storage_uri: string | null;
22
+ rollout_cohort_count: number | null;
23
+ target_cohorts: string | null;
24
+ };
25
+
26
+ type WorkerPatchRow = {
27
+ id: string;
28
+ bundle_id: string;
29
+ base_bundle_id: string;
30
+ base_file_hash: string;
31
+ patch_file_hash: string;
32
+ patch_storage_uri: string;
33
+ order_index: number | null;
34
+ };
35
+
36
+ type TestEnv = {
37
+ DB: ReturnType<typeof createD1Binding>;
38
+ JWT_SECRET: string;
39
+ BUCKET: {
40
+ get: (key: string) => Promise<{ text: () => Promise<string> } | null>;
41
+ };
42
+ };
43
+
44
+ const rows = new Map<string, WorkerBundleRow>();
45
+ const patchRows = new Map<string, WorkerPatchRow>();
46
+
47
+ const createBundleRow = (index: number): WorkerBundleRow => {
48
+ const id = `00000000-0000-0000-0000-${String(index).padStart(12, "0")}`;
49
+ return {
50
+ id,
51
+ channel: "production",
52
+ enabled: 1,
53
+ should_force_update: 0,
54
+ file_hash: `hash-${index}`,
55
+ git_commit_hash: null,
56
+ message: null,
57
+ platform: "ios",
58
+ target_app_version: `>=0.${index}.0`,
59
+ storage_uri: `r2://bucket/${id}.zip`,
60
+ fingerprint_hash: null,
61
+ metadata: "{}",
62
+ manifest_storage_uri: null,
63
+ manifest_file_hash: null,
64
+ asset_base_storage_uri: null,
65
+ rollout_cohort_count: 1000,
66
+ target_cohorts: null,
67
+ };
68
+ };
69
+
70
+ const normalizeSql = (sql: string) => sql.replace(/\s+/g, " ").trim();
71
+
72
+ const filterRows = (sql: string, params: unknown[]) => {
73
+ let filteredRows = Array.from(rows.values());
74
+ let index = 0;
75
+
76
+ if (sql.includes("channel = ?")) {
77
+ const channel = params[index++];
78
+ filteredRows = filteredRows.filter((row) => row.channel === channel);
79
+ }
80
+
81
+ if (sql.includes("platform = ?")) {
82
+ const platform = params[index++];
83
+ filteredRows = filteredRows.filter((row) => row.platform === platform);
84
+ }
85
+
86
+ if (sql.includes("enabled = ?")) {
87
+ const enabled = Number(params[index++]);
88
+ filteredRows = filteredRows.filter((row) => row.enabled === enabled);
89
+ }
90
+
91
+ if (sql.includes("id >= ?")) {
92
+ const id = String(params[index++]);
93
+ filteredRows = filteredRows.filter((row) => row.id.localeCompare(id) >= 0);
94
+ }
95
+
96
+ if (sql.includes("target_app_version IS NOT NULL")) {
97
+ filteredRows = filteredRows.filter(
98
+ (row) => row.target_app_version !== null,
99
+ );
100
+ }
101
+
102
+ const inMatch = sql.match(/target_app_version IN \(([^)]+)\)/);
103
+ if (inMatch) {
104
+ const body = inMatch[1] ?? "";
105
+ const inValues = body.includes("json_each(")
106
+ ? (JSON.parse(String(params[index++])) as unknown[])
107
+ : params.slice(index, index + (body.match(/\?/g) ?? []).length);
108
+ const values = new Set(inValues);
109
+ filteredRows = filteredRows.filter((row) =>
110
+ values.has(row.target_app_version),
111
+ );
112
+ if (!body.includes("json_each(")) {
113
+ index += inValues.length;
114
+ }
115
+ }
116
+
117
+ return { filteredRows, index };
118
+ };
119
+
120
+ function createD1Binding() {
121
+ return {
122
+ prepare(sql: string) {
123
+ return {
124
+ bind(...params: unknown[]) {
125
+ if (params.length > 100) {
126
+ throw new Error(
127
+ "D1_ERROR: too many SQL variables at offset 386: SQLITE_ERROR",
128
+ );
129
+ }
130
+
131
+ return {
132
+ async all<T>() {
133
+ const normalizedSql = normalizeSql(sql).toLowerCase();
134
+
135
+ if (
136
+ normalizedSql.startsWith(
137
+ "select target_app_version from bundles",
138
+ )
139
+ ) {
140
+ const { filteredRows } = filterRows(sql, params);
141
+ const targetAppVersions = Array.from(
142
+ new Set(
143
+ filteredRows
144
+ .map((row) => row.target_app_version)
145
+ .filter(
146
+ (targetAppVersion): targetAppVersion is string =>
147
+ targetAppVersion !== null,
148
+ ),
149
+ ),
150
+ ).map((targetAppVersion) => ({
151
+ target_app_version: targetAppVersion,
152
+ }));
153
+
154
+ return { results: targetAppVersions as T[] };
155
+ }
156
+
157
+ if (
158
+ normalizedSql.startsWith(
159
+ "select count(*) as total from bundles",
160
+ )
161
+ ) {
162
+ const { filteredRows } = filterRows(sql, params);
163
+ return { results: [{ total: filteredRows.length }] as T[] };
164
+ }
165
+
166
+ if (normalizedSql.startsWith("select * from bundles")) {
167
+ const { filteredRows, index } = filterRows(sql, params);
168
+ const limit = Number(params[index] ?? filteredRows.length);
169
+ const offset = Number(params[index + 1] ?? 0);
170
+ const result = filteredRows
171
+ .sort((left, right) => right.id.localeCompare(left.id))
172
+ .slice(offset, offset + limit);
173
+
174
+ return { results: result as T[] };
175
+ }
176
+
177
+ if (
178
+ normalizedSql.startsWith(
179
+ "select * from bundle_patches where bundle_id in",
180
+ )
181
+ ) {
182
+ const selectedBundleIds = new Set(
183
+ normalizedSql.includes("json_each")
184
+ ? (JSON.parse(String(params[0])) as unknown[]).map(String)
185
+ : params.map(String),
186
+ );
187
+ const result = Array.from(patchRows.values()).filter((row) =>
188
+ selectedBundleIds.has(row.bundle_id),
189
+ );
190
+
191
+ return { results: result as T[] };
192
+ }
193
+
194
+ throw new Error(`Unsupported SQL in D1 worker mock: ${sql}`);
195
+ },
196
+ async first<T>() {
197
+ return (await this.all<T>()).results?.[0] ?? null;
198
+ },
199
+ async run() {
200
+ return {};
201
+ },
202
+ };
203
+ },
204
+ };
205
+ },
206
+ };
207
+ }
208
+
209
+ describe("cloudflare worker d1Database", () => {
210
+ let plugin: DatabasePlugin<RequestEnvContext<TestEnv>>;
211
+ let context: RequestEnvContext<TestEnv>;
212
+
213
+ beforeEach(() => {
214
+ rows.clear();
215
+ patchRows.clear();
216
+ plugin = d1Database<RequestEnvContext<TestEnv>>()();
217
+ context = {
218
+ env: {
219
+ DB: createD1Binding(),
220
+ JWT_SECRET: "test-secret",
221
+ BUCKET: {
222
+ get: async () => null,
223
+ },
224
+ },
225
+ };
226
+ });
227
+
228
+ it("queries getUpdateInfo with 200 distinct target_app_versions without exceeding D1's 100-bind cap", async () => {
229
+ for (let index = 0; index < 200; index++) {
230
+ const row = createBundleRow(index);
231
+ rows.set(row.id, row);
232
+ }
233
+
234
+ const result = await plugin.getUpdateInfo?.(
235
+ {
236
+ appVersion: "1.0.0",
237
+ bundleId: "00000000-0000-0000-0000-000000000000",
238
+ platform: "ios",
239
+ channel: "production",
240
+ minBundleId: "00000000-0000-0000-0000-000000000000",
241
+ _updateStrategy: "appVersion",
242
+ },
243
+ context,
244
+ );
245
+
246
+ expect(result).not.toBeNull();
247
+ });
248
+
249
+ it("queries patches for 200 listed bundles without exceeding D1's 100-bind cap", async () => {
250
+ for (let index = 0; index < 200; index++) {
251
+ const row = createBundleRow(index);
252
+ rows.set(row.id, row);
253
+ }
254
+
255
+ const result = await plugin.getBundles({ limit: 200 }, context);
256
+
257
+ expect(result.data).toHaveLength(200);
258
+ expect(result.pagination.total).toBe(200);
259
+ });
260
+ });
@@ -52,6 +52,19 @@ interface BuildQueryResult {
52
52
  params: unknown[];
53
53
  }
54
54
 
55
+ const buildJsonEachInClause = (
56
+ columnName: string,
57
+ values: string[],
58
+ params: unknown[],
59
+ ) => {
60
+ if (values.length === 0) {
61
+ return "1 = 0";
62
+ }
63
+
64
+ params.push(JSON.stringify(values));
65
+ return `${columnName} IN (SELECT value FROM json_each(?))`;
66
+ };
67
+
55
68
  interface D1WorkerBundleRow {
56
69
  id: string;
57
70
  channel: string;
@@ -108,12 +121,7 @@ function buildWhereClause(
108
121
  }
109
122
 
110
123
  if (conditions.id?.in) {
111
- if (conditions.id.in.length === 0) {
112
- clauses.push("1 = 0");
113
- } else {
114
- clauses.push(`id IN (${conditions.id.in.map(() => "?").join(", ")})`);
115
- params.push(...conditions.id.in);
116
- }
124
+ clauses.push(buildJsonEachInClause("id", conditions.id.in, params));
117
125
  }
118
126
 
119
127
  if (conditions.id?.eq) {
@@ -155,16 +163,13 @@ function buildWhereClause(
155
163
  }
156
164
 
157
165
  if (conditions.targetAppVersionIn) {
158
- if (conditions.targetAppVersionIn.length === 0) {
159
- clauses.push("1 = 0");
160
- } else {
161
- clauses.push(
162
- `target_app_version IN (${conditions.targetAppVersionIn
163
- .map(() => "?")
164
- .join(", ")})`,
165
- );
166
- params.push(...conditions.targetAppVersionIn);
167
- }
166
+ clauses.push(
167
+ buildJsonEachInClause(
168
+ "target_app_version",
169
+ conditions.targetAppVersionIn,
170
+ params,
171
+ ),
172
+ );
168
173
  }
169
174
 
170
175
  if (conditions.fingerprintHash !== undefined) {
@@ -335,15 +340,14 @@ export const d1WorkerDatabase = <
335
340
  return patchMap;
336
341
  }
337
342
 
338
- const placeholders = bundleIds.map(() => "?").join(", ");
339
343
  const rows = await queryAll<D1WorkerBundlePatchRow>(
340
344
  `
341
345
  SELECT *
342
346
  FROM bundle_patches
343
- WHERE bundle_id IN (${placeholders})
347
+ WHERE bundle_id IN (SELECT value FROM json_each(?))
344
348
  ORDER BY order_index ASC, base_bundle_id ASC
345
349
  `,
346
- bundleIds,
350
+ [JSON.stringify(bundleIds)],
347
351
  context,
348
352
  );
349
353
 
@@ -64,7 +64,13 @@ const getFilteredRows = (sql: string, params: any[]) => {
64
64
  return null;
65
65
  }
66
66
 
67
- const count = (match[1]?.match(/\?/g) ?? []).length;
67
+ const body = match[1] ?? "";
68
+ if (body.includes("json_each(")) {
69
+ const values = JSON.parse(String(params[index++])) as unknown;
70
+ return Array.isArray(values) ? values : [];
71
+ }
72
+
73
+ const count = (body.match(/\?/g) ?? []).length;
68
74
  const values = params.slice(index, index + count);
69
75
  index += count;
70
76
  return values;
@@ -169,6 +175,12 @@ vi.mock("cloudflare", () => ({
169
175
  params?: any[];
170
176
  },
171
177
  ) => {
178
+ if (params.length > 100) {
179
+ throw new Error(
180
+ "D1_ERROR: too many SQL variables at offset 386: SQLITE_ERROR",
181
+ );
182
+ }
183
+
172
184
  const normalizedSql = sql.replace(/\s+/g, " ").trim().toLowerCase();
173
185
 
174
186
  if (
@@ -190,7 +202,9 @@ vi.mock("cloudflare", () => ({
190
202
  )
191
203
  ) {
192
204
  const selectedBundleIds = new Set(
193
- params.map((value) => String(value)),
205
+ normalizedSql.includes("json_each")
206
+ ? (JSON.parse(String(params[0])) as unknown[]).map(String)
207
+ : params.map((value) => String(value)),
194
208
  );
195
209
  const result = Array.from(patchRows.values())
196
210
  .filter((row) => selectedBundleIds.has(row.bundle_id))
package/src/d1Database.ts CHANGED
@@ -34,6 +34,19 @@ interface BuildQueryResult {
34
34
  params: any[];
35
35
  }
36
36
 
37
+ const buildJsonEachInClause = (
38
+ columnName: string,
39
+ values: string[],
40
+ params: any[],
41
+ ) => {
42
+ if (values.length === 0) {
43
+ return "1 = 0";
44
+ }
45
+
46
+ params.push(JSON.stringify(values));
47
+ return `${columnName} IN (SELECT value FROM json_each(?))`;
48
+ };
49
+
37
50
  interface D1BundleRow {
38
51
  id: string;
39
52
  channel: string;
@@ -94,12 +107,7 @@ function buildWhereClause(conditions: QueryConditions): BuildQueryResult {
94
107
  }
95
108
 
96
109
  if (conditions.id?.in) {
97
- if (conditions.id.in.length === 0) {
98
- clauses.push("1 = 0");
99
- } else {
100
- clauses.push(`id IN (${conditions.id.in.map(() => "?").join(", ")})`);
101
- params.push(...conditions.id.in);
102
- }
110
+ clauses.push(buildJsonEachInClause("id", conditions.id.in, params));
103
111
  }
104
112
 
105
113
  if (conditions.id?.eq) {
@@ -141,16 +149,13 @@ function buildWhereClause(conditions: QueryConditions): BuildQueryResult {
141
149
  }
142
150
 
143
151
  if (conditions.targetAppVersionIn) {
144
- if (conditions.targetAppVersionIn.length === 0) {
145
- clauses.push("1 = 0");
146
- } else {
147
- clauses.push(
148
- `target_app_version IN (${conditions.targetAppVersionIn
149
- .map(() => "?")
150
- .join(", ")})`,
151
- );
152
- params.push(...conditions.targetAppVersionIn);
153
- }
152
+ clauses.push(
153
+ buildJsonEachInClause(
154
+ "target_app_version",
155
+ conditions.targetAppVersionIn,
156
+ params,
157
+ ),
158
+ );
154
159
  }
155
160
 
156
161
  if (conditions.fingerprintHash !== undefined) {
@@ -275,18 +280,17 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
275
280
  return patchMap;
276
281
  }
277
282
 
278
- const placeholders = bundleIds.map(() => "?").join(", ");
279
283
  const sql = minify(`
280
284
  SELECT *
281
285
  FROM bundle_patches
282
- WHERE bundle_id IN (${placeholders})
286
+ WHERE bundle_id IN (SELECT value FROM json_each(?))
283
287
  ORDER BY order_index ASC, base_bundle_id ASC
284
288
  `);
285
289
 
286
290
  const result = await cf.d1.database.query(config.databaseId, {
287
291
  account_id: config.accountId,
288
292
  sql,
289
- params: bundleIds,
293
+ params: [JSON.stringify(bundleIds)],
290
294
  });
291
295
  const rows = await resolvePage<D1BundlePatchRow>(result);
292
296
 
@@ -1 +1 @@
1
- This folder contains the built output assets for the worker "hot-updater" generated at 2026-05-21T13:59:33.325Z.
1
+ This folder contains the built output assets for the worker "hot-updater" generated at 2026-06-12T18:34:53.149Z.