@hot-updater/cloudflare 0.36.7 → 1.0.0-rc.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.
- package/dist/iac/index.cjs +988 -10
- package/dist/iac/index.mjs +991 -14
- package/dist/index.cjs +919 -6989
- package/dist/index.d.cts +11 -47
- package/dist/index.d.mts +11 -47
- package/dist/index.mjs +917 -7006
- package/dist/worker/index.cjs +898 -336
- package/dist/worker/index.d.cts +18 -33
- package/dist/worker/index.d.mts +18 -33
- package/dist/worker/index.mjs +899 -331
- package/package.json +7 -10
- package/sql/bundles.sql +183 -35
- package/src/d1Database.spec.ts +143 -392
- package/src/d1Database.ts +55 -650
- package/src/d1Implementation.spec.ts +252 -0
- package/src/d1Implementation.ts +742 -0
- package/src/d1Query.spec.ts +46 -0
- package/src/d1Query.ts +89 -0
- package/src/d1Rows.spec.ts +111 -0
- package/src/d1Rows.ts +326 -0
- package/src/d1Sql.spec.ts +30 -0
- package/src/d1Sql.ts +175 -0
- package/src/r2S3Storage.ts +93 -137
- package/src/r2Storage.spec.ts +120 -218
- package/src/r2Storage.ts +13 -61
- package/src/r2WorkerStorage.spec.ts +161 -50
- package/src/r2WorkerStorage.ts +85 -118
- package/src/worker/d1Database.spec.ts +33 -0
- package/src/worker/d1Database.ts +52 -0
- package/src/worker/index.ts +3 -31
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +5968 -4701
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0001_hot-updater_1.0.0.sql +205 -0
- package/worker/src/databaseMigration.integration.spec.ts +130 -0
- package/worker/src/databasePlugins.integration.spec.ts +292 -0
- package/worker/src/index.integration.spec.ts +102 -294
- package/worker/src/index.ts +14 -66
- package/worker/src/storagePlugin.integration.spec.ts +59 -0
- package/worker/wrangler.json +5 -2
- package/src/cloudflareWorkerDatabase.spec.ts +0 -260
- package/src/cloudflareWorkerDatabase.ts +0 -651
- package/src/r2WranglerStorage.ts +0 -193
- package/worker/migrations/0001_hot-updater_init.sql +0 -16
- package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
- package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
- package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
- package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
});
|