@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,368 +1,930 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let _hot_updater_js = require("@hot-updater/js");
3
- let _hot_updater_core = require("@hot-updater/core");
4
2
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
5
- //#region src/cloudflareWorkerDatabase.ts
6
- const buildJsonEachInClause = (columnName, values, params) => {
7
- if (values.length === 0) return "1 = 0";
8
- params.push(JSON.stringify(values));
9
- return `${columnName} IN (SELECT value FROM json_each(?))`;
10
- };
11
- function buildWhereClause(conditions) {
12
- if (!conditions) return {
13
- sql: "",
14
- params: []
15
- };
16
- const clauses = [];
17
- const params = [];
18
- if (conditions.channel) {
19
- clauses.push("channel = ?");
20
- params.push(conditions.channel);
21
- }
22
- if (conditions.platform) {
23
- clauses.push("platform = ?");
24
- params.push(conditions.platform);
3
+ let _hot_updater_plugin_core_internal = require("@hot-updater/plugin-core/internal");
4
+ //#region src/d1Rows.ts
5
+ var InvalidD1RowError = class extends Error {
6
+ name = "InvalidD1RowError";
7
+ model;
8
+ constructor(model) {
9
+ super(`D1 returned an invalid ${model} row`);
10
+ this.model = model;
25
11
  }
26
- if (conditions.enabled !== void 0) {
27
- clauses.push("enabled = ?");
28
- params.push(conditions.enabled ? 1 : 0);
12
+ };
13
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14
+ const stringValue = (row, field, model) => {
15
+ const value = row[field];
16
+ if (typeof value !== "string") throw new InvalidD1RowError(model);
17
+ return value;
18
+ };
19
+ const nullableString = (row, field, model) => {
20
+ const value = row[field];
21
+ if (value === null) return null;
22
+ if (typeof value !== "string") throw new InvalidD1RowError(model);
23
+ return value;
24
+ };
25
+ const numberValue = (row, field, model) => {
26
+ const value = row[field];
27
+ if (typeof value !== "number") throw new InvalidD1RowError(model);
28
+ return value;
29
+ };
30
+ const byteSizeValue = (row, field, model) => {
31
+ const value = row[field];
32
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new InvalidD1RowError(model);
33
+ return value;
34
+ };
35
+ const booleanValue = (row, field, model) => {
36
+ const value = row[field];
37
+ if (value === true || value === 1) return true;
38
+ if (value === false || value === 0) return false;
39
+ throw new InvalidD1RowError(model);
40
+ };
41
+ const jsonValue = (value, model) => {
42
+ if (typeof value !== "string") throw new InvalidD1RowError(model);
43
+ try {
44
+ return JSON.parse(value);
45
+ } catch (error) {
46
+ if (error instanceof SyntaxError) throw new InvalidD1RowError(model);
47
+ throw error;
29
48
  }
30
- if (conditions.id?.in) clauses.push(buildJsonEachInClause("id", conditions.id.in, params));
31
- if (conditions.id?.eq) {
32
- clauses.push("id = ?");
33
- params.push(conditions.id.eq);
49
+ };
50
+ const metadata = (row) => {
51
+ const value = jsonValue(row["metadata"], "bundles");
52
+ if (!(0, _hot_updater_plugin_core.isDatabaseMetadataObject)(value)) throw new InvalidD1RowError("bundles");
53
+ return value;
54
+ };
55
+ const bundleRow = (row) => {
56
+ const platform = stringValue(row, "platform", "bundles");
57
+ if (platform !== "ios" && platform !== "android") throw new InvalidD1RowError("bundles");
58
+ return {
59
+ id: stringValue(row, "id", "bundles"),
60
+ platform,
61
+ file_hash: stringValue(row, "file_hash", "bundles"),
62
+ git_commit_hash: nullableString(row, "git_commit_hash", "bundles"),
63
+ storage_uri: stringValue(row, "storage_uri", "bundles"),
64
+ archive_byte_size: row["archive_byte_size"] === void 0 ? 0 : byteSizeValue(row, "archive_byte_size", "bundles"),
65
+ metadata: metadata(row),
66
+ manifest_storage_uri: nullableString(row, "manifest_storage_uri", "bundles"),
67
+ manifest_file_hash: nullableString(row, "manifest_file_hash", "bundles"),
68
+ asset_base_storage_uri: nullableString(row, "asset_base_storage_uri", "bundles")
69
+ };
70
+ };
71
+ const patchRow = (row) => ({
72
+ id: stringValue(row, "id", "bundle_patches"),
73
+ bundle_id: stringValue(row, "bundle_id", "bundle_patches"),
74
+ base_bundle_id: stringValue(row, "base_bundle_id", "bundle_patches"),
75
+ base_file_hash: stringValue(row, "base_file_hash", "bundle_patches"),
76
+ patch_file_hash: stringValue(row, "patch_file_hash", "bundle_patches"),
77
+ patch_storage_uri: stringValue(row, "patch_storage_uri", "bundle_patches"),
78
+ byte_size: byteSizeValue(row, "byte_size", "bundle_patches"),
79
+ order_index: numberValue(row, "order_index", "bundle_patches")
80
+ });
81
+ const channelRow = (row) => ({
82
+ id: stringValue(row, "id", "channels"),
83
+ name: stringValue(row, "name", "channels")
84
+ });
85
+ const eventRow = (row) => {
86
+ const type = stringValue(row, "type", "bundle_events");
87
+ const platform = stringValue(row, "platform", "bundle_events");
88
+ const fromBundleId = nullableString(row, "from_bundle_id", "bundle_events");
89
+ const updateStrategy = nullableString(row, "update_strategy", "bundle_events");
90
+ if (platform !== "ios" && platform !== "android" || !((type === "UPDATE_APPLIED" || type === "RECOVERED" || type === "RELEASE_ADOPTED") && typeof fromBundleId === "string" && (updateStrategy === "fingerprint" || updateStrategy === "appVersion") || type === "UNCHANGED" && fromBundleId === null && updateStrategy === null)) throw new InvalidD1RowError("bundle_events");
91
+ return {
92
+ id: stringValue(row, "id", "bundle_events"),
93
+ type,
94
+ install_id: stringValue(row, "install_id", "bundle_events"),
95
+ user_id: nullableString(row, "user_id", "bundle_events"),
96
+ username: nullableString(row, "username", "bundle_events"),
97
+ from_release_id: nullableString(row, "from_release_id", "bundle_events"),
98
+ from_bundle_id: fromBundleId,
99
+ to_release_id: nullableString(row, "to_release_id", "bundle_events"),
100
+ to_bundle_id: stringValue(row, "to_bundle_id", "bundle_events"),
101
+ platform,
102
+ app_version: stringValue(row, "app_version", "bundle_events"),
103
+ channel: stringValue(row, "channel", "bundle_events"),
104
+ cohort: stringValue(row, "cohort", "bundle_events"),
105
+ update_strategy: updateStrategy,
106
+ fingerprint_hash: nullableString(row, "fingerprint_hash", "bundle_events"),
107
+ sdk_version: nullableString(row, "sdk_version", "bundle_events"),
108
+ received_at_ms: numberValue(row, "received_at_ms", "bundle_events")
109
+ };
110
+ };
111
+ const apiKeyRow = (row) => {
112
+ const role = stringValue(row, "role", "api_keys");
113
+ if (role !== "client") throw new InvalidD1RowError("api_keys");
114
+ const revokedAt = row["revoked_at_ms"];
115
+ return {
116
+ id: stringValue(row, "id", "api_keys"),
117
+ hash: stringValue(row, "hash", "api_keys"),
118
+ name: stringValue(row, "name", "api_keys"),
119
+ prefix: stringValue(row, "prefix", "api_keys"),
120
+ role,
121
+ created_at_ms: numberValue(row, "created_at_ms", "api_keys"),
122
+ revoked_at_ms: revokedAt === null ? null : numberValue(row, "revoked_at_ms", "api_keys")
123
+ };
124
+ };
125
+ const releaseTargetCohorts = (row) => {
126
+ const value = jsonValue(row["target_cohorts"], "releases");
127
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new InvalidD1RowError("releases");
128
+ return value.filter((item) => typeof item === "string");
129
+ };
130
+ const releaseRow = (row) => {
131
+ const platform = stringValue(row, "platform", "releases");
132
+ const kind = stringValue(row, "kind", "releases");
133
+ const strategy = stringValue(row, "strategy", "releases");
134
+ const operation = stringValue(row, "operation", "releases");
135
+ if (platform !== "ios" && platform !== "android" || kind !== "BUNDLE" && kind !== "EMBEDDED" || strategy !== "APP_VERSION" && strategy !== "FINGERPRINT" || operation !== "DEPLOY" && operation !== "PROMOTE" && operation !== "ROLLBACK") throw new InvalidD1RowError("releases");
136
+ return {
137
+ id: stringValue(row, "id", "releases"),
138
+ revision: numberValue(row, "revision", "releases"),
139
+ scope_key: stringValue(row, "scope_key", "releases"),
140
+ channel_id: stringValue(row, "channel_id", "releases"),
141
+ platform,
142
+ kind,
143
+ bundle_id: nullableString(row, "bundle_id", "releases"),
144
+ strategy,
145
+ target_app_version: nullableString(row, "target_app_version", "releases"),
146
+ fingerprint_hash: nullableString(row, "fingerprint_hash", "releases"),
147
+ enabled: booleanValue(row, "enabled", "releases"),
148
+ should_force_update: booleanValue(row, "should_force_update", "releases"),
149
+ message: nullableString(row, "message", "releases"),
150
+ rollout_cohort_count: numberValue(row, "rollout_cohort_count", "releases"),
151
+ target_cohorts: releaseTargetCohorts(row),
152
+ operation,
153
+ source_release_id: nullableString(row, "source_release_id", "releases"),
154
+ created_at_ms: numberValue(row, "created_at_ms", "releases"),
155
+ updated_at_ms: numberValue(row, "updated_at_ms", "releases")
156
+ };
157
+ };
158
+ const releaseCatalogRow = (row) => {
159
+ const platform = stringValue(row, "platform", "release_catalogs");
160
+ const strategy = stringValue(row, "strategy", "release_catalogs");
161
+ if (platform !== "ios" && platform !== "android" || strategy !== "APP_VERSION" && strategy !== "FINGERPRINT") throw new InvalidD1RowError("release_catalogs");
162
+ return {
163
+ scope_key: stringValue(row, "scope_key", "release_catalogs"),
164
+ catalog_id: stringValue(row, "catalog_id", "release_catalogs"),
165
+ strategy,
166
+ channel_id: stringValue(row, "channel_id", "release_catalogs"),
167
+ channel_key: stringValue(row, "channel_key", "release_catalogs"),
168
+ platform,
169
+ fingerprint_hash: nullableString(row, "fingerprint_hash", "release_catalogs"),
170
+ generation: numberValue(row, "generation", "release_catalogs"),
171
+ payload: stringValue(row, "payload", "release_catalogs"),
172
+ catalog_hash: stringValue(row, "catalog_hash", "release_catalogs"),
173
+ byte_size: numberValue(row, "byte_size", "release_catalogs"),
174
+ is_tombstone: booleanValue(row, "is_tombstone", "release_catalogs"),
175
+ updated_at_ms: numberValue(row, "updated_at_ms", "release_catalogs")
176
+ };
177
+ };
178
+ function parseD1Row(model, value) {
179
+ if (!isRecord(value)) throw new InvalidD1RowError(model);
180
+ switch (model) {
181
+ case "bundles": return bundleRow(value);
182
+ case "bundle_patches": return patchRow(value);
183
+ case "channels": return channelRow(value);
184
+ case "bundle_events": return eventRow(value);
185
+ case "api_keys": return apiKeyRow(value);
186
+ case "releases": return releaseRow(value);
187
+ case "release_catalogs": return releaseCatalogRow(value);
34
188
  }
35
- if (conditions.id?.gt) {
36
- clauses.push("id > ?");
37
- params.push(conditions.id.gt);
189
+ }
190
+ //#endregion
191
+ //#region src/d1Sql.ts
192
+ var InvalidD1PredicateError = class extends Error {
193
+ name = "InvalidD1PredicateError";
194
+ };
195
+ const encodeD1Value = (value) => {
196
+ const encoded = JSON.stringify(value);
197
+ if (encoded === void 0) throw new TypeError("D1 values must be JSON-serializable.");
198
+ return encoded;
199
+ };
200
+ const bind = (value) => ({
201
+ sql: "json_extract(?, '$')",
202
+ params: [encodeD1Value(value)]
203
+ });
204
+ const escapeLikePattern = (value) => value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
205
+ const stringPredicate = (condition, operator) => {
206
+ if (typeof condition.value !== "string") throw new InvalidD1PredicateError();
207
+ if (condition.mode !== "insensitive") {
208
+ const parameter = bind(condition.value);
209
+ const column = `${condition.field} COLLATE BINARY`;
210
+ const value = `${parameter.sql} COLLATE BINARY`;
211
+ if (operator === "contains" || operator === "starts_with") return {
212
+ sql: `instr(${column}, ${value}) ${operator === "contains" ? "> 0" : "= 1"}`,
213
+ params: parameter.params
214
+ };
215
+ return {
216
+ sql: `substr(${column}, length(${condition.field}) - length(${parameter.sql}) + 1) = ${value}`,
217
+ params: [...parameter.params, ...parameter.params]
218
+ };
38
219
  }
39
- if (conditions.id?.gte) {
40
- clauses.push("id >= ?");
41
- params.push(conditions.id.gte);
220
+ const literal = escapeLikePattern(condition.value);
221
+ const parameter = bind(operator === "contains" ? `%${literal}%` : operator === "starts_with" ? `${literal}%` : `%${literal}`);
222
+ return {
223
+ sql: `lower(${condition.field}) LIKE lower(${parameter.sql}) ESCAPE '\\'`,
224
+ params: parameter.params
225
+ };
226
+ };
227
+ const predicate = (condition) => {
228
+ const operator = condition.operator ?? "eq";
229
+ switch (operator) {
230
+ case "eq":
231
+ case "ne": {
232
+ if (condition.value === null) return {
233
+ sql: `${condition.field} IS ${operator === "ne" ? "NOT " : ""}NULL`,
234
+ params: []
235
+ };
236
+ const parameter = bind(condition.value);
237
+ const insensitive = "mode" in condition && condition.mode === "insensitive";
238
+ const column = insensitive ? `lower(${condition.field})` : condition.field;
239
+ const value = insensitive ? `lower(${parameter.sql})` : parameter.sql;
240
+ return {
241
+ sql: `${column} ${operator === "eq" ? "=" : "<>"} ${value}`,
242
+ params: parameter.params
243
+ };
244
+ }
245
+ case "gt":
246
+ case "gte":
247
+ case "lt":
248
+ case "lte": {
249
+ const sqlOperator = {
250
+ gt: ">",
251
+ gte: ">=",
252
+ lt: "<",
253
+ lte: "<="
254
+ }[operator];
255
+ const parameter = bind(condition.value);
256
+ return {
257
+ sql: `${condition.field} ${sqlOperator} ${parameter.sql}`,
258
+ params: parameter.params
259
+ };
260
+ }
261
+ case "in":
262
+ case "not_in":
263
+ if (!Array.isArray(condition.value)) throw new InvalidD1PredicateError();
264
+ if (condition.value.length === 0) return {
265
+ sql: operator === "not_in" ? "1 = 1" : "1 = 0",
266
+ params: []
267
+ };
268
+ return {
269
+ sql: `${condition.field} ${operator === "not_in" ? "NOT " : ""}IN (SELECT value FROM json_each(?))`,
270
+ params: [encodeD1Value(condition.value)]
271
+ };
272
+ case "contains":
273
+ case "starts_with":
274
+ case "ends_with": return stringPredicate(condition, operator);
42
275
  }
43
- if (conditions.id?.lt) {
44
- clauses.push("id < ?");
45
- params.push(conditions.id.lt);
276
+ };
277
+ const buildD1Where = (where) => {
278
+ const [first, ...rest] = where ?? [];
279
+ if (first === void 0) return {
280
+ sql: "",
281
+ params: []
282
+ };
283
+ const initial = predicate(first);
284
+ let sql = initial.sql;
285
+ const params = [...initial.params];
286
+ for (const condition of rest) {
287
+ const next = predicate(condition);
288
+ sql = `(${sql} ${condition.connector === "OR" ? "OR" : "AND"} ${next.sql})`;
289
+ params.push(...next.params);
46
290
  }
47
- if (conditions.id?.lte) {
48
- clauses.push("id <= ?");
49
- params.push(conditions.id.lte);
291
+ return {
292
+ sql: ` WHERE ${sql}`,
293
+ params
294
+ };
295
+ };
296
+ const buildD1Order = (orderBy) => orderBy === void 0 ? "" : ` ORDER BY ${orderBy.map((clause) => `${clause.field} ${clause.direction.toUpperCase()}${clause.nulls ? ` NULLS ${clause.nulls.toUpperCase()}` : ""}`).join(", ")}`;
297
+ const d1Placeholders = (count) => Array.from({ length: count }, () => "json_extract(?, '$')").join(", ");
298
+ const encodeD1Values = (values) => values.map(encodeD1Value);
299
+ //#endregion
300
+ //#region src/d1Query.ts
301
+ const d1TableNames = {
302
+ bundles: "bundles",
303
+ bundle_patches: "bundle_patches",
304
+ channels: "channels",
305
+ bundle_events: "bundle_events",
306
+ api_keys: "api_keys",
307
+ release_catalogs: "release_catalogs",
308
+ releases: "releases"
309
+ };
310
+ var D1QueryResultError = class extends Error {
311
+ name = "D1QueryResultError";
312
+ operation = "count";
313
+ constructor(result) {
314
+ super("D1 count query returned a malformed first row");
315
+ this.result = result;
50
316
  }
51
- if (conditions.targetAppVersionNotNull) clauses.push("target_app_version IS NOT NULL");
52
- if (conditions.targetAppVersion !== void 0) if (conditions.targetAppVersion === null) clauses.push("target_app_version IS NULL");
53
- else {
54
- clauses.push("target_app_version = ?");
55
- params.push(conditions.targetAppVersion);
317
+ };
318
+ const countD1Rows = async (executor, input) => {
319
+ const where = buildD1Where(input.where);
320
+ const table = d1TableNames[input.model];
321
+ const source = input.distinct === void 0 ? `${table}${where.sql}` : `(SELECT DISTINCT ${input.distinct.join(", ")} FROM ${table}${where.sql}) AS distinct_rows`;
322
+ const row = (await executor.query(`SELECT COUNT(*) AS count FROM ${source}`, where.params))[0];
323
+ if (typeof row !== "object" || row === null || Array.isArray(row)) throw new D1QueryResultError(row);
324
+ const count = Reflect.get(row, "count");
325
+ if (typeof count !== "number" || !Number.isFinite(count)) throw new D1QueryResultError(row);
326
+ return count;
327
+ };
328
+ const findManyD1Rows = async (executor, input) => {
329
+ const where = buildD1Where(input.where);
330
+ const orderBy = input.orderBy;
331
+ const order = buildD1Order(orderBy);
332
+ const table = d1TableNames[input.model];
333
+ const source = input.distinctOn === void 0 ? `SELECT * FROM ${table}${where.sql}${order}` : `SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ${input.distinctOn.fields.join(", ")}${order}) AS __hot_updater_rank FROM ${table}${where.sql}) AS distinct_rows WHERE __hot_updater_rank = 1${order}`;
334
+ const pageParams = encodeD1Values([input.limit, input.offset]);
335
+ const rows = await executor.query(`${source} LIMIT json_extract(?, '$') OFFSET json_extract(?, '$')`, [...where.params, ...pageParams]);
336
+ switch (input.model) {
337
+ case "bundles": return rows.map((row) => parseD1Row("bundles", row));
338
+ case "bundle_patches": return rows.map((row) => parseD1Row("bundle_patches", row));
339
+ case "channels": return rows.map((row) => parseD1Row("channels", row));
340
+ case "bundle_events": return rows.map((row) => parseD1Row("bundle_events", row));
341
+ case "api_keys": return rows.map((row) => parseD1Row("api_keys", row));
342
+ case "releases": return rows.map((row) => parseD1Row("releases", row));
343
+ case "release_catalogs": return rows.map((row) => parseD1Row("release_catalogs", row));
56
344
  }
57
- if (conditions.targetAppVersionIn) clauses.push(buildJsonEachInClause("target_app_version", conditions.targetAppVersionIn, params));
58
- if (conditions.fingerprintHash !== void 0) if (conditions.fingerprintHash === null) clauses.push("fingerprint_hash IS NULL");
59
- else {
60
- clauses.push("fingerprint_hash = ?");
61
- params.push(conditions.fingerprintHash);
345
+ };
346
+ //#endregion
347
+ //#region src/d1Implementation.ts
348
+ const EXPECTATION_CONFLICT_MARKER = "HOT_UPDATER_COMMIT_EXPECTATION_CONFLICT";
349
+ const expectationGuard = (expectations) => {
350
+ const clauses = [];
351
+ const params = [];
352
+ for (const expectation of expectations) {
353
+ const isRelease = expectation.model === "releases";
354
+ const table = isRelease ? "releases" : "release_catalogs";
355
+ const keyField = isRelease ? "id" : "scope_key";
356
+ const versionField = isRelease ? "revision" : "generation";
357
+ const key = isRelease ? expectation.id : expectation.scopeKey;
358
+ const version = isRelease ? expectation.revision : expectation.generation;
359
+ if (version === null) {
360
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${table} WHERE ${keyField} = json_extract(?, '$'))`);
361
+ params.push(...encodeD1Values([key]));
362
+ } else {
363
+ clauses.push(`EXISTS (SELECT 1 FROM ${table} WHERE ${keyField} = json_extract(?, '$') AND ${versionField} = json_extract(?, '$'))`);
364
+ params.push(...encodeD1Values([key, version]));
365
+ }
62
366
  }
63
367
  return {
64
- sql: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "",
368
+ sql: clauses.join(" AND ") || "1",
65
369
  params
66
370
  };
67
- }
68
- function parseTargetCohorts(value) {
69
- if (!value) return null;
70
- if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
71
- if (typeof value === "string") try {
72
- const parsed = JSON.parse(value);
73
- if (Array.isArray(parsed)) return parsed.filter((item) => typeof item === "string");
74
- } catch {
75
- return null;
371
+ };
372
+ const readVersion = (row, field) => {
373
+ if (typeof row !== "object" || row === null) return null;
374
+ const value = Reflect.get(row, field);
375
+ return typeof value === "number" ? value : null;
376
+ };
377
+ const expectationConflict = async (executor, expectations) => {
378
+ for (const expectation of expectations) {
379
+ const isRelease = expectation.model === "releases";
380
+ const table = isRelease ? "releases" : "release_catalogs";
381
+ const keyField = isRelease ? "id" : "scope_key";
382
+ const versionField = isRelease ? "revision" : "generation";
383
+ const key = isRelease ? expectation.id : expectation.scopeKey;
384
+ const expectedVersion = isRelease ? expectation.revision : expectation.generation;
385
+ const actualVersion = readVersion((await executor.query(`SELECT ${versionField} FROM ${table} WHERE ${keyField} = json_extract(?, '$') LIMIT 1`, encodeD1Values([key])))[0], versionField);
386
+ if (actualVersion !== expectedVersion) return {
387
+ committed: false,
388
+ conflict: {
389
+ actualVersion,
390
+ changeIndex: -1,
391
+ expectedVersion,
392
+ key,
393
+ model: expectation.model,
394
+ reason: "version_conflict"
395
+ }
396
+ };
76
397
  }
77
398
  return null;
78
- }
79
- const parseMetadata = (value) => {
80
- if (!value) return void 0;
81
- if (typeof value === "string") try {
82
- return parseMetadata(JSON.parse(value));
83
- } catch {
84
- return;
399
+ };
400
+ const isExpectationConflictError = (error) => {
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ return message.includes(EXPECTATION_CONFLICT_MARKER) || message.toLowerCase().includes("malformed json");
403
+ };
404
+ const bundleValues = (row) => [
405
+ row.id,
406
+ row.platform,
407
+ row.file_hash,
408
+ row.git_commit_hash,
409
+ row.storage_uri,
410
+ row.archive_byte_size,
411
+ row.metadata,
412
+ row.manifest_storage_uri,
413
+ row.manifest_file_hash,
414
+ row.asset_base_storage_uri
415
+ ];
416
+ const patchValues = (row) => [
417
+ row.id,
418
+ row.bundle_id,
419
+ row.base_bundle_id,
420
+ row.base_file_hash,
421
+ row.patch_file_hash,
422
+ row.patch_storage_uri,
423
+ row.byte_size,
424
+ row.order_index
425
+ ];
426
+ const channelValues = (row) => [row.id, row.name];
427
+ const insertQuery = (input, guard, conflictMode = "returnExisting") => {
428
+ let columns;
429
+ let values;
430
+ switch (input.model) {
431
+ case "bundles":
432
+ columns = [
433
+ "id",
434
+ "platform",
435
+ "file_hash",
436
+ "git_commit_hash",
437
+ "storage_uri",
438
+ "archive_byte_size",
439
+ "metadata",
440
+ "manifest_storage_uri",
441
+ "manifest_file_hash",
442
+ "asset_base_storage_uri"
443
+ ];
444
+ values = bundleValues(input.data);
445
+ break;
446
+ case "bundle_patches":
447
+ columns = [
448
+ "id",
449
+ "bundle_id",
450
+ "base_bundle_id",
451
+ "base_file_hash",
452
+ "patch_file_hash",
453
+ "patch_storage_uri",
454
+ "byte_size",
455
+ "order_index"
456
+ ];
457
+ values = patchValues(input.data);
458
+ break;
459
+ case "channels":
460
+ columns = ["id", "name"];
461
+ values = channelValues(input.data);
462
+ break;
463
+ case "bundle_events":
464
+ case "api_keys":
465
+ case "release_catalogs":
466
+ case "releases":
467
+ columns = Object.keys(input.data);
468
+ values = Object.values(input.data);
469
+ break;
85
470
  }
86
- return typeof value === "object" && !Array.isArray(value) ? value : void 0;
87
- };
88
- const buildBundlePatchId = (bundleId, baseBundleId) => `${bundleId}:${baseBundleId}`;
89
- const bundleToPatchRows = (bundle) => (0, _hot_updater_core.getBundlePatches)(bundle).map((patch, index) => ({
90
- id: buildBundlePatchId(bundle.id, patch.baseBundleId),
91
- bundle_id: bundle.id,
92
- base_bundle_id: patch.baseBundleId,
93
- base_file_hash: patch.baseFileHash,
94
- patch_file_hash: patch.patchFileHash,
95
- patch_storage_uri: patch.patchStorageUri,
96
- order_index: index
97
- }));
98
- function transformRowToBundle(row, patchRows = []) {
99
- const rawMetadata = parseMetadata(row.metadata);
100
- const patches = patchRows.slice().sort((left, right) => (left.order_index ?? 0) - (right.order_index ?? 0) || left.base_bundle_id.localeCompare(right.base_bundle_id)).map((patch) => ({
101
- baseBundleId: patch.base_bundle_id,
102
- baseFileHash: patch.base_file_hash,
103
- patchFileHash: patch.patch_file_hash,
104
- patchStorageUri: patch.patch_storage_uri
105
- }));
106
- const primaryPatch = patches[0] ?? null;
471
+ const conflict = input.onConflict !== "ignore" ? "" : input.model === "channels" ? conflictMode === "ignore" ? " ON CONFLICT(name) DO NOTHING" : " ON CONFLICT(name) DO UPDATE SET name = excluded.name" : input.model === "api_keys" ? conflictMode === "ignore" ? " ON CONFLICT(hash) DO NOTHING" : " ON CONFLICT(hash) DO UPDATE SET hash = excluded.hash" : "";
107
472
  return {
108
- id: row.id,
109
- channel: row.channel,
110
- enabled: Boolean(row.enabled),
111
- shouldForceUpdate: Boolean(row.should_force_update),
112
- fileHash: row.file_hash,
113
- gitCommitHash: row.git_commit_hash,
114
- message: row.message,
115
- platform: row.platform,
116
- targetAppVersion: row.target_app_version,
117
- storageUri: row.storage_uri,
118
- fingerprintHash: row.fingerprint_hash,
119
- metadata: (0, _hot_updater_core.stripBundleArtifactMetadata)(rawMetadata),
120
- manifestStorageUri: row.manifest_storage_uri ?? null,
121
- manifestFileHash: row.manifest_file_hash ?? null,
122
- assetBaseStorageUri: row.asset_base_storage_uri ?? null,
123
- patches,
124
- patchBaseBundleId: primaryPatch?.baseBundleId ?? null,
125
- patchBaseFileHash: primaryPatch?.baseFileHash ?? null,
126
- patchFileHash: primaryPatch?.patchFileHash ?? null,
127
- patchStorageUri: primaryPatch?.patchStorageUri ?? null,
128
- rolloutCohortCount: row.rollout_cohort_count ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT,
129
- targetCohorts: parseTargetCohorts(row.target_cohorts)
473
+ sql: `INSERT INTO ${d1TableNames[input.model]} (${columns.join(", ")}) ${guard === void 0 ? `VALUES (${d1Placeholders(values.length)})` : `SELECT ${d1Placeholders(values.length)} WHERE ${guard.sql}`}${conflict} RETURNING *`,
474
+ params: [...encodeD1Values(values), ...guard?.params ?? []]
130
475
  };
131
- }
132
- const resolveDbFromContext = (context) => {
133
- const db = context?.env?.DB;
134
- if (!db) throw new Error("d1WorkerDatabase requires env.DB in the hot updater context.");
135
- return db;
136
- };
137
- const d1WorkerDatabase = () => (0, _hot_updater_plugin_core.createDatabasePlugin)({
138
- name: "d1WorkerDatabase",
139
- factory: (config) => {
140
- const queryAll = async (sql, params = [], context) => {
141
- return (await config.getDb(context).prepare(sql).bind(...params).all()).results ?? [];
142
- };
143
- const queryFirst = async (sql, params = [], context) => {
144
- return await config.getDb(context).prepare(sql).bind(...params).first() ?? null;
145
- };
146
- const getPatchMap = async (bundleIds, context) => {
147
- const patchMap = /* @__PURE__ */ new Map();
148
- if (bundleIds.length === 0) return patchMap;
149
- const rows = await queryAll(`
150
- SELECT *
151
- FROM bundle_patches
152
- WHERE bundle_id IN (SELECT value FROM json_each(?))
153
- ORDER BY order_index ASC, base_bundle_id ASC
154
- `, [JSON.stringify(bundleIds)], context);
155
- for (const row of rows) {
156
- const current = patchMap.get(row.bundle_id) ?? [];
157
- current.push(row);
158
- patchMap.set(row.bundle_id, current);
159
- }
160
- return patchMap;
161
- };
162
- const queryBundlesForUpdateInfo = async (conditions, context) => {
163
- const { sql: whereClause, params } = buildWhereClause(conditions);
164
- const rows = await queryAll(`
165
- SELECT * FROM bundles
166
- ${whereClause}
167
- `, params, context);
168
- const patchMap = await getPatchMap(rows.map((row) => row.id), context);
169
- return rows.map((row) => transformRowToBundle(row, patchMap.get(row.id)));
476
+ };
477
+ const updateEntries = (update) => Object.entries(update);
478
+ const updateQuery = (input, guard) => {
479
+ const entries = updateEntries(input.update);
480
+ const where = buildD1Where(input.where);
481
+ if (entries.length === 0) return {
482
+ sql: `SELECT * FROM ${d1TableNames[input.model]}${where.sql}${guard ? ` AND ${guard.sql}` : ""} LIMIT 1`,
483
+ params: [...where.params, ...guard?.params ?? []]
484
+ };
485
+ const assignments = entries.map(([field]) => `${field} = json_extract(?, '$')`).join(", ");
486
+ return {
487
+ sql: `UPDATE ${d1TableNames[input.model]} SET ${assignments}${where.sql}${guard ? ` AND ${guard.sql}` : ""} RETURNING *`,
488
+ params: [
489
+ ...encodeD1Values(entries.map(([, value]) => value)),
490
+ ...where.params,
491
+ ...guard?.params ?? []
492
+ ]
493
+ };
494
+ };
495
+ const deleteQuery = (input, guard) => {
496
+ const where = buildD1Where(input.where);
497
+ return {
498
+ sql: `DELETE FROM ${d1TableNames[input.model]}${where.sql}${guard ? ` AND ${guard.sql}` : ""}`,
499
+ params: [...where.params, ...guard?.params ?? []]
500
+ };
501
+ };
502
+ const insertedKey = (change) => {
503
+ if (change.operation !== "insert") return void 0;
504
+ if (change.model === "bundles") return `bundles:${change.row.id}`;
505
+ if (change.model === "apiKeys") return `api_keys:${change.row.id}`;
506
+ if (change.model === "releases") return `releases:${change.row.id}`;
507
+ };
508
+ const requiredRow = (change, changeIndex) => {
509
+ if (change.operation !== "update") return void 0;
510
+ if (change.model === "bundles") return {
511
+ model: "bundles",
512
+ id: change.where.id,
513
+ changeIndex
514
+ };
515
+ if (change.model === "apiKeys") return {
516
+ model: "api_keys",
517
+ id: change.where.id,
518
+ changeIndex
519
+ };
520
+ if (change.model === "releases") return {
521
+ model: "releases",
522
+ id: change.where.id,
523
+ changeIndex
524
+ };
525
+ };
526
+ const channelDeletePrecondition = (changes, changeIndex, channelId) => {
527
+ const releaseEffects = /* @__PURE__ */ new Map();
528
+ for (const change of changes.slice(0, changeIndex)) {
529
+ if (change.model !== "releases") continue;
530
+ switch (change.operation) {
531
+ case "insert":
532
+ releaseEffects.set(change.row.id, change.row.channel_id);
533
+ break;
534
+ case "update": break;
535
+ case "delete":
536
+ releaseEffects.set(change.where.id, null);
537
+ break;
538
+ }
539
+ }
540
+ return {
541
+ id: channelId,
542
+ excludedReleaseIds: [...releaseEffects.keys()],
543
+ addedReferenceReleaseIds: [...releaseEffects].filter(([, finalChannelId]) => finalChannelId === channelId).map(([releaseId]) => releaseId)
544
+ };
545
+ };
546
+ const changeQuery = (change, guard) => {
547
+ switch (change.model) {
548
+ case "bundles": switch (change.operation) {
549
+ case "insert": return insertQuery({
550
+ model: "bundles",
551
+ data: change.row
552
+ }, guard);
553
+ case "update": return updateQuery({
554
+ model: "bundles",
555
+ where: [{
556
+ field: "id",
557
+ value: change.where.id
558
+ }],
559
+ update: change.update
560
+ }, guard);
561
+ case "delete": return deleteQuery({
562
+ model: "bundles",
563
+ where: [{
564
+ field: "id",
565
+ value: change.where.id
566
+ }]
567
+ }, guard);
568
+ }
569
+ case "bundlePatches": return change.operation === "insert" ? insertQuery({
570
+ model: "bundle_patches",
571
+ data: change.row
572
+ }, guard) : deleteQuery({
573
+ model: "bundle_patches",
574
+ where: [{
575
+ field: "bundle_id",
576
+ value: change.where.bundleId
577
+ }]
578
+ }, guard);
579
+ case "releases": switch (change.operation) {
580
+ case "insert": return insertQuery({
581
+ model: "releases",
582
+ data: change.row
583
+ }, guard);
584
+ case "update": return updateQuery({
585
+ model: "releases",
586
+ where: [{
587
+ field: "id",
588
+ value: change.where.id
589
+ }],
590
+ update: change.update
591
+ }, guard);
592
+ case "delete": return deleteQuery({
593
+ model: "releases",
594
+ where: [{
595
+ field: "id",
596
+ value: change.where.id
597
+ }]
598
+ }, guard);
599
+ }
600
+ case "releaseCatalogs": return {
601
+ sql: `INSERT INTO release_catalogs (${Object.keys(change.row).join(", ")}) SELECT ${d1Placeholders(Object.keys(change.row).length)} WHERE ${guard.sql} ON CONFLICT(scope_key) DO UPDATE SET ${Object.keys(change.row).filter((field) => field !== "scope_key").map((field) => `${field} = excluded.${field}`).join(", ")} RETURNING *`,
602
+ params: [...encodeD1Values(Object.values(change.row)), ...guard.params]
170
603
  };
171
- const getTargetAppVersionsForUpdateInfo = async ({ platform, channel, minBundleId }, context) => {
172
- return (await queryAll(`
173
- SELECT target_app_version
174
- FROM bundles
175
- WHERE channel = ?
176
- AND platform = ?
177
- AND enabled = 1
178
- AND id >= ?
179
- AND target_app_version IS NOT NULL
180
- GROUP BY target_app_version
181
- `, [
182
- channel,
183
- platform,
184
- minBundleId
185
- ], context)).map((row) => row.target_app_version);
604
+ case "channels": return change.operation === "insert" ? insertQuery({
605
+ model: "channels",
606
+ data: change.row,
607
+ onConflict: change.onConflict
608
+ }, guard, "ignore") : {
609
+ sql: `DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS (SELECT 1 FROM releases WHERE channel_id = json_extract(?, '$')) AND ${guard.sql}`,
610
+ params: [...encodeD1Values([change.where.id, change.where.id]), ...guard.params]
186
611
  };
187
- return {
188
- async getUpdateInfo(args, context) {
189
- const channel = args.channel ?? "production";
190
- const minBundleId = args.minBundleId ?? _hot_updater_core.NIL_UUID;
191
- if (args._updateStrategy === "appVersion") {
192
- const compatibleAppVersions = (0, _hot_updater_plugin_core.filterCompatibleAppVersions)(await getTargetAppVersionsForUpdateInfo({
193
- platform: args.platform,
194
- channel,
195
- minBundleId
196
- }, context), args.appVersion);
197
- const bundles = compatibleAppVersions.length > 0 ? await queryBundlesForUpdateInfo({
198
- enabled: true,
199
- platform: args.platform,
200
- channel,
201
- id: { gte: minBundleId },
202
- targetAppVersionIn: compatibleAppVersions
203
- }, context) : [];
204
- return (0, _hot_updater_plugin_core.resolveUpdateInfoFromBundles)({
205
- args: {
206
- ...args,
207
- channel,
208
- minBundleId
209
- },
210
- bundles,
211
- context
212
- });
213
- }
214
- const bundles = await queryBundlesForUpdateInfo({
215
- enabled: true,
216
- platform: args.platform,
217
- channel,
218
- id: { gte: minBundleId },
219
- fingerprintHash: args.fingerprintHash
220
- }, context);
221
- return (0, _hot_updater_plugin_core.resolveUpdateInfoFromBundles)({
222
- args: {
223
- ...args,
224
- channel,
225
- minBundleId
226
- },
227
- bundles,
228
- context
612
+ case "analytics": return insertQuery({
613
+ model: "bundle_events",
614
+ data: change.row
615
+ }, guard);
616
+ case "apiKeys": return change.operation === "insert" ? insertQuery({
617
+ model: "api_keys",
618
+ data: change.row,
619
+ onConflict: change.onConflict
620
+ }, guard, "ignore") : updateQuery({
621
+ model: "api_keys",
622
+ where: [{
623
+ field: "id",
624
+ value: change.where.id
625
+ }],
626
+ update: { revoked_at_ms: change.update.revokedAtMs }
627
+ }, guard);
628
+ }
629
+ };
630
+ const createCommitPlan = (input) => {
631
+ const checks = [];
632
+ const statements = [];
633
+ const expectations = input.expectations ?? [];
634
+ if (expectations.length > 0) {
635
+ const guard = expectationGuard(expectations);
636
+ statements.push({
637
+ sql: `SELECT CASE WHEN ${guard.sql} THEN 1 ELSE json_extract('${EXPECTATION_CONFLICT_MARKER}', '$') END AS expectation_guard`,
638
+ params: guard.params
639
+ });
640
+ }
641
+ const requiredRows = [];
642
+ const channelDeletes = [];
643
+ const inserted = /* @__PURE__ */ new Set();
644
+ for (const [changeIndex, change] of input.changes.entries()) {
645
+ const required = requiredRow(change, changeIndex);
646
+ if (required !== void 0) {
647
+ if (!inserted.has(`${required.model}:${required.id}`)) {
648
+ requiredRows.push(required);
649
+ checks.push({
650
+ changeIndex,
651
+ resultIndex: statements.length,
652
+ conflictWhen: "empty",
653
+ reason: "not_found"
654
+ });
655
+ statements.push({
656
+ sql: `SELECT id FROM ${d1TableNames[required.model]} WHERE id = json_extract(?, '$') LIMIT 1`,
657
+ params: encodeD1Values([required.id])
229
658
  });
230
- },
231
- async getBundleById(bundleId, context) {
232
- const [row, patchMap] = await Promise.all([queryFirst("SELECT * FROM bundles WHERE id = ? LIMIT 1", [bundleId], context), getPatchMap([bundleId], context)]);
233
- return row ? transformRowToBundle(row, patchMap.get(bundleId)) : null;
234
- },
235
- async getBundles(options, context) {
236
- const { where, limit, orderBy } = options;
237
- const offset = ("offset" in options ? options.offset : void 0) ?? 0;
238
- const { sql: whereClause, params } = buildWhereClause(where);
239
- const orderSql = orderBy?.direction === "asc" ? "ORDER BY id ASC" : "ORDER BY id DESC";
240
- const total = (await queryAll(`SELECT COUNT(*) as total FROM bundles${whereClause}`, params, context))[0]?.total ?? 0;
241
- const rows = await queryAll(`SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`, [
242
- ...params,
243
- limit,
244
- offset
245
- ], context);
246
- const patchMap = await getPatchMap(rows.map((row) => row.id), context);
247
- return {
248
- data: rows.map((row) => transformRowToBundle(row, patchMap.get(row.id))),
249
- pagination: (0, _hot_updater_plugin_core.calculatePagination)(total, {
250
- limit,
251
- offset
252
- })
253
- };
254
- },
255
- async getChannels(context) {
256
- return (await queryAll("SELECT channel FROM bundles GROUP BY channel", [], context)).map((row) => row.channel);
257
- },
258
- async commitBundle({ changedSets }, context) {
259
- if (changedSets.length === 0) return;
260
- const db = config.getDb(context);
261
- for (const operation of changedSets) {
262
- if (operation.operation === "delete") {
263
- await db.prepare("DELETE FROM bundle_patches WHERE bundle_id = ?").bind(operation.data.id).run();
264
- await db.prepare("DELETE FROM bundle_patches WHERE base_bundle_id = ?").bind(operation.data.id).run();
265
- await db.prepare("DELETE FROM bundles WHERE id = ?").bind(operation.data.id).run();
266
- continue;
267
- }
268
- const bundle = operation.data;
269
- await db.prepare(`
270
- INSERT OR REPLACE INTO bundles (
271
- id,
272
- channel,
273
- enabled,
274
- should_force_update,
275
- file_hash,
276
- git_commit_hash,
277
- message,
278
- platform,
279
- target_app_version,
280
- storage_uri,
281
- fingerprint_hash,
282
- metadata,
283
- manifest_storage_uri,
284
- manifest_file_hash,
285
- asset_base_storage_uri,
286
- rollout_cohort_count,
287
- target_cohorts
288
- )
289
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
290
- `).bind(bundle.id, bundle.channel, bundle.enabled ? 1 : 0, bundle.shouldForceUpdate ? 1 : 0, bundle.fileHash, bundle.gitCommitHash || null, bundle.message || null, bundle.platform, bundle.targetAppVersion, bundle.storageUri, bundle.fingerprintHash, JSON.stringify((0, _hot_updater_core.stripBundleArtifactMetadata)(bundle.metadata) ?? {}), (0, _hot_updater_core.getManifestStorageUri)(bundle), (0, _hot_updater_core.getManifestFileHash)(bundle), (0, _hot_updater_core.getAssetBaseStorageUri)(bundle), bundle.rolloutCohortCount ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT, bundle.targetCohorts ? JSON.stringify(bundle.targetCohorts) : null).run();
291
- await db.prepare("DELETE FROM bundle_patches WHERE bundle_id = ?").bind(bundle.id).run();
292
- const patchRows = bundleToPatchRows(bundle);
293
- for (const patchRow of patchRows) await db.prepare(`
294
- INSERT OR REPLACE INTO bundle_patches (
295
- id,
296
- bundle_id,
297
- base_bundle_id,
298
- base_file_hash,
299
- patch_file_hash,
300
- patch_storage_uri,
301
- order_index
302
- )
303
- VALUES (?, ?, ?, ?, ?, ?, ?)
304
- `).bind(patchRow.id, patchRow.bundle_id, patchRow.base_bundle_id, patchRow.base_file_hash, patchRow.patch_file_hash, patchRow.patch_storage_uri, patchRow.order_index ?? 0).run();
305
- }
306
659
  }
307
- };
660
+ }
661
+ if (change.model === "channels" && change.operation === "delete") {
662
+ const precondition = channelDeletePrecondition(input.changes, changeIndex, change.where.id);
663
+ channelDeletes.push(precondition);
664
+ checks.push({
665
+ changeIndex,
666
+ resultIndex: statements.length,
667
+ conflictWhen: "nonempty",
668
+ reason: "referenced"
669
+ });
670
+ statements.push({
671
+ sql: "SELECT id FROM releases WHERE channel_id = json_extract(?, '$') AND id NOT IN (SELECT value FROM json_each(?)) UNION ALL SELECT value AS id FROM json_each(?) LIMIT 1",
672
+ params: encodeD1Values([
673
+ precondition.id,
674
+ precondition.excludedReleaseIds,
675
+ precondition.addedReferenceReleaseIds
676
+ ])
677
+ });
678
+ }
679
+ const key = insertedKey(change);
680
+ if (key !== void 0) inserted.add(key);
308
681
  }
309
- })({ getDb: resolveDbFromContext });
310
- //#endregion
311
- //#region src/r2WorkerStorage.ts
312
- const resolveContextValue = async (value, context) => {
313
- return typeof value === "function" ? await value(context) : value;
682
+ const guard = {
683
+ sql: `NOT EXISTS (
684
+ SELECT 1 FROM json_each(?) AS required
685
+ WHERE NOT EXISTS (
686
+ SELECT 1 FROM bundles
687
+ WHERE json_extract(required.value, '$.model') = 'bundles'
688
+ AND bundles.id = json_extract(required.value, '$.id')
689
+ UNION ALL
690
+ SELECT 1 FROM api_keys
691
+ WHERE json_extract(required.value, '$.model') = 'api_keys'
692
+ AND api_keys.id = json_extract(required.value, '$.id')
693
+ UNION ALL
694
+ SELECT 1 FROM channels
695
+ WHERE json_extract(required.value, '$.model') = 'channels'
696
+ AND channels.id = json_extract(required.value, '$.id')
697
+ UNION ALL
698
+ SELECT 1 FROM releases
699
+ WHERE json_extract(required.value, '$.model') = 'releases'
700
+ AND releases.id = json_extract(required.value, '$.id')
701
+ )
702
+ ) AND NOT EXISTS (
703
+ SELECT 1 FROM json_each(?) AS channel_delete
704
+ WHERE EXISTS (
705
+ SELECT 1 FROM releases
706
+ WHERE releases.channel_id = json_extract(channel_delete.value, '$.id')
707
+ AND releases.id NOT IN (
708
+ SELECT value FROM json_each(
709
+ json_extract(channel_delete.value, '$.excludedReleaseIds')
710
+ )
711
+ )
712
+ UNION ALL
713
+ SELECT value FROM json_each(
714
+ json_extract(channel_delete.value, '$.addedReferenceReleaseIds')
715
+ )
716
+ )
717
+ )`,
718
+ params: encodeD1Values([requiredRows.map(({ model, id }) => ({
719
+ model,
720
+ id
721
+ })), channelDeletes])
722
+ };
723
+ statements.push(...input.changes.map((change) => changeQuery(change, guard)));
724
+ return {
725
+ checks,
726
+ statements
727
+ };
728
+ };
729
+ const resultForPlan = (plan, results) => {
730
+ const missing = plan.checks.find(({ conflictWhen, resultIndex }) => conflictWhen === "empty" ? (results[resultIndex]?.length ?? 0) === 0 : (results[resultIndex]?.length ?? 0) > 0);
731
+ return missing === void 0 ? { committed: true } : {
732
+ committed: false,
733
+ conflict: {
734
+ changeIndex: missing.changeIndex,
735
+ reason: missing.reason
736
+ }
737
+ };
738
+ };
739
+ const insertChannel = async (executor, input) => {
740
+ const insert = insertQuery({
741
+ model: "channels",
742
+ data: input.row,
743
+ onConflict: "ignore"
744
+ }, void 0, "ignore");
745
+ const select = {
746
+ sql: "SELECT id, name FROM channels WHERE name = json_extract(?, '$') LIMIT 1",
747
+ params: encodeD1Values([input.row.name])
748
+ };
749
+ const [insertedRows = [], canonicalRows = []] = await executor.batch([insert, select]);
750
+ return {
751
+ row: parseD1Row("channels", canonicalRows[0]),
752
+ inserted: insertedRows.length > 0
753
+ };
314
754
  };
315
- const resolveJwtSecretFromContext = (context) => {
316
- const jwtSecret = context?.env?.JWT_SECRET;
317
- if (!jwtSecret) throw new Error("r2WorkerStorage requires env.JWT_SECRET in the hot updater context.");
318
- return jwtSecret;
755
+ const deleteChannel = async (executor, input) => {
756
+ const [channelRows = [], referenceRows = [], deletedRows = []] = await executor.batch([
757
+ {
758
+ sql: "SELECT id FROM channels WHERE id = json_extract(?, '$') LIMIT 1",
759
+ params: encodeD1Values([input.id])
760
+ },
761
+ {
762
+ sql: "SELECT id FROM releases WHERE channel_id = json_extract(?, '$') LIMIT 1",
763
+ params: encodeD1Values([input.id])
764
+ },
765
+ {
766
+ sql: "DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS (SELECT 1 FROM releases WHERE channel_id = json_extract(?, '$')) RETURNING id",
767
+ params: encodeD1Values([input.id, input.id])
768
+ }
769
+ ]);
770
+ if (channelRows.length === 0) return {
771
+ deleted: false,
772
+ reason: "not_found"
773
+ };
774
+ if (referenceRows.length > 0) return {
775
+ deleted: false,
776
+ reason: "not_empty"
777
+ };
778
+ return deletedRows.length > 0 ? { deleted: true } : {
779
+ deleted: false,
780
+ reason: "not_empty"
781
+ };
319
782
  };
320
- const resolveR2BucketFromContext = (context) => {
321
- const bucket = context?.env?.BUCKET;
322
- if (!bucket) throw new Error("r2WorkerStorage requires env.BUCKET in the hot updater context.");
323
- return bucket;
783
+ const createD1Implementation = (executor) => ({
784
+ async create(input) {
785
+ const query = insertQuery(input);
786
+ const rows = await executor.query(query.sql, query.params);
787
+ switch (input.model) {
788
+ case "bundles": return parseD1Row("bundles", rows[0]);
789
+ case "bundle_patches": return parseD1Row("bundle_patches", rows[0]);
790
+ case "channels": return parseD1Row("channels", rows[0]);
791
+ case "bundle_events": return parseD1Row("bundle_events", rows[0]);
792
+ case "api_keys": return parseD1Row("api_keys", rows[0]);
793
+ case "releases": return parseD1Row("releases", rows[0]);
794
+ case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
795
+ }
796
+ },
797
+ async update(input) {
798
+ const query = updateQuery(input);
799
+ const rows = await executor.query(query.sql, query.params);
800
+ if (rows[0] === void 0) return null;
801
+ switch (input.model) {
802
+ case "bundles": return parseD1Row("bundles", rows[0]);
803
+ case "api_keys": return parseD1Row("api_keys", rows[0]);
804
+ case "releases": return parseD1Row("releases", rows[0]);
805
+ case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
806
+ }
807
+ },
808
+ async delete(input) {
809
+ const query = deleteQuery(input);
810
+ await executor.query(query.sql, query.params);
811
+ },
812
+ count: (input) => countD1Rows(executor, input),
813
+ async findOne(input) {
814
+ const where = buildD1Where(input.where);
815
+ const rows = await executor.query(`SELECT * FROM ${d1TableNames[input.model]}${where.sql} LIMIT 1`, where.params);
816
+ if (rows[0] === void 0) return null;
817
+ switch (input.model) {
818
+ case "bundles": return parseD1Row("bundles", rows[0]);
819
+ case "bundle_patches": return parseD1Row("bundle_patches", rows[0]);
820
+ case "channels": return parseD1Row("channels", rows[0]);
821
+ case "api_keys": return parseD1Row("api_keys", rows[0]);
822
+ case "releases": return parseD1Row("releases", rows[0]);
823
+ case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
824
+ }
825
+ },
826
+ findMany: (input) => findManyD1Rows(executor, input),
827
+ insertChannel: (input) => insertChannel(executor, input),
828
+ deleteChannel: (input) => deleteChannel(executor, input),
829
+ async commit(input) {
830
+ if (input.changes.length === 0) return { committed: true };
831
+ const expectations = input.expectations ?? [];
832
+ const conflict = await expectationConflict(executor, expectations);
833
+ if (conflict !== null) return conflict;
834
+ const plan = createCommitPlan(input);
835
+ try {
836
+ return resultForPlan(plan, await executor.batch(plan.statements));
837
+ } catch (error) {
838
+ if (expectations.length === 0 || !isExpectationConflictError(error)) throw error;
839
+ return await expectationConflict(executor, expectations) ?? {
840
+ committed: false,
841
+ conflict: {
842
+ actualVersion: expectations[0].model === "releases" ? expectations[0].revision : expectations[0].generation,
843
+ changeIndex: -1,
844
+ expectedVersion: expectations[0].model === "releases" ? expectations[0].revision : expectations[0].generation,
845
+ key: expectations[0].model === "releases" ? expectations[0].id : expectations[0].scopeKey,
846
+ model: expectations[0].model,
847
+ reason: "version_conflict"
848
+ }
849
+ };
850
+ }
851
+ }
852
+ });
853
+ //#endregion
854
+ //#region src/worker/d1Database.ts
855
+ const d1Database = (database) => {
856
+ const adapter = (0, _hot_updater_plugin_core_internal.createDatabasePluginAdapter)("d1Database", createD1Implementation({
857
+ async query(sql, params) {
858
+ return (await database.prepare(sql).bind(...params).all()).results ?? [];
859
+ },
860
+ async batch(statements) {
861
+ return (await database.batch(statements.map(({ sql, params }) => database.prepare(sql).bind(...params)))).map(({ results }) => results ?? []);
862
+ }
863
+ }));
864
+ return (0, _hot_updater_plugin_core.createDatabasePlugin)({
865
+ name: "d1Database",
866
+ models: adapter.models,
867
+ commit: adapter.commit,
868
+ ...adapter.dispose ? { dispose: adapter.dispose } : {}
869
+ });
324
870
  };
325
- const createPublicObjectPath = (storageUrl) => `${storageUrl.host}${storageUrl.pathname}`;
326
- const createR2ObjectKey = (storageUrl) => (0, _hot_updater_plugin_core.decodeStorageObjectKey)(storageUrl.pathname.replace(/^\/+/, ""));
871
+ //#endregion
872
+ //#region src/r2WorkerStorage.ts
327
873
  const r2WorkerStorage = (config) => {
328
- return (0, _hot_updater_plugin_core.createRuntimeStoragePlugin)({
329
- name: "r2WorkerStorage",
330
- supportedProtocol: "r2",
331
- factory: (config) => ({
332
- async readText(storageUri, context) {
333
- const storageUrl = new URL(storageUri);
334
- if (storageUrl.protocol !== "r2:") throw new Error("Invalid R2 storage URI protocol");
335
- const bucket = resolveR2BucketFromContext(context);
336
- const key = createR2ObjectKey(storageUrl);
337
- const object = await bucket.get(key);
338
- if (!object) return null;
339
- return object.text();
340
- },
341
- async getDownloadUrl(storageUri, context) {
342
- const storageUrl = new URL(storageUri);
343
- if (storageUrl.protocol !== "r2:") throw new Error("Invalid R2 storage URI protocol");
344
- const key = createPublicObjectPath(storageUrl);
345
- const [jwtSecret, publicBaseUrl] = await Promise.all([resolveContextValue(config.jwtSecret ?? resolveJwtSecretFromContext, context), resolveContextValue(config.publicBaseUrl, context)]);
346
- const token = await (0, _hot_updater_js.signToken)(key, jwtSecret);
347
- const url = new URL(publicBaseUrl);
348
- url.pathname = key;
349
- url.search = "";
350
- url.searchParams.set("token", token);
351
- return { fileUrl: url.toString() };
874
+ const getStorageKey = (0, _hot_updater_plugin_core.createStorageKeyBuilder)(config.basePath);
875
+ const getDownloadUrl = (0, _hot_updater_plugin_core.createStorageDownloadUrl)(config.downloadUrlSigningKey);
876
+ const parseAndValidate = (storageUri) => {
877
+ const parsed = (0, _hot_updater_plugin_core.parseStorageUri)(storageUri, "r2");
878
+ if (parsed.bucket !== config.bucketName) throw new Error(`Bucket name mismatch: expected "${config.bucketName}", but found "${parsed.bucket}".`);
879
+ return parsed;
880
+ };
881
+ return (0, _hot_updater_plugin_core.createStoragePlugin)({
882
+ name: "r2Storage",
883
+ protocol: "r2",
884
+ async put({ key, body, contentLength, contentType }) {
885
+ const storageKey = getStorageKey(key);
886
+ const uploadOptions = { httpMetadata: {
887
+ contentType,
888
+ cacheControl: "max-age=31536000"
889
+ } };
890
+ if (contentLength === void 0) {
891
+ const bufferedBody = new Uint8Array(await new Response(body).arrayBuffer());
892
+ await config.bucket.put(storageKey, bufferedBody, uploadOptions);
893
+ } else {
894
+ const fixedLengthBody = new FixedLengthStream(contentLength);
895
+ await Promise.all([body.pipeTo(fixedLengthBody.writable), config.bucket.put(storageKey, fixedLengthBody.readable, uploadOptions)]);
352
896
  }
353
- })
354
- })(config);
897
+ return { storageUri: (0, _hot_updater_plugin_core.createStorageUri)({
898
+ protocol: "r2",
899
+ bucket: config.bucketName,
900
+ key: storageKey
901
+ }) };
902
+ },
903
+ async get({ storageUri }) {
904
+ const { key } = parseAndValidate(storageUri);
905
+ const object = await config.bucket.get(key);
906
+ if (!object) return { response: null };
907
+ const headers = new Headers();
908
+ object.writeHttpMetadata(headers);
909
+ headers.set("etag", object.httpEtag);
910
+ headers.set("content-length", String(object.size));
911
+ return { response: new Response(object.body, { headers }) };
912
+ },
913
+ async getDownloadUrl({ storageUri }) {
914
+ parseAndValidate(storageUri);
915
+ return getDownloadUrl({ storageUri });
916
+ },
917
+ async exists({ storageUri }) {
918
+ const { key } = parseAndValidate(storageUri);
919
+ return { exists: await config.bucket.head(key) !== null };
920
+ },
921
+ async delete({ storageUri }) {
922
+ const { key } = parseAndValidate(storageUri);
923
+ await config.bucket.delete(key);
924
+ return { deleted: true };
925
+ }
926
+ });
355
927
  };
356
928
  //#endregion
357
- //#region src/worker/index.ts
358
- const d1Database = () => d1WorkerDatabase();
359
- const r2Storage = (config) => r2WorkerStorage(config);
360
- //#endregion
361
929
  exports.d1Database = d1Database;
362
- exports.r2Storage = r2Storage;
363
- Object.defineProperty(exports, "verifyJwtSignedUrl", {
364
- enumerable: true,
365
- get: function() {
366
- return _hot_updater_js.verifyJwtSignedUrl;
367
- }
368
- });
930
+ exports.r2Storage = r2WorkerStorage;