@hot-updater/cloudflare 1.0.0-rc.18 → 1.0.0-rc.21

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 (41) hide show
  1. package/dist/index.cjs +7 -9
  2. package/dist/index.d.cts +323 -35
  3. package/dist/index.d.mts +323 -35
  4. package/dist/index.mjs +2 -4
  5. package/dist/init/index.cjs +11768 -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 +11761 -1
  9. package/dist/worker/index.cjs +4 -5
  10. package/dist/worker/index.d.cts +323 -35
  11. package/dist/worker/index.d.mts +323 -35
  12. package/dist/worker/index.mjs +2 -3
  13. package/package.json +8 -11
  14. package/src/d1Database.ts +5 -2
  15. package/src/d1Executor.ts +2 -2
  16. package/src/d1Schema.ts +3 -2
  17. package/src/r2S3Storage.ts +4 -4
  18. package/src/r2Storage.ts +2 -2
  19. package/src/r2WorkerStorage.ts +4 -4
  20. package/worker/dist/README.md +1 -1
  21. package/worker/dist/index.js +4804 -4720
  22. package/worker/dist/index.js.map +4 -4
  23. package/dist/iac/index.cjs +0 -11838
  24. package/dist/iac/index.d.cts +0 -5
  25. package/dist/iac/index.d.mts +0 -5
  26. package/dist/iac/index.mjs +0 -11830
  27. package/src/d1Database.spec.ts +0 -190
  28. package/src/d1Schema.spec.ts +0 -89
  29. package/src/d1TestDatabase.ts +0 -38
  30. package/src/r2Storage.spec.ts +0 -346
  31. package/src/r2WorkerStorage.spec.ts +0 -184
  32. package/src/utils/createWrangler.spec.ts +0 -75
  33. package/src/worker/d1Database.spec.ts +0 -59
  34. package/worker/src/d1.conformance.integration.spec.ts +0 -53
  35. package/worker/src/d1.readBudgets.integration.spec.ts +0 -85
  36. package/worker/src/databaseMigration.integration.spec.ts +0 -101
  37. package/worker/src/databasePlugins.integration.spec.ts +0 -318
  38. package/worker/src/index.integration.spec.ts +0 -182
  39. package/worker/src/readPlans.integration.spec.ts +0 -89
  40. package/worker/src/storagePlugin.integration.spec.ts +0 -59
  41. package/worker/src/testWorker.ts +0 -5
@@ -1,184 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
-
3
- import { r2WorkerStorage } from "./r2WorkerStorage";
4
-
5
- const createBucket = (
6
- get: ReturnType<typeof vi.fn>,
7
- overrides: Partial<R2Bucket> = {},
8
- ) =>
9
- ({
10
- get,
11
- head: vi.fn(),
12
- put: vi.fn(),
13
- delete: vi.fn(),
14
- list: vi.fn(),
15
- ...overrides,
16
- }) as unknown as R2Bucket;
17
-
18
- describe("r2WorkerStorage", () => {
19
- afterEach(() => {
20
- vi.unstubAllGlobals();
21
- });
22
-
23
- it("reads bytes from the R2 binding captured by the implementation", async () => {
24
- const get = vi.fn(async (key: string) => ({
25
- body: new Response(`text:${key}`).body,
26
- httpEtag: '"etag"',
27
- size: `text:${key}`.length,
28
- writeHttpMetadata: vi.fn(),
29
- }));
30
- const storage = r2WorkerStorage({
31
- bucket: createBucket(get),
32
- bucketName: "bundles",
33
- downloadUrlSigningKey: "test-signing-key",
34
- });
35
-
36
- const { response } = await storage.get({
37
- storageUri: "r2://bundles/app/manifest.json",
38
- });
39
-
40
- expect(await response?.text()).toBe("text:app/manifest.json");
41
- expect(get).toHaveBeenCalledWith("app/manifest.json");
42
- });
43
-
44
- it("rejects URIs owned by a different R2 binding", async () => {
45
- const get = vi.fn();
46
- const storage = r2WorkerStorage({
47
- bucket: createBucket(get),
48
- bucketName: "bundles",
49
- downloadUrlSigningKey: "test-signing-key",
50
- });
51
-
52
- await expect(
53
- storage.get({ storageUri: "r2://other/app/manifest.json" }),
54
- ).rejects.toThrow(
55
- 'Bucket name mismatch: expected "bundles", but found "other".',
56
- );
57
- expect(get).not.toHaveBeenCalled();
58
- });
59
-
60
- it("writes and deletes the exact object key through the R2 binding", async () => {
61
- let uploaded = "";
62
- const put = vi.fn(async (_key: string, body: Uint8Array) => {
63
- uploaded = new TextDecoder().decode(body);
64
- return null;
65
- });
66
- const bucket = createBucket(vi.fn(), {
67
- put: put as unknown as R2Bucket["put"],
68
- });
69
- const storage = r2WorkerStorage({
70
- bucket,
71
- bucketName: "bundles",
72
- downloadUrlSigningKey: "test-signing-key",
73
- });
74
-
75
- await storage.put({
76
- key: "releases/bundle.zip",
77
- body: new Response("bundle").body!,
78
- contentType: "application/zip",
79
- });
80
- await expect(
81
- storage.delete({
82
- storageUri: "r2://bundles/releases/bundle.zip",
83
- }),
84
- ).resolves.toEqual({
85
- deleted: true,
86
- });
87
-
88
- expect(bucket.put).toHaveBeenCalledWith(
89
- "releases/bundle.zip",
90
- expect.any(Uint8Array),
91
- {
92
- httpMetadata: {
93
- cacheControl: "max-age=31536000",
94
- contentType: "application/zip",
95
- },
96
- },
97
- );
98
- expect(uploaded).toBe("bundle");
99
- expect(bucket.delete).toHaveBeenCalledWith("releases/bundle.zip");
100
- });
101
-
102
- it("round-trips reserved characters without changing the R2 binding key", async () => {
103
- const bucket = createBucket(vi.fn());
104
- const storage = r2WorkerStorage({
105
- bucket,
106
- bucketName: "bundles",
107
- downloadUrlSigningKey: "test-signing-key",
108
- });
109
- const key = "릴리스 folder/logo@2x #100%/bundle.zip";
110
-
111
- const uploaded = await storage.put({
112
- key,
113
- body: new Response("bundle").body!,
114
- contentType: "application/zip",
115
- });
116
- expect(uploaded.storageUri).toContain("logo%402x");
117
- await storage.delete({ storageUri: uploaded.storageUri });
118
- await storage.delete({ storageUri: uploaded.storageUri });
119
-
120
- expect(bucket.put).toHaveBeenCalledWith(
121
- key,
122
- expect.any(Uint8Array),
123
- expect.any(Object),
124
- );
125
- expect(bucket.delete).toHaveBeenNthCalledWith(1, key);
126
- expect(bucket.delete).toHaveBeenNthCalledWith(2, key);
127
- });
128
-
129
- it("uses a fixed-length stream when the upload length is known", async () => {
130
- const expectedLengths: number[] = [];
131
- class TestFixedLengthStream extends TransformStream<
132
- Uint8Array,
133
- Uint8Array
134
- > {
135
- constructor(expectedLength: number) {
136
- super();
137
- expectedLengths.push(expectedLength);
138
- }
139
- }
140
- vi.stubGlobal("FixedLengthStream", TestFixedLengthStream);
141
- let uploaded = "";
142
- const put = vi.fn(
143
- async (_key: string, body: ReadableStream<Uint8Array>) => {
144
- uploaded = await new Response(body).text();
145
- return null;
146
- },
147
- );
148
- const storage = r2WorkerStorage({
149
- bucket: createBucket(vi.fn(), {
150
- put: put as unknown as R2Bucket["put"],
151
- }),
152
- bucketName: "bundles",
153
- downloadUrlSigningKey: "test-signing-key",
154
- });
155
-
156
- await storage.put({
157
- key: "releases/bundle.zip",
158
- body: new Response("bundle").body!,
159
- contentLength: 6,
160
- contentType: "application/zip",
161
- });
162
-
163
- expect(expectedLengths).toEqual([6]);
164
- expect(uploaded).toBe("bundle");
165
- });
166
-
167
- it("returns a stable signed download path for private R2 objects", async () => {
168
- const storage = r2WorkerStorage({
169
- bucket: createBucket(vi.fn()),
170
- bucketName: "bundles",
171
- downloadUrlSigningKey: "test-signing-key",
172
- });
173
-
174
- const first = await storage.getDownloadUrl({
175
- storageUri: "r2://bundles/releases/bundle.zip",
176
- });
177
- const second = await storage.getDownloadUrl({
178
- storageUri: "r2://bundles/releases/bundle.zip",
179
- });
180
-
181
- expect(first).toEqual(second);
182
- expect(first.url).toMatch(/^\/storage\//);
183
- });
184
- });
@@ -1,75 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const mocks = vi.hoisted(() => ({
4
- command: vi.fn(),
5
- execa: vi.fn(),
6
- }));
7
-
8
- vi.mock("execa", () => ({
9
- execa: mocks.execa,
10
- }));
11
-
12
- import { createWrangler } from "./createWrangler";
13
-
14
- describe("createWrangler", () => {
15
- beforeEach(() => {
16
- vi.clearAllMocks();
17
- mocks.execa.mockReturnValue(mocks.command);
18
- mocks.command.mockResolvedValue({});
19
- });
20
-
21
- it("marks replay commands as CI so Wrangler skips nested prompts", async () => {
22
- const wrangler = createWrangler({
23
- accountId: "account-id",
24
- cloudflareApiToken: "api-token",
25
- cwd: "/tmp/cloudflare-init",
26
- nonInteractive: true,
27
- stdio: "inherit",
28
- });
29
-
30
- await wrangler("d1", "migrations", "apply", "hot-updater", "--remote");
31
-
32
- expect(mocks.execa).toHaveBeenCalledWith({
33
- cwd: "/tmp/cloudflare-init",
34
- env: {
35
- CI: "true",
36
- CLOUDFLARE_ACCOUNT_ID: "account-id",
37
- CLOUDFLARE_API_TOKEN: "api-token",
38
- },
39
- extendsEnv: true,
40
- shell: false,
41
- stdio: "inherit",
42
- });
43
- expect(mocks.command).toHaveBeenCalledWith("npx", [
44
- "wrangler",
45
- "d1",
46
- "migrations",
47
- "apply",
48
- "hot-updater",
49
- "--remote",
50
- ]);
51
- });
52
-
53
- it("passes shell metacharacters as one argument", async () => {
54
- const wrangler = createWrangler({
55
- accountId: "account-id",
56
- cloudflareApiToken: "api-token",
57
- cwd: "/tmp/cloudflare-init",
58
- stdio: "inherit",
59
- });
60
-
61
- await wrangler("deploy", "--name", "safe; printf INJECTED");
62
-
63
- expect(mocks.execa).toHaveBeenCalledWith(
64
- expect.objectContaining({
65
- shell: false,
66
- }),
67
- );
68
- expect(mocks.command).toHaveBeenCalledWith("npx", [
69
- "wrangler",
70
- "deploy",
71
- "--name",
72
- "safe; printf INJECTED",
73
- ]);
74
- });
75
- });
@@ -1,59 +0,0 @@
1
- import { createDatabaseCoreApi } from "@hot-updater/server/db";
2
- import { expect, it } from "vitest";
3
-
4
- import {
5
- createBundleFixture,
6
- createBundleRowFixture,
7
- } from "../../../../packages/test-utils/src/databaseTestFixtures";
8
- import { createD1TestDatabase } from "../d1TestDatabase";
9
- import { d1Database } from "./d1Database";
10
-
11
- it("reads through the binding's statements and writes each change as one batch", async () => {
12
- const database = createD1TestDatabase();
13
- const calls = { all: 0, batch: 0 };
14
- const core = createDatabaseCoreApi(
15
- d1Database({
16
- prepare: (sql) => ({
17
- bind: (...params) => ({
18
- sql,
19
- params,
20
- all: async () => {
21
- calls.all += 1;
22
- return database.run(sql, params);
23
- },
24
- }),
25
- }),
26
- batch: async (statements) => {
27
- calls.batch += 1;
28
- return database.batch(
29
- statements as unknown as {
30
- sql: string;
31
- params: readonly unknown[];
32
- }[],
33
- );
34
- },
35
- }),
36
- );
37
- const bundle = createBundleFixture("1");
38
- await core.ensureChannel("production");
39
- await core.deploy([
40
- {
41
- bundle,
42
- release: {
43
- channel: "production",
44
- enabled: true,
45
- fingerprintHash: null,
46
- message: null,
47
- shouldForceUpdate: false,
48
- targetAppVersion: "1.0.0",
49
- },
50
- },
51
- ]);
52
- await expect(core.getBundle(bundle.id)).resolves.toEqual({
53
- bundle: createBundleRowFixture("1"),
54
- patches: [],
55
- childCount: 0,
56
- });
57
- expect(calls.batch).toBe(2);
58
- expect(calls.all).toBeGreaterThan(0);
59
- });
@@ -1,53 +0,0 @@
1
- import {
2
- coreSettings,
3
- createTableStatements,
4
- isMultiIndex,
5
- type PhysicalTable,
6
- SETTINGS_TABLE,
7
- WRITE_GUARD_TABLE,
8
- } from "@hot-updater/server/database";
9
- import { settingsStatements } from "@hot-updater/server/db";
10
- import { env } from "cloudflare:test";
11
-
12
- // Not the package index: it loads Node-only helpers workerd lacks.
13
- import { setupDatabaseAdapterConformanceSuite } from "../../../../packages/test-utils/src/setupDatabaseAdapterConformanceSuite";
14
- import { D1_MAX_OPS } from "../../src/d1Executor";
15
- import { d1Database } from "../../src/worker";
16
-
17
- /** Drops the tables and their index tables from the D1 binding. */
18
- const drop = async (tables: readonly PhysicalTable[]) => {
19
- await env.DB.batch(
20
- tables
21
- .flatMap((table) => [
22
- table.name,
23
- ...table.indexes
24
- .filter((index) => isMultiIndex(table, index))
25
- .map((index) => `${table.name}__${index.name}`),
26
- ])
27
- .map((name) => env.DB.prepare(`DROP TABLE IF EXISTS "${name}"`)),
28
- );
29
- };
30
-
31
- /**
32
- * The adapter the worker's `d1Database` returns on the D1 binding, schema
33
- * fence included: each test creates the conformance tables, the batch guard,
34
- * and the settings rows the fence checks, and drops them after. D1 has no
35
- * native pages.
36
- */
37
- setupDatabaseAdapterConformanceSuite({
38
- name: "d1 (workerd)",
39
- maxOps: D1_MAX_OPS,
40
- createAdapter: async ({ tables }) => {
41
- const created = [...tables, SETTINGS_TABLE, WRITE_GUARD_TABLE];
42
- await env.DB.batch(
43
- [
44
- ...createTableStatements("sqlite", created),
45
- ...settingsStatements("sqlite", coreSettings),
46
- ].map((sql) => env.DB.prepare(sql)),
47
- );
48
- return {
49
- adapter: d1Database(env.DB).adapter,
50
- cleanup: () => drop(created),
51
- };
52
- },
53
- });
@@ -1,85 +0,0 @@
1
- import { toolingTargetOf } from "@hot-updater/server/database";
2
- import {
3
- createMeasuredDatabase,
4
- targetBaseCandidateKey,
5
- } from "@hot-updater/server/db";
6
- import { apiKeys } from "@hot-updater/server/plugins/api-keys";
7
- import { insights } from "@hot-updater/server/plugins/insights";
8
- import { env } from "cloudflare:test";
9
- import { inject } from "vitest";
10
-
11
- // From source: the package root also loads Node-only helpers workerd lacks.
12
- import { setupReadBudgetTestSuite } from "../../../../packages/test-utils/src/setupReadBudgetTestSuite";
13
- import type { RowsExamined } from "../../../../packages/test-utils/src/sqlRowsExamined";
14
- import { d1Database, type D1Like } from "../../src/worker";
15
-
16
- /**
17
- * The binding, and D1's `rows_read` for each SELECT it runs outside a batch
18
- * while counting: the reads of the SQL core. D1 reads a batch get's key list
19
- * and a multi-valued index's own table beside each row it returns, and one
20
- * row past the end of a range.
21
- */
22
- const rowsRead = (database: D1Database) => {
23
- const statements = new WeakMap<object, D1PreparedStatement>();
24
- let counting = false;
25
- let read = 0;
26
- const binding: D1Like = {
27
- prepare: (sql) => ({
28
- bind: (...params) => {
29
- const statement = database.prepare(sql).bind(...params);
30
- const bound = {
31
- all: async () => {
32
- const result = await statement.all();
33
- if (counting && /^\s*SELECT\b/iu.test(sql)) {
34
- read += result.meta.rows_read;
35
- }
36
- return result;
37
- },
38
- };
39
- statements.set(bound, statement);
40
- return bound;
41
- },
42
- }),
43
- batch: (bound) =>
44
- database.batch(bound.map((statement) => statements.get(statement)!)),
45
- };
46
- const examined: RowsExamined = {
47
- reset() {
48
- counting = true;
49
- read = 0;
50
- },
51
- async total() {
52
- counting = false;
53
- return read;
54
- },
55
- perRow: 2,
56
- perRead: 1,
57
- };
58
- return { binding, examined };
59
- };
60
-
61
- /**
62
- * The worker's `d1Database` on the D1 binding. Its schema fence reads the
63
- * settings rows the migration writes once, while the suite seeds.
64
- */
65
- setupReadBudgetTestSuite({
66
- name: "d1 (workerd)",
67
- server: {
68
- createMeasuredDatabase,
69
- toolingTargetOf,
70
- plugins: [insights(), apiKeys()],
71
- targetBaseCandidateKey,
72
- },
73
- createAdapter: async () => {
74
- const [migration] = inject("d1Migrations");
75
- // `exec` runs one statement per line, and a comment line is not one.
76
- await env.DB.exec(
77
- migration!.sql
78
- .split("\n")
79
- .filter((line) => line.trim() !== "" && !line.startsWith("--"))
80
- .join("\n"),
81
- );
82
- const { binding, examined } = rowsRead(env.DB);
83
- return { adapter: d1Database(binding).adapter, examined };
84
- },
85
- });
@@ -1,101 +0,0 @@
1
- import { createHotUpdater } from "@hot-updater/server";
2
- import { isMultiIndex, toolingTargetOf } from "@hot-updater/server/database";
3
- import {
4
- createInsightsModel,
5
- insights,
6
- } from "@hot-updater/server/plugins/insights";
7
- import { createBundleEventRowFixture } from "@hot-updater/server/plugins/insights/testing";
8
- import { env } from "cloudflare:test";
9
- import { expect, inject, it } from "vitest";
10
-
11
- import { plugins } from "../../src/plugins";
12
- import { d1Database } from "../../src/worker";
13
-
14
- declare module "vitest" {
15
- export interface ProvidedContext {
16
- d1Migrations: readonly {
17
- readonly name: string;
18
- readonly sql: string;
19
- }[];
20
- }
21
- }
22
-
23
- /** Every data table of the managed server's migration: each model's table and the index tables of its multi-valued indexes. */
24
- const dataTables = toolingTargetOf(plugins).schema.tables.flatMap((table) => [
25
- table.name,
26
- ...table.indexes
27
- .filter((index) => isMultiIndex(table, index))
28
- .map((index) => `${table.name}__${index.name}`),
29
- ]);
30
-
31
- it("ships a single 1.0.0 initialization migration", () => {
32
- expect(inject("d1Migrations").map(({ name }) => name)).toEqual([
33
- "0001_hot-updater_1.0.0.sql",
34
- ]);
35
- });
36
-
37
- it("creates every table, the batch guard, and the settings the fence checks", async () => {
38
- const [migration] = inject("d1Migrations");
39
- // `exec` runs one statement per line, and a comment line is not one.
40
- await env.DB.exec(
41
- migration!.sql
42
- .split("\n")
43
- .filter((line) => line.trim() !== "" && !line.startsWith("--"))
44
- .join("\n"),
45
- );
46
- const tables = (
47
- await env.DB.prepare(
48
- "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'",
49
- ).all<{ name: string }>()
50
- ).results.map(({ name }) => name);
51
- expect(tables.toSorted()).toEqual(
52
- ["_hu_write", "private_hot_updater_settings", ...dataTables].toSorted(),
53
- );
54
- const settings = await env.DB.prepare(
55
- "SELECT key, value FROM private_hot_updater_settings ORDER BY key",
56
- ).all<{ key: string; value: string }>();
57
- expect(settings.results.map(({ key }) => key)).toEqual([
58
- "schema.apiKeys",
59
- "schema.core",
60
- "schema.engine",
61
- "schema.insights",
62
- ]);
63
- });
64
-
65
- it("returns canonical downloaded and applied events from the initialized D1 schema", async () => {
66
- const model = createInsightsModel(
67
- createHotUpdater({
68
- database: d1Database(env.DB),
69
- plugins: [insights()],
70
- clientAccess: "public",
71
- }).api.insights,
72
- );
73
- const download = {
74
- ...createBundleEventRowFixture("9601", 100),
75
- type: "UPDATE_DOWNLOADED" as const,
76
- from_bundle_id: "00000000-0000-7000-8000-000000001001",
77
- metadata: {
78
- ...createBundleEventRowFixture("9601", 100).metadata,
79
- update_strategy: "appVersion" as const,
80
- },
81
- };
82
- await model.recordEvent({ event: download });
83
- await expect(
84
- model.findLatestEvents({
85
- installId: download.install_id,
86
- }),
87
- ).resolves.toEqual([download]);
88
- const applied = {
89
- ...download,
90
- id: createBundleEventRowFixture("9602", 200).id,
91
- type: "UPDATE_APPLIED" as const,
92
- received_at_ms: 200,
93
- };
94
- await model.recordEvent({ event: applied });
95
- await model.recordEvent({ event: download });
96
- await expect(
97
- model.findLatestEvents({
98
- installId: download.install_id,
99
- }),
100
- ).resolves.toEqual([applied]);
101
- });