@hot-updater/cloudflare 1.0.0-rc.5 → 1.0.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 (51) hide show
  1. package/dist/index.cjs +90 -924
  2. package/dist/index.d.cts +16 -6
  3. package/dist/index.d.mts +16 -6
  4. package/dist/index.mjs +91 -926
  5. package/dist/init/index.cjs +11777 -0
  6. package/dist/init/index.d.cts +5 -1
  7. package/dist/init/index.d.mts +5 -1
  8. package/dist/init/index.mjs +11771 -1
  9. package/dist/worker/index.cjs +117 -916
  10. package/dist/worker/index.d.cts +45 -11
  11. package/dist/worker/index.d.mts +45 -11
  12. package/dist/worker/index.mjs +118 -917
  13. package/package.json +9 -14
  14. package/sql/bundles.sql +121 -212
  15. package/src/d1Database.ts +58 -43
  16. package/src/d1Executor.ts +84 -0
  17. package/src/d1Schema.ts +26 -0
  18. package/src/presignR2Url.ts +103 -0
  19. package/src/r2S3Storage.ts +20 -19
  20. package/src/r2Storage.ts +5 -3
  21. package/src/r2WorkerStorage.ts +55 -17
  22. package/src/worker/d1Database.ts +21 -39
  23. package/worker/dist/README.md +1 -1
  24. package/worker/dist/index.js +9859 -5696
  25. package/worker/dist/index.js.map +4 -4
  26. package/worker/migrations/0001_hot-updater_1.0.0.sql +121 -222
  27. package/worker/src/index.ts +15 -9
  28. package/worker/wrangler.json +1 -0
  29. package/dist/iac/index.cjs +0 -12687
  30. package/dist/iac/index.d.cts +0 -5
  31. package/dist/iac/index.d.mts +0 -5
  32. package/dist/iac/index.mjs +0 -12679
  33. package/src/d1Database.spec.ts +0 -277
  34. package/src/d1Implementation.spec.ts +0 -342
  35. package/src/d1Implementation.ts +0 -781
  36. package/src/d1Query.spec.ts +0 -46
  37. package/src/d1Query.ts +0 -92
  38. package/src/d1Rows.spec.ts +0 -143
  39. package/src/d1Rows.ts +0 -357
  40. package/src/d1Sql.spec.ts +0 -30
  41. package/src/d1Sql.ts +0 -175
  42. package/src/r2Storage.spec.ts +0 -346
  43. package/src/r2WorkerStorage.spec.ts +0 -184
  44. package/src/utils/createWrangler.spec.ts +0 -75
  45. package/src/worker/d1Database.spec.ts +0 -69
  46. package/worker/src/databaseMigration.integration.spec.ts +0 -191
  47. package/worker/src/databasePlugins.integration.spec.ts +0 -326
  48. package/worker/src/index.integration.spec.ts +0 -190
  49. package/worker/src/insightsMovement.integration.spec.ts +0 -73
  50. package/worker/src/storagePlugin.integration.spec.ts +0 -59
  51. package/worker/src/testWorker.ts +0 -5
@@ -1,190 +0,0 @@
1
- import {
2
- type Bundle,
3
- createReleaseCatalogScopeKey,
4
- encodeChannelKey,
5
- } from "@hot-updater/core";
6
- import {
7
- commitReleaseCatalogMutations,
8
- createUUIDv7,
9
- } from "@hot-updater/plugin-core";
10
- import { createHotUpdater, registerApiKey } from "@hot-updater/server";
11
- import { env } from "cloudflare:test";
12
- import { beforeAll, beforeEach, describe, expect, inject, it } from "vitest";
13
-
14
- import { d1Database } from "../../src/worker";
15
- import worker, { HOT_UPDATER_BASE_PATH } from "./index";
16
-
17
- declare module "vitest" {
18
- export interface ProvidedContext {
19
- prepareSql: string;
20
- }
21
- }
22
-
23
- declare module "cloudflare:test" {
24
- interface ProvidedEnv {
25
- DB: D1Database;
26
- BUCKET: R2Bucket;
27
- BUCKET_NAME: string;
28
- STORAGE_DOWNLOAD_URL_SIGNING_KEY: string;
29
- }
30
- }
31
-
32
- const PUBLIC_BASE_URL = "https://updates.example.com";
33
- const API_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
34
-
35
- const toRuntimeBundle = (bundle: Bundle): Bundle => {
36
- return {
37
- ...bundle,
38
- storageUri: `r2://${env.BUCKET_NAME}/${bundle.id}/bundle.zip`,
39
- };
40
- };
41
-
42
- const seedBundles = async (bundles: Bundle[]) => {
43
- const database = d1Database(env.DB);
44
- const seedHotUpdater = createHotUpdater({
45
- database,
46
- clientAccess: { type: "public" },
47
- });
48
- for (const bundle of bundles.map(toRuntimeBundle)) {
49
- const existing = await seedHotUpdater.getBundleById(bundle.id);
50
- if (existing === null) {
51
- await seedHotUpdater.insertBundle(bundle);
52
- } else {
53
- await seedHotUpdater.updateBundleById(bundle.id, bundle);
54
- }
55
- const channelName = "production";
56
- const channelKey = encodeChannelKey(channelName);
57
- const channel = (
58
- await database.models.channels.insert({
59
- row: { id: `channel:${channelKey}`, name: channelName },
60
- onConflict: "returnExisting",
61
- })
62
- ).row;
63
- const scopeKey = createReleaseCatalogScopeKey({
64
- channelKey,
65
- platform: bundle.platform,
66
- strategy: "APP_VERSION",
67
- });
68
- const now = Date.now();
69
- const releaseId = createUUIDv7();
70
- await commitReleaseCatalogMutations({
71
- database,
72
- mutations: [
73
- {
74
- mutation: {
75
- operation: "insert",
76
- row: {
77
- bundle_id: bundle.id,
78
- channel_id: channel.id,
79
- created_at_ms: now,
80
- enabled: true,
81
- fingerprint_hash: null,
82
- id: releaseId,
83
- kind: "BUNDLE",
84
- message: "hello",
85
- operation: "DEPLOY",
86
- platform: bundle.platform,
87
- revision: 1,
88
- rollout_cohort_count: 1_000,
89
- scope_key: scopeKey,
90
- should_force_update: false,
91
- source_release_id: null,
92
- strategy: "APP_VERSION",
93
- target_app_version: "1.0",
94
- target_cohorts: [],
95
- updated_at_ms: now,
96
- },
97
- },
98
- scope: {
99
- channelId: channel.id,
100
- channelName,
101
- fingerprintHash: null,
102
- platform: bundle.platform,
103
- scopeKey,
104
- strategy: "APP_VERSION",
105
- },
106
- updatedAtMs: now,
107
- },
108
- ],
109
- });
110
- }
111
- };
112
-
113
- describe.sequential("cloudflare worker runtime acceptance", () => {
114
- beforeAll(async () => {
115
- await env.DB.prepare(inject("prepareSql")).run();
116
- const database = d1Database(env.DB);
117
- await registerApiKey({
118
- apiKey: API_KEY,
119
- apiKeys: database.models.apiKeys,
120
- name: "Runtime acceptance",
121
- });
122
- });
123
-
124
- beforeEach(async () => {
125
- await env.DB.prepare("DELETE FROM bundle_patches").run();
126
- await env.DB.prepare("DELETE FROM release_catalogs").run();
127
- await env.DB.prepare("DELETE FROM releases").run();
128
- await env.DB.prepare("DELETE FROM bundles").run();
129
- await env.DB.prepare("DELETE FROM channels").run();
130
- });
131
-
132
- it("serves unversioned Release Catalog routes from the worker entrypoint", async () => {
133
- expect(HOT_UPDATER_BASE_PATH).toBe("/");
134
- await seedBundles([
135
- {
136
- id: "00000000-0000-0000-0000-000000000001",
137
- platform: "ios",
138
- fileHash: "hash",
139
- gitCommitHash: null,
140
- storageUri: "storage://unused",
141
- archiveByteSize: 3_000_000_001,
142
- },
143
- ]);
144
-
145
- const unauthorized = await worker.fetch(
146
- new Request(
147
- `${PUBLIC_BASE_URL}/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.0.0`,
148
- ),
149
- env,
150
- );
151
- expect(unauthorized.status).toBe(401);
152
-
153
- const response = await worker.fetch(
154
- new Request(
155
- `${PUBLIC_BASE_URL}/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.0.0`,
156
- { headers: { "x-api-key": API_KEY } },
157
- ),
158
- env,
159
- );
160
-
161
- await expect(response.json()).resolves.toMatchObject({
162
- catalogId: expect.stringMatching(/^[0-9a-f-]{36}$/),
163
- releases: [{ bundleId: "00000000-0000-0000-0000-000000000001" }],
164
- });
165
- });
166
-
167
- it("does not support the legacy exact path", async () => {
168
- const response = await worker.fetch(
169
- new Request(`${PUBLIC_BASE_URL}/api/check-update`),
170
- env,
171
- );
172
-
173
- expect(response.status).toBe(404);
174
- await expect(response.json()).resolves.toEqual({
175
- error: "Not found",
176
- });
177
- });
178
-
179
- it("does not expose management routes from the worker entrypoint", async () => {
180
- const response = await worker.fetch(
181
- new Request(`${PUBLIC_BASE_URL}/admin/bundles`),
182
- env,
183
- );
184
-
185
- expect(response.status).toBe(404);
186
- await expect(response.json()).resolves.toEqual({
187
- error: "Not found",
188
- });
189
- });
190
- });
@@ -1,73 +0,0 @@
1
- import { env } from "cloudflare:test";
2
- import { expect, inject, it } from "vitest";
3
-
4
- import { d1Database } from "../../src/worker";
5
-
6
- it("pages sparse movements beyond 50,000 reports through ordered native index ranges", async () => {
7
- await env.DB.prepare(inject("prepareSql")).run();
8
- await env.DB.prepare(`
9
- WITH RECURSIVE reports(n) AS (
10
- SELECT 1 UNION ALL SELECT n + 1 FROM reports WHERE n < 60000
11
- )
12
- INSERT INTO bundle_events (
13
- id, type, install_id, from_bundle_id, to_bundle_id, platform,
14
- app_version, channel, cohort, update_strategy, received_at_ms
15
- )
16
- SELECT '00000000-0000-7000-8000-' || printf('%012d', n),
17
- CASE n % 800 WHEN 0 THEN 'UPDATE_APPLIED' WHEN 1 THEN 'RECOVERED' ELSE 'UNCHANGED' END,
18
- 'sparse-movement-install',
19
- CASE WHEN n % 800 IN (0, 1) THEN '00000000-0000-7000-8000-000000001001' ELSE NULL END,
20
- '00000000-0000-7000-8000-000000001002', 'ios', '1.0.0', 'production', '0',
21
- CASE WHEN n % 800 IN (0, 1) THEN 'appVersion' ELSE NULL END,
22
- n / 2
23
- FROM reports
24
- `).run();
25
-
26
- const queries: { sql: string; params: readonly unknown[] }[] = [];
27
- const plugin = d1Database({
28
- prepare(sql) {
29
- return {
30
- bind(...params) {
31
- queries.push({ sql, params });
32
- return env.DB.prepare(sql).bind(...params);
33
- },
34
- };
35
- },
36
- batch: () =>
37
- Promise.reject(new Error("Unexpected write in movement query")),
38
- });
39
- const input = {
40
- filter: {
41
- kind: "installationMovement",
42
- installId: "sparse-movement-install",
43
- },
44
- beforeReceivedAtMs: 30001,
45
- limit: 101,
46
- } as const;
47
- const first = await plugin.models.insights.listEvents(input);
48
- expect(first).toHaveLength(101);
49
- const last = first[first.length - 1]!;
50
- const second = await plugin.models.insights.listEvents({
51
- ...input,
52
- after: { receivedAtMs: last.received_at_ms, id: last.id },
53
- });
54
- expect(second).toHaveLength(49);
55
- const expected = Array.from({ length: 60000 }, (_, index) => index + 1)
56
- .filter((index) => index % 800 === 0 || index % 800 === 1)
57
- .reverse()
58
- .map(
59
- (index) => `00000000-0000-7000-8000-${String(index).padStart(12, "0")}`,
60
- );
61
- expect([...first, ...second].map(({ id }) => id)).toEqual(expected);
62
- expect(queries.length).toBeLessThanOrEqual(6);
63
- for (const { sql, params } of queries) {
64
- expect(sql).toContain("LIMIT");
65
- expect(params[params.length - 1]).toBe("0");
66
- const plan = await env.DB.prepare(`EXPLAIN QUERY PLAN ${sql}`)
67
- .bind(...params)
68
- .all<{ detail: string }>();
69
- const details = plan.results.map(({ detail }) => detail).join("\n");
70
- expect(details).toContain("bundle_events_install_idx");
71
- expect(details).not.toContain("TEMP B-TREE");
72
- }
73
- });
@@ -1,59 +0,0 @@
1
- import { env } from "cloudflare:test";
2
- import { afterEach, describe, expect, it } from "vitest";
3
-
4
- import { r2Storage } from "../../src/worker";
5
-
6
- const UNKNOWN_LENGTH_KEY = "storage-tests/unknown-length.txt";
7
- const KNOWN_LENGTH_KEY = "storage-tests/known-length.txt";
8
-
9
- const createChunkedBody = (...chunks: string[]) =>
10
- new ReadableStream<Uint8Array>({
11
- start(controller) {
12
- for (const chunk of chunks) {
13
- controller.enqueue(new TextEncoder().encode(chunk));
14
- }
15
- controller.close();
16
- },
17
- });
18
-
19
- const createStorage = () =>
20
- r2Storage({
21
- bucket: env.BUCKET,
22
- bucketName: env.BUCKET_NAME,
23
- downloadUrlSigningKey: env.STORAGE_DOWNLOAD_URL_SIGNING_KEY,
24
- });
25
-
26
- afterEach(async () => {
27
- await env.BUCKET.delete([UNKNOWN_LENGTH_KEY, KNOWN_LENGTH_KEY]);
28
- });
29
-
30
- describe("Cloudflare Worker R2 storage uploads", () => {
31
- it("writes exact bytes from an unknown-length stream", async () => {
32
- const storage = createStorage();
33
-
34
- await storage.put({
35
- key: UNKNOWN_LENGTH_KEY,
36
- body: createChunkedBody("unknown", "-length"),
37
- contentType: "text/plain",
38
- });
39
-
40
- await expect(
41
- env.BUCKET.get(UNKNOWN_LENGTH_KEY).then((item) => item?.text()),
42
- ).resolves.toBe("unknown-length");
43
- });
44
-
45
- it("streams known-length bytes through FixedLengthStream", async () => {
46
- const storage = createStorage();
47
-
48
- await storage.put({
49
- key: KNOWN_LENGTH_KEY,
50
- body: createChunkedBody("known", "-length"),
51
- contentLength: 12,
52
- contentType: "text/plain",
53
- });
54
-
55
- await expect(
56
- env.BUCKET.get(KNOWN_LENGTH_KEY).then((item) => item?.text()),
57
- ).resolves.toBe("known-length");
58
- });
59
- });
@@ -1,5 +0,0 @@
1
- export default {
2
- fetch() {
3
- return new Response("ok");
4
- },
5
- };