@hot-updater/cloudflare 0.36.6 → 1.0.0-rc.0

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.
Files changed (49) hide show
  1. package/dist/iac/index.cjs +989 -11
  2. package/dist/iac/index.mjs +992 -15
  3. package/dist/index.cjs +919 -6989
  4. package/dist/index.d.cts +11 -47
  5. package/dist/index.d.mts +11 -47
  6. package/dist/index.mjs +917 -7006
  7. package/dist/worker/index.cjs +898 -336
  8. package/dist/worker/index.d.cts +18 -33
  9. package/dist/worker/index.d.mts +18 -33
  10. package/dist/worker/index.mjs +899 -331
  11. package/package.json +7 -10
  12. package/sql/bundles.sql +183 -35
  13. package/src/d1Database.spec.ts +143 -392
  14. package/src/d1Database.ts +55 -650
  15. package/src/d1Implementation.spec.ts +252 -0
  16. package/src/d1Implementation.ts +742 -0
  17. package/src/d1Query.spec.ts +46 -0
  18. package/src/d1Query.ts +89 -0
  19. package/src/d1Rows.spec.ts +111 -0
  20. package/src/d1Rows.ts +326 -0
  21. package/src/d1Sql.spec.ts +30 -0
  22. package/src/d1Sql.ts +175 -0
  23. package/src/r2S3Storage.ts +93 -137
  24. package/src/r2Storage.spec.ts +120 -218
  25. package/src/r2Storage.ts +13 -61
  26. package/src/r2WorkerStorage.spec.ts +161 -50
  27. package/src/r2WorkerStorage.ts +85 -118
  28. package/src/worker/d1Database.spec.ts +33 -0
  29. package/src/worker/d1Database.ts +52 -0
  30. package/src/worker/index.ts +3 -31
  31. package/worker/dist/README.md +1 -1
  32. package/worker/dist/index.js +5968 -4701
  33. package/worker/dist/index.js.map +4 -4
  34. package/worker/migrations/0001_hot-updater_1.0.0.sql +197 -0
  35. package/worker/migrations/0002_hot-updater_artifact_byte_sizes.sql +11 -0
  36. package/worker/src/databaseMigration.integration.spec.ts +114 -0
  37. package/worker/src/databasePlugins.integration.spec.ts +292 -0
  38. package/worker/src/index.integration.spec.ts +102 -294
  39. package/worker/src/index.ts +14 -66
  40. package/worker/src/storagePlugin.integration.spec.ts +59 -0
  41. package/worker/wrangler.json +5 -2
  42. package/src/cloudflareWorkerDatabase.spec.ts +0 -260
  43. package/src/cloudflareWorkerDatabase.ts +0 -651
  44. package/src/r2WranglerStorage.ts +0 -193
  45. package/worker/migrations/0001_hot-updater_init.sql +0 -16
  46. package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
  47. package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
  48. package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
  49. package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
@@ -1,427 +1,178 @@
1
- import type { DatabasePlugin } from "@hot-updater/plugin-core";
2
- import {
3
- setupBundleMethodsTestSuite,
4
- setupGetUpdateInfoTestSuite,
5
- } from "@hot-updater/test-utils";
6
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, expect, it, vi } from "vitest";
7
2
 
8
3
  import { d1Database } from "./d1Database";
9
4
 
10
- type D1Row = {
11
- id: string;
12
- channel: string;
13
- enabled: number | boolean;
14
- should_force_update: number | boolean;
15
- file_hash: string;
16
- git_commit_hash: string | null;
17
- message: string | null;
18
- platform: "ios" | "android";
19
- target_app_version: string | null;
20
- storage_uri: string;
21
- fingerprint_hash: string | null;
22
- metadata: string;
23
- manifest_storage_uri?: string | null;
24
- manifest_file_hash?: string | null;
25
- asset_base_storage_uri?: string | null;
26
- rollout_cohort_count: number | null;
27
- target_cohorts: string | null;
5
+ type RecordedQuery = {
6
+ readonly sql: string;
7
+ readonly params: readonly string[];
28
8
  };
29
9
 
30
- type D1PatchRow = {
31
- id: string;
32
- bundle_id: string;
33
- base_bundle_id: string;
34
- base_file_hash: string;
35
- patch_file_hash: string;
36
- patch_storage_uri: string;
37
- order_index: number | null;
38
- };
39
-
40
- const { rows, patchRows } = vi.hoisted(() => ({
41
- rows: new Map<string, D1Row>(),
42
- patchRows: new Map<string, D1PatchRow>(),
43
- }));
44
-
45
- vi.mock("pg-minify", () => ({
46
- default: (sql: string) => sql.replace(/\s+/g, " ").trim(),
47
- }));
48
-
49
- const createPage = <T>(results: T[]) => ({
50
- async *iterPages() {
51
- yield {
52
- result: [{ results }],
53
- };
54
- },
55
- });
56
-
57
- const getFilteredRows = (sql: string, params: any[]) => {
58
- let filteredRows = Array.from(rows.values());
59
- let index = 0;
60
-
61
- const consumeInValues = (pattern: RegExp) => {
62
- const match = sql.match(pattern);
63
- if (!match) {
64
- return null;
65
- }
66
-
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;
74
- const values = params.slice(index, index + count);
75
- index += count;
76
- return values;
77
- };
78
-
79
- if (sql.includes("channel = ?")) {
80
- const channel = params[index++];
81
- filteredRows = filteredRows.filter((row) => row.channel === channel);
82
- }
83
-
84
- if (sql.includes("platform = ?")) {
85
- const platform = params[index++];
86
- filteredRows = filteredRows.filter((row) => row.platform === platform);
87
- }
88
-
89
- if (sql.includes("enabled = ?")) {
90
- const enabled = Number(params[index++]);
91
- filteredRows = filteredRows.filter(
92
- (row) => Number(row.enabled) === enabled,
93
- );
94
- }
95
-
96
- const idInValues = consumeInValues(/id IN \(([^)]+)\)/);
97
- if (idInValues) {
98
- filteredRows = filteredRows.filter((row) => idInValues.includes(row.id));
99
- }
100
-
101
- if (sql.includes("id = ?")) {
102
- const id = params[index++];
103
- filteredRows = filteredRows.filter((row) => row.id === id);
104
- }
105
-
106
- if (sql.includes("id > ?")) {
107
- const id = params[index++];
108
- filteredRows = filteredRows.filter((row) => row.id.localeCompare(id) > 0);
109
- }
110
-
111
- if (sql.includes("id >= ?")) {
112
- const id = params[index++];
113
- filteredRows = filteredRows.filter((row) => row.id.localeCompare(id) >= 0);
114
- }
115
-
116
- if (sql.includes("id < ?")) {
117
- const id = params[index++];
118
- filteredRows = filteredRows.filter((row) => row.id.localeCompare(id) < 0);
119
- }
120
-
121
- if (sql.includes("id <= ?")) {
122
- const id = params[index++];
123
- filteredRows = filteredRows.filter((row) => row.id.localeCompare(id) <= 0);
124
- }
125
-
126
- if (sql.includes("target_app_version IS NOT NULL")) {
127
- filteredRows = filteredRows.filter(
128
- (row) => row.target_app_version !== null,
129
- );
130
- }
131
-
132
- if (sql.includes("target_app_version IS NULL")) {
133
- filteredRows = filteredRows.filter(
134
- (row) => row.target_app_version === null,
135
- );
136
- } else if (sql.includes("target_app_version = ?")) {
137
- const targetAppVersion = params[index++];
138
- filteredRows = filteredRows.filter(
139
- (row) => row.target_app_version === targetAppVersion,
140
- );
141
- }
142
-
143
- const targetAppVersionInValues = consumeInValues(
144
- /target_app_version IN \(([^)]+)\)/,
145
- );
146
- if (targetAppVersionInValues) {
147
- filteredRows = filteredRows.filter((row) =>
148
- targetAppVersionInValues.includes(row.target_app_version),
149
- );
150
- }
151
-
152
- if (sql.includes("fingerprint_hash IS NULL")) {
153
- filteredRows = filteredRows.filter((row) => row.fingerprint_hash === null);
154
- } else if (sql.includes("fingerprint_hash = ?")) {
155
- const fingerprintHash = params[index++];
156
- filteredRows = filteredRows.filter(
157
- (row) => row.fingerprint_hash === fingerprintHash,
158
- );
159
- }
160
-
161
- return { filteredRows, index };
162
- };
10
+ const state = vi.hoisted<{
11
+ batches: RecordedQuery[][];
12
+ queries: RecordedQuery[];
13
+ results: unknown[];
14
+ }>(() => ({ batches: [], queries: [], results: [] }));
15
+
16
+ const bundleD1Row = {
17
+ id: "bundle-1",
18
+ platform: "ios",
19
+ file_hash: "hash",
20
+ git_commit_hash: null,
21
+ storage_uri: "storage://bundle",
22
+ archive_byte_size: 3_000_000_001,
23
+ metadata: '{"version":1}',
24
+ manifest_storage_uri: null,
25
+ manifest_file_hash: null,
26
+ asset_base_storage_uri: null,
27
+ } as const;
163
28
 
164
29
  vi.mock("cloudflare", () => ({
165
30
  default: class MockCloudflare {
166
- d1 = {
31
+ readonly d1 = {
167
32
  database: {
168
33
  query: async (
169
34
  _databaseId: string,
170
- {
171
- sql,
172
- params = [],
173
- }: {
174
- sql: string;
175
- params?: any[];
176
- },
35
+ input:
36
+ | { readonly sql: string; readonly params?: readonly string[] }
37
+ | {
38
+ readonly batch: readonly {
39
+ readonly sql: string;
40
+ readonly params?: readonly string[];
41
+ }[];
42
+ },
177
43
  ) => {
178
- if (params.length > 100) {
179
- throw new Error(
180
- "D1_ERROR: too many SQL variables at offset 386: SQLITE_ERROR",
44
+ if ("batch" in input) {
45
+ state.batches.push(
46
+ input.batch.map(({ sql, params }) => ({
47
+ sql,
48
+ params: params ?? [],
49
+ })),
181
50
  );
51
+ } else {
52
+ state.queries.push({
53
+ sql: input.sql,
54
+ params: input.params ?? [],
55
+ });
182
56
  }
183
-
184
- const normalizedSql = sql.replace(/\s+/g, " ").trim().toLowerCase();
185
-
186
- if (
187
- normalizedSql.startsWith("select count(*) as total from bundles")
188
- ) {
189
- const { filteredRows } = getFilteredRows(sql, params);
190
- return createPage([{ total: filteredRows.length }]);
191
- }
192
-
193
- if (normalizedSql.startsWith("select * from bundles where id = ?")) {
194
- const bundleId = params[0];
195
- const row = rows.get(bundleId);
196
- return createPage(row ? [row] : []);
197
- }
198
-
199
- if (
200
- normalizedSql.startsWith(
201
- "select * from bundle_patches where bundle_id in",
202
- )
203
- ) {
204
- const selectedBundleIds = new Set(
205
- normalizedSql.includes("json_each")
206
- ? (JSON.parse(String(params[0])) as unknown[]).map(String)
207
- : params.map((value) => String(value)),
208
- );
209
- const result = Array.from(patchRows.values())
210
- .filter((row) => selectedBundleIds.has(row.bundle_id))
211
- .sort(
212
- (a, b) =>
213
- Number(a.order_index ?? 0) - Number(b.order_index ?? 0) ||
214
- a.base_bundle_id.localeCompare(b.base_bundle_id),
215
- );
216
-
217
- return createPage(result);
218
- }
219
-
220
- if (normalizedSql.startsWith("select * from bundles")) {
221
- const { filteredRows, index } = getFilteredRows(sql, params);
222
- const limit = Number(params[index] ?? filteredRows.length);
223
- const offset = Number(params[index + 1] ?? 0);
224
- const result = filteredRows
225
- .sort((a, b) => b.id.localeCompare(a.id))
226
- .slice(offset, offset + limit);
227
-
228
- return createPage(result);
229
- }
230
-
231
- if (
232
- normalizedSql.startsWith("select target_app_version from bundles")
233
- ) {
234
- const { filteredRows } = getFilteredRows(sql, params);
235
- const result = Array.from(
236
- new Set(
237
- filteredRows
238
- .map((row) => row.target_app_version)
239
- .filter((version): version is string => Boolean(version)),
240
- ),
241
- ).map((targetAppVersion) => ({
242
- target_app_version: targetAppVersion,
243
- }));
244
-
245
- return createPage(result);
246
- }
247
-
248
- if (
249
- normalizedSql.startsWith(
250
- "select channel from bundles group by channel",
251
- )
252
- ) {
253
- const channels = Array.from(
254
- new Set(Array.from(rows.values()).map((row) => row.channel)),
255
- ).map((channel) => ({ channel }));
256
- return createPage(channels);
257
- }
258
-
259
- if (normalizedSql.startsWith("delete from bundles where id = ?")) {
260
- rows.delete(params[0]);
261
- return createPage([]);
262
- }
263
-
264
- if (
265
- normalizedSql.startsWith(
266
- "delete from bundle_patches where bundle_id = ?",
267
- )
268
- ) {
269
- const bundleId = String(params[0]);
270
- for (const [id, row] of patchRows.entries()) {
271
- if (row.bundle_id === bundleId) {
272
- patchRows.delete(id);
273
- }
274
- }
275
- return createPage([]);
276
- }
277
-
278
- if (
279
- normalizedSql.startsWith(
280
- "delete from bundle_patches where base_bundle_id = ?",
281
- )
282
- ) {
283
- const baseBundleId = String(params[0]);
284
- for (const [id, row] of patchRows.entries()) {
285
- if (row.base_bundle_id === baseBundleId) {
286
- patchRows.delete(id);
287
- }
288
- }
289
- return createPage([]);
290
- }
291
-
292
- if (normalizedSql.startsWith("insert or replace into bundles")) {
293
- const row: D1Row = {
294
- id: params[0],
295
- channel: params[1],
296
- enabled: params[2],
297
- should_force_update: params[3],
298
- file_hash: params[4],
299
- git_commit_hash: params[5],
300
- message: params[6],
301
- platform: params[7],
302
- target_app_version: params[8],
303
- storage_uri: params[9],
304
- fingerprint_hash: params[10],
305
- metadata: params[11],
306
- manifest_storage_uri: params[12],
307
- manifest_file_hash: params[13],
308
- asset_base_storage_uri: params[14],
309
- rollout_cohort_count: params[15],
310
- target_cohorts: params[16],
311
- };
312
- rows.set(row.id, row);
313
- return createPage([]);
314
- }
315
-
316
- if (
317
- normalizedSql.startsWith("insert or replace into bundle_patches")
318
- ) {
319
- const row: D1PatchRow = {
320
- id: params[0],
321
- bundle_id: params[1],
322
- base_bundle_id: params[2],
323
- base_file_hash: params[3],
324
- patch_file_hash: params[4],
325
- patch_storage_uri: params[5],
326
- order_index: Number(params[6] ?? 0),
327
- };
328
- patchRows.set(row.id, row);
329
- return createPage([]);
330
- }
331
-
332
- throw new Error(`Unsupported SQL in D1 mock: ${sql}`);
57
+ return {
58
+ async *iterPages() {
59
+ yield {
60
+ result:
61
+ "batch" in input
62
+ ? input.batch.map(() => ({ results: [] }))
63
+ : [{ results: state.results }],
64
+ };
65
+ },
66
+ };
333
67
  },
334
68
  },
335
69
  };
336
70
  },
337
71
  }));
338
72
 
339
- describe("d1Database plugin", () => {
340
- let plugin: DatabasePlugin;
73
+ beforeEach(() => {
74
+ state.batches.length = 0;
75
+ state.queries.length = 0;
76
+ state.results.length = 0;
77
+ });
341
78
 
342
- beforeEach(() => {
343
- rows.clear();
344
- patchRows.clear();
345
- plugin = d1Database({
346
- databaseId: "test-db-id",
347
- accountId: "test-account-id",
348
- cloudflareApiToken: "test-token",
349
- })();
79
+ it("queries bundles through domain filters", async () => {
80
+ state.results.push(bundleD1Row);
81
+ const plugin = d1Database({
82
+ accountId: "account",
83
+ cloudflareApiToken: "token",
84
+ databaseId: "database",
350
85
  });
351
86
 
352
- setupBundleMethodsTestSuite({
353
- getBundleById: (id) => plugin.getBundleById(id),
354
- getChannels: () => plugin.getChannels(),
355
- insertBundle: async (bundle) => {
356
- await plugin.appendBundle(bundle);
357
- await plugin.commitBundle();
358
- },
359
- getBundles: (options) => plugin.getBundles(options),
360
- updateBundleById: async (bundleId, newBundle) => {
361
- await plugin.updateBundle(bundleId, newBundle);
362
- await plugin.commitBundle();
363
- },
364
- deleteBundleById: async (bundleId) => {
365
- const bundle = await plugin.getBundleById(bundleId);
366
- if (!bundle) {
367
- return;
368
- }
369
- await plugin.deleteBundle(bundle);
370
- await plugin.commitBundle();
371
- },
87
+ const rows = await plugin.models.bundles.findMany({
88
+ where: { platform: "ios", id: { gte: "bundle-1" } },
89
+ orderBy: { field: "id", direction: "desc" },
90
+ limit: 10,
91
+ offset: 0,
372
92
  });
373
93
 
374
- setupGetUpdateInfoTestSuite({
375
- getUpdateInfo: async (bundles, args) => {
376
- rows.clear();
377
- patchRows.clear();
94
+ expect(rows).toEqual([
95
+ {
96
+ ...bundleD1Row,
97
+ metadata: { version: 1 },
98
+ },
99
+ ]);
100
+ expect(state.queries[0]?.sql).toContain("platform = json_extract(?, '$')");
101
+ expect(state.queries[0]?.sql).toContain("id >= json_extract(?, '$')");
102
+ expect(state.queries[0]?.sql).toContain("ORDER BY id DESC");
103
+ });
378
104
 
379
- for (const bundle of bundles) {
380
- await plugin.appendBundle(bundle);
381
- }
382
- await plugin.commitBundle();
105
+ it("lists normalized channels without scanning bundles", async () => {
106
+ state.results.push({ id: "channel-production", name: "production" });
107
+ const plugin = d1Database({
108
+ accountId: "account",
109
+ cloudflareApiToken: "token",
110
+ databaseId: "database",
111
+ });
383
112
 
384
- return plugin.getUpdateInfo?.(args) ?? null;
385
- },
113
+ await expect(plugin.models.channels.list({})).resolves.toEqual({
114
+ channels: [{ id: "channel-production", name: "production" }],
386
115
  });
387
116
 
388
- it("refreshes bundle data before merging an update after a previous list request", async () => {
389
- const bundleId = "bundle-stale-cache";
390
- const initialRow: D1Row = {
391
- id: bundleId,
392
- channel: "production",
393
- enabled: 1,
394
- should_force_update: 0,
395
- file_hash: "hash-1",
396
- git_commit_hash: "commit-1",
397
- message: "stale message",
398
- platform: "ios",
399
- target_app_version: "1.0.0",
400
- storage_uri: "s3://bucket/stale.zip",
401
- fingerprint_hash: null,
402
- metadata: JSON.stringify({ source: "initial" }),
403
- rollout_cohort_count: 1000,
404
- target_cohorts: null,
405
- };
406
- rows.set(bundleId, initialRow);
117
+ expect(state.queries[0]?.params).toEqual(["100", "0"]);
118
+ expect(state.queries[0]?.sql).toBe(
119
+ "SELECT * FROM channels ORDER BY name ASC LIMIT json_extract(?, '$') OFFSET json_extract(?, '$')",
120
+ );
121
+ expect(state.queries[0]?.sql).not.toContain("bundles");
122
+ });
407
123
 
408
- await plugin.getBundles({ limit: 20 });
124
+ it("counts domain-filtered bundle rows in SQL", async () => {
125
+ state.results.push({ count: 3 });
126
+ const plugin = d1Database({
127
+ accountId: "account",
128
+ cloudflareApiToken: "token",
129
+ databaseId: "database",
130
+ });
409
131
 
410
- rows.set(bundleId, {
411
- ...initialRow,
412
- message: "fresh message",
413
- metadata: JSON.stringify({ source: "fresh" }),
414
- });
132
+ await plugin.models.bundles.count({
133
+ platform: "ios",
134
+ });
415
135
 
416
- await plugin.updateBundle(bundleId, { enabled: false });
417
- await plugin.commitBundle();
136
+ expect(state.queries[0]?.sql).toContain(
137
+ "SELECT COUNT(*) AS count FROM bundles",
138
+ );
139
+ expect(state.queries[0]?.sql).toContain("platform = json_extract(?, '$')");
140
+ });
418
141
 
419
- expect(rows.get(bundleId)).toEqual(
420
- expect.objectContaining({
421
- enabled: 0,
422
- message: "fresh message",
423
- metadata: JSON.stringify({ source: "fresh" }),
424
- }),
425
- );
142
+ it("sends parameterized commits through the D1 batch body", async () => {
143
+ const plugin = d1Database({
144
+ accountId: "account",
145
+ cloudflareApiToken: "token",
146
+ databaseId: "database",
426
147
  });
148
+
149
+ await expect(
150
+ plugin.commit({
151
+ changes: [
152
+ {
153
+ model: "channels",
154
+ operation: "insert",
155
+ row: {
156
+ id: "00000000-0000-0000-0000-000000000001",
157
+ name: "production",
158
+ },
159
+ onConflict: "ignore",
160
+ },
161
+ {
162
+ model: "channels",
163
+ operation: "insert",
164
+ row: {
165
+ id: "00000000-0000-0000-0000-000000000002",
166
+ name: "beta",
167
+ },
168
+ onConflict: "ignore",
169
+ },
170
+ ],
171
+ }),
172
+ ).resolves.toEqual({ committed: true });
173
+
174
+ expect(state.batches).toHaveLength(1);
175
+ expect(state.batches[0]).toHaveLength(2);
176
+ expect(state.batches[0]?.[0]?.params.length).toBeGreaterThan(0);
177
+ expect(state.batches[0]?.[1]?.params.length).toBeGreaterThan(0);
427
178
  });