@hot-updater/cloudflare 1.0.0-rc.2 → 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 (51) hide show
  1. package/dist/index.cjs +95 -870
  2. package/dist/index.d.cts +724 -6
  3. package/dist/index.d.mts +724 -6
  4. package/dist/index.mjs +93 -870
  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 +59 -859
  10. package/dist/worker/index.d.cts +725 -11
  11. package/dist/worker/index.d.mts +725 -11
  12. package/dist/worker/index.mjs +62 -863
  13. package/package.json +9 -13
  14. package/sql/bundles.sql +109 -190
  15. package/src/d1Database.ts +61 -40
  16. package/src/d1Executor.ts +84 -0
  17. package/src/d1Schema.ts +26 -0
  18. package/src/index.ts +1 -0
  19. package/src/plugins.ts +8 -0
  20. package/src/r2S3Storage.ts +4 -4
  21. package/src/r2Storage.ts +2 -2
  22. package/src/r2WorkerStorage.ts +4 -4
  23. package/src/worker/d1Database.ts +21 -27
  24. package/src/worker/index.ts +1 -0
  25. package/worker/dist/README.md +1 -1
  26. package/worker/dist/index.js +9682 -6206
  27. package/worker/dist/index.js.map +4 -4
  28. package/worker/migrations/0001_hot-updater_1.0.0.sql +109 -205
  29. package/worker/src/index.ts +2 -2
  30. package/dist/iac/index.cjs +0 -12566
  31. package/dist/iac/index.d.cts +0 -9
  32. package/dist/iac/index.d.mts +0 -9
  33. package/dist/iac/index.mjs +0 -12558
  34. package/src/d1Database.spec.ts +0 -178
  35. package/src/d1Implementation.spec.ts +0 -252
  36. package/src/d1Implementation.ts +0 -742
  37. package/src/d1Query.spec.ts +0 -46
  38. package/src/d1Query.ts +0 -89
  39. package/src/d1Rows.spec.ts +0 -111
  40. package/src/d1Rows.ts +0 -326
  41. package/src/d1Sql.spec.ts +0 -30
  42. package/src/d1Sql.ts +0 -175
  43. package/src/r2Storage.spec.ts +0 -346
  44. package/src/r2WorkerStorage.spec.ts +0 -184
  45. package/src/utils/createWrangler.spec.ts +0 -75
  46. package/src/worker/d1Database.spec.ts +0 -33
  47. package/worker/src/databaseMigration.integration.spec.ts +0 -130
  48. package/worker/src/databasePlugins.integration.spec.ts +0 -292
  49. package/worker/src/index.integration.spec.ts +0 -190
  50. package/worker/src/storagePlugin.integration.spec.ts +0 -59
  51. package/worker/src/testWorker.ts +0 -5
@@ -1,178 +0,0 @@
1
- import { beforeEach, expect, it, vi } from "vitest";
2
-
3
- import { d1Database } from "./d1Database";
4
-
5
- type RecordedQuery = {
6
- readonly sql: string;
7
- readonly params: readonly string[];
8
- };
9
-
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;
28
-
29
- vi.mock("cloudflare", () => ({
30
- default: class MockCloudflare {
31
- readonly d1 = {
32
- database: {
33
- query: async (
34
- _databaseId: string,
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
- },
43
- ) => {
44
- if ("batch" in input) {
45
- state.batches.push(
46
- input.batch.map(({ sql, params }) => ({
47
- sql,
48
- params: params ?? [],
49
- })),
50
- );
51
- } else {
52
- state.queries.push({
53
- sql: input.sql,
54
- params: input.params ?? [],
55
- });
56
- }
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
- };
67
- },
68
- },
69
- };
70
- },
71
- }));
72
-
73
- beforeEach(() => {
74
- state.batches.length = 0;
75
- state.queries.length = 0;
76
- state.results.length = 0;
77
- });
78
-
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",
85
- });
86
-
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,
92
- });
93
-
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
- });
104
-
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
- });
112
-
113
- await expect(plugin.models.channels.list({})).resolves.toEqual({
114
- channels: [{ id: "channel-production", name: "production" }],
115
- });
116
-
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
- });
123
-
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
- });
131
-
132
- await plugin.models.bundles.count({
133
- platform: "ios",
134
- });
135
-
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
- });
141
-
142
- it("sends parameterized commits through the D1 batch body", async () => {
143
- const plugin = d1Database({
144
- accountId: "account",
145
- cloudflareApiToken: "token",
146
- databaseId: "database",
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);
178
- });
@@ -1,252 +0,0 @@
1
- import { expect, it } from "vitest";
2
-
3
- import { createD1Implementation, type D1Statement } from "./d1Implementation";
4
-
5
- it("guards every write and reports the missing change index", async () => {
6
- let recorded: readonly D1Statement[] = [];
7
- const implementation = createD1Implementation({
8
- query: () => Promise.reject(new Error("unexpected standalone query")),
9
- async batch(statements) {
10
- recorded = statements;
11
- return statements.map((_, index) =>
12
- index === 0 ? [{ id: "bundle-1" }] : [],
13
- );
14
- },
15
- });
16
-
17
- const result = await implementation.commit?.({
18
- changes: [
19
- {
20
- model: "bundles",
21
- operation: "update",
22
- where: { id: "bundle-1" },
23
- update: { metadata: { app_version: "1.0.0" } },
24
- },
25
- {
26
- model: "apiKeys",
27
- operation: "update",
28
- where: { id: "missing-key" },
29
- update: { revokedAtMs: 1 },
30
- },
31
- ],
32
- });
33
-
34
- expect(result).toEqual({
35
- committed: false,
36
- conflict: { changeIndex: 1, reason: "not_found" },
37
- });
38
- expect(recorded.slice(0, 2).map(({ sql }) => sql)).toEqual([
39
- "SELECT id FROM bundles WHERE id = json_extract(?, '$') LIMIT 1",
40
- "SELECT id FROM api_keys WHERE id = json_extract(?, '$') LIMIT 1",
41
- ]);
42
- expect(recorded.slice(2)).toHaveLength(2);
43
- for (const statement of recorded.slice(2)) {
44
- expect(statement.sql).toContain("SELECT 1 FROM json_each(?) AS required");
45
- expect(statement.params).toContain(
46
- JSON.stringify([
47
- { model: "bundles", id: "bundle-1" },
48
- { model: "api_keys", id: "missing-key" },
49
- ]),
50
- );
51
- }
52
- });
53
-
54
- it("aborts an atomic commit when a Release expectation changes after preflight", async () => {
55
- let queryCount = 0;
56
- const implementation = createD1Implementation({
57
- async query(sql) {
58
- expect(sql).toContain("SELECT revision FROM releases");
59
- queryCount += 1;
60
- return [{ revision: queryCount }];
61
- },
62
- async batch(statements) {
63
- expect(statements[0]?.sql).toContain(
64
- "HOT_UPDATER_COMMIT_EXPECTATION_CONFLICT",
65
- );
66
- expect(
67
- statements.some(({ sql }) => sql.includes("UPDATE releases")),
68
- ).toBe(true);
69
- throw new Error("D1_ERROR: malformed JSON");
70
- },
71
- });
72
-
73
- await expect(
74
- implementation.commit?.({
75
- changes: [
76
- {
77
- model: "releases",
78
- operation: "update",
79
- where: { id: "release-1" },
80
- update: { revision: 2 },
81
- },
82
- ],
83
- expectations: [{ id: "release-1", model: "releases", revision: 1 }],
84
- }),
85
- ).resolves.toEqual({
86
- committed: false,
87
- conflict: {
88
- actualVersion: 2,
89
- changeIndex: -1,
90
- expectedVersion: 1,
91
- key: "release-1",
92
- model: "releases",
93
- reason: "version_conflict",
94
- },
95
- });
96
- });
97
-
98
- it("maps idempotent Channel inserts to the normalized table", async () => {
99
- let recorded: readonly D1Statement[] = [];
100
- const implementation = createD1Implementation({
101
- query: () => Promise.reject(new Error("unexpected standalone query")),
102
- async batch(statements) {
103
- recorded = statements;
104
- return statements.map(() => []);
105
- },
106
- });
107
-
108
- await expect(
109
- implementation.commit?.({
110
- changes: [
111
- {
112
- model: "channels",
113
- operation: "insert",
114
- row: { id: "candidate", name: "production" },
115
- onConflict: "ignore",
116
- },
117
- ],
118
- }),
119
- ).resolves.toEqual({ committed: true });
120
-
121
- expect(recorded).toHaveLength(1);
122
- expect(recorded[0]?.sql).toContain("INSERT INTO channels (id, name)");
123
- expect(recorded[0]?.sql).toContain("ON CONFLICT(name) DO NOTHING");
124
- });
125
-
126
- it("persists required archive and patch byte sizes", async () => {
127
- let recorded: readonly D1Statement[] = [];
128
- const implementation = createD1Implementation({
129
- query: () => Promise.reject(new Error("unexpected standalone query")),
130
- async batch(statements) {
131
- recorded = statements;
132
- return statements.map(() => []);
133
- },
134
- });
135
- const bundle = {
136
- id: "bundle-1",
137
- platform: "ios" as const,
138
- file_hash: "bundle-hash",
139
- git_commit_hash: null,
140
- storage_uri: "storage://bundle",
141
- archive_byte_size: 3_000_000_001,
142
- metadata: {},
143
- manifest_storage_uri: null,
144
- manifest_file_hash: null,
145
- asset_base_storage_uri: null,
146
- };
147
-
148
- await expect(
149
- implementation.commit?.({
150
- changes: [
151
- { model: "bundles", operation: "insert", row: bundle },
152
- {
153
- model: "bundlePatches",
154
- operation: "insert",
155
- row: {
156
- id: "patch-1",
157
- bundle_id: bundle.id,
158
- base_bundle_id: bundle.id,
159
- base_file_hash: "base-hash",
160
- patch_file_hash: "patch-hash",
161
- patch_storage_uri: "storage://patch",
162
- byte_size: 3_000_000_002,
163
- order_index: 0,
164
- },
165
- },
166
- ],
167
- }),
168
- ).resolves.toEqual({ committed: true });
169
-
170
- expect(recorded[0]?.sql).toContain("archive_byte_size");
171
- expect(recorded[0]?.params).toContain("3000000001");
172
- expect(recorded[1]?.sql).toContain("byte_size");
173
- expect(recorded[1]?.params).toContain("3000000002");
174
- });
175
-
176
- it("returns the canonical Channel row after a concurrent name conflict", async () => {
177
- const implementation = createD1Implementation({
178
- query: () => Promise.reject(new Error("unexpected standalone query")),
179
- async batch(statements) {
180
- expect(statements[0]?.sql).toContain("ON CONFLICT(name) DO NOTHING");
181
- expect(statements[1]?.sql).toBe(
182
- "SELECT id, name FROM channels WHERE name = json_extract(?, '$') LIMIT 1",
183
- );
184
- return [[], [{ id: "canonical", name: "production" }]];
185
- },
186
- });
187
-
188
- await expect(
189
- implementation.insertChannel({
190
- row: { id: "losing-candidate", name: "production" },
191
- onConflict: "returnExisting",
192
- }),
193
- ).resolves.toEqual({
194
- row: { id: "canonical", name: "production" },
195
- inserted: false,
196
- });
197
- });
198
-
199
- it("deletes an empty Channel and distinguishes missing and referenced rows", async () => {
200
- const results = [
201
- [[{ id: "empty" }], [], [{ id: "empty" }]],
202
- [[], [], []],
203
- [[{ id: "active" }], [{ id: "release" }], []],
204
- ];
205
- const implementation = createD1Implementation({
206
- query: () => Promise.reject(new Error("unexpected standalone query")),
207
- async batch(statements) {
208
- expect(statements[2]?.sql).toContain(
209
- "DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS",
210
- );
211
- return results.shift() ?? [];
212
- },
213
- });
214
-
215
- await expect(implementation.deleteChannel({ id: "empty" })).resolves.toEqual({
216
- deleted: true,
217
- });
218
- await expect(
219
- implementation.deleteChannel({ id: "missing" }),
220
- ).resolves.toEqual({ deleted: false, reason: "not_found" });
221
- await expect(implementation.deleteChannel({ id: "active" })).resolves.toEqual(
222
- { deleted: false, reason: "not_empty" },
223
- );
224
- });
225
-
226
- it("guards a generic Channel delete and reports a referenced conflict", async () => {
227
- let recorded: readonly D1Statement[] = [];
228
- const implementation = createD1Implementation({
229
- query: () => Promise.reject(new Error("unexpected standalone query")),
230
- async batch(statements) {
231
- recorded = statements;
232
- return [[{ id: "bundle" }], []];
233
- },
234
- });
235
-
236
- await expect(
237
- implementation.commit?.({
238
- changes: [
239
- {
240
- model: "channels",
241
- operation: "delete",
242
- where: { id: "active" },
243
- },
244
- ],
245
- }),
246
- ).resolves.toEqual({
247
- committed: false,
248
- conflict: { changeIndex: 0, reason: "referenced" },
249
- });
250
- expect(recorded[1]?.sql).toContain("DELETE FROM channels");
251
- expect(recorded[1]?.sql).toContain("NOT EXISTS");
252
- });