@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
@@ -0,0 +1,46 @@
1
+ import { expect, it } from "vitest";
2
+
3
+ import type { D1Executor } from "./d1Implementation";
4
+ import { countD1Rows, D1QueryResultError } from "./d1Query";
5
+
6
+ const countWithResult = (result: readonly unknown[]): Promise<number> => {
7
+ const executor: D1Executor = {
8
+ query: async () => result,
9
+ batch: async () => [],
10
+ };
11
+
12
+ return countD1Rows(executor, { model: "bundles" });
13
+ };
14
+
15
+ it("preserves a zero count", async () => {
16
+ const count = countWithResult([{ count: 0 }]);
17
+
18
+ await expect(count).resolves.toBe(0);
19
+ });
20
+
21
+ it.each([
22
+ ["a missing first row", []],
23
+ ["a null first row", [null]],
24
+ ["a primitive first row", ["invalid"]],
25
+ ["an array first row", [[0]]],
26
+ ["a missing count property", [{}]],
27
+ ["a numeric string count", [{ count: "1" }]],
28
+ ["a NaN count", [{ count: Number.NaN }]],
29
+ ["an infinite count", [{ count: Number.POSITIVE_INFINITY }]],
30
+ ] as const)("rejects %s", async (_description, rows) => {
31
+ const count = countWithResult(rows);
32
+
33
+ await expect(count).rejects.toBeInstanceOf(D1QueryResultError);
34
+ });
35
+
36
+ it("includes the malformed count row in the provider error", async () => {
37
+ const row = { count: "1" };
38
+
39
+ const count = countWithResult([row]);
40
+
41
+ await expect(count).rejects.toMatchObject({
42
+ name: "D1QueryResultError",
43
+ operation: "count",
44
+ result: row,
45
+ });
46
+ });
package/src/d1Query.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type {
2
+ CountDatabaseImplementationInput,
3
+ DatabaseImplementationResult,
4
+ DatabaseModel,
5
+ FindManyDatabaseImplementationInput,
6
+ } from "@hot-updater/plugin-core/internal";
7
+
8
+ import type { D1Executor } from "./d1Implementation";
9
+ import { parseD1Row } from "./d1Rows";
10
+ import { buildD1Order, buildD1Where, encodeD1Values } from "./d1Sql";
11
+
12
+ export const d1TableNames = {
13
+ bundles: "bundles",
14
+ bundle_patches: "bundle_patches",
15
+ channels: "channels",
16
+ bundle_events: "bundle_events",
17
+ api_keys: "api_keys",
18
+ release_catalogs: "release_catalogs",
19
+ releases: "releases",
20
+ } as const satisfies Record<DatabaseModel, string>;
21
+
22
+ export class D1QueryResultError extends Error {
23
+ readonly name = "D1QueryResultError";
24
+ readonly operation = "count";
25
+
26
+ constructor(readonly result: unknown) {
27
+ super("D1 count query returned a malformed first row");
28
+ }
29
+ }
30
+
31
+ export const countD1Rows = async (
32
+ executor: D1Executor,
33
+ input: CountDatabaseImplementationInput,
34
+ ): Promise<number> => {
35
+ const where = buildD1Where(input.where);
36
+ const table = d1TableNames[input.model];
37
+ const source =
38
+ input.distinct === undefined
39
+ ? `${table}${where.sql}`
40
+ : `(SELECT DISTINCT ${input.distinct.join(", ")} FROM ${table}${where.sql}) AS distinct_rows`;
41
+ const rows = await executor.query(
42
+ `SELECT COUNT(*) AS count FROM ${source}`,
43
+ where.params,
44
+ );
45
+ const row = rows[0];
46
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
47
+ throw new D1QueryResultError(row);
48
+ }
49
+ const count = Reflect.get(row, "count");
50
+ if (typeof count !== "number" || !Number.isFinite(count)) {
51
+ throw new D1QueryResultError(row);
52
+ }
53
+ return count;
54
+ };
55
+
56
+ export const findManyD1Rows = async (
57
+ executor: D1Executor,
58
+ input: FindManyDatabaseImplementationInput,
59
+ ): Promise<readonly DatabaseImplementationResult[]> => {
60
+ const where = buildD1Where(input.where);
61
+ const orderBy = input.orderBy;
62
+ const order = buildD1Order(orderBy);
63
+ const table = d1TableNames[input.model];
64
+ const source =
65
+ input.distinctOn === undefined
66
+ ? `SELECT * FROM ${table}${where.sql}${order}`
67
+ : `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}`;
68
+ const pageParams = encodeD1Values([input.limit, input.offset]);
69
+ const rows = await executor.query(
70
+ `${source} LIMIT json_extract(?, '$') OFFSET json_extract(?, '$')`,
71
+ [...where.params, ...pageParams],
72
+ );
73
+ switch (input.model) {
74
+ case "bundles":
75
+ return rows.map((row) => parseD1Row("bundles", row));
76
+ case "bundle_patches":
77
+ return rows.map((row) => parseD1Row("bundle_patches", row));
78
+ case "channels":
79
+ return rows.map((row) => parseD1Row("channels", row));
80
+ case "bundle_events":
81
+ return rows.map((row) => parseD1Row("bundle_events", row));
82
+ case "api_keys":
83
+ return rows.map((row) => parseD1Row("api_keys", row));
84
+ case "releases":
85
+ return rows.map((row) => parseD1Row("releases", row));
86
+ case "release_catalogs":
87
+ return rows.map((row) => parseD1Row("release_catalogs", row));
88
+ }
89
+ };
@@ -0,0 +1,111 @@
1
+ import { expect, it } from "vitest";
2
+
3
+ import {
4
+ createBundleEventRowFixture,
5
+ createBundlePatchRowFixture,
6
+ } from "../../../packages/test-utils/src/databaseTestFixtures";
7
+ import { parseD1Row } from "./d1Rows";
8
+
9
+ const bundleD1Row = {
10
+ id: "bundle-1",
11
+ platform: "android",
12
+ file_hash: "hash",
13
+ git_commit_hash: null,
14
+ storage_uri: "storage://bundle",
15
+ archive_byte_size: 3_000_000_001,
16
+ metadata: '{"build":1}',
17
+ manifest_storage_uri: null,
18
+ manifest_file_hash: null,
19
+ asset_base_storage_uri: null,
20
+ } as const;
21
+
22
+ it("parses SQLite JSON into an artifact-only Bundle row", () => {
23
+ const row = parseD1Row("bundles", bundleD1Row);
24
+
25
+ expect(row).toMatchObject({
26
+ metadata: { build: 1 },
27
+ });
28
+ });
29
+
30
+ it("defaults a legacy bundle's missing archive byte size", () => {
31
+ const row: Record<string, unknown> = { ...bundleD1Row };
32
+ delete row["archive_byte_size"];
33
+
34
+ expect(parseD1Row("bundles", row)).toMatchObject({
35
+ archive_byte_size: 0,
36
+ });
37
+ });
38
+
39
+ it("rejects an invalid artifact platform", () => {
40
+ expect(() =>
41
+ parseD1Row("bundles", { ...bundleD1Row, platform: "web" }),
42
+ ).toThrow("D1 returned an invalid bundles row");
43
+ });
44
+
45
+ it("rejects corrupt SQLite metadata JSON", () => {
46
+ expect(() =>
47
+ parseD1Row("bundles", { ...bundleD1Row, metadata: "{" }),
48
+ ).toThrow("D1 returned an invalid bundles row");
49
+ });
50
+
51
+ it.each([-1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
52
+ "rejects invalid SQLite archive size %s",
53
+ (archiveByteSize) => {
54
+ expect(() =>
55
+ parseD1Row("bundles", {
56
+ ...bundleD1Row,
57
+ archive_byte_size: archiveByteSize,
58
+ }),
59
+ ).toThrow("D1 returned an invalid bundles row");
60
+ },
61
+ );
62
+
63
+ it("preserves SQLite patch sizes above 2 GiB", () => {
64
+ const patch = createBundlePatchRowFixture("large", "bundle", "base");
65
+
66
+ expect(parseD1Row("bundle_patches", patch)).toMatchObject({
67
+ byte_size: 3_000_000_002,
68
+ });
69
+ });
70
+
71
+ it.each(["null", "[]", "1", '"metadata"'])(
72
+ "rejects non-object SQLite metadata %s",
73
+ (metadata) => {
74
+ expect(() => parseD1Row("bundles", { ...bundleD1Row, metadata })).toThrow(
75
+ "D1 returned an invalid bundles row",
76
+ );
77
+ },
78
+ );
79
+
80
+ it("preserves explicit null Release ids on an Analytics row", () => {
81
+ expect(
82
+ parseD1Row("bundle_events", createBundleEventRowFixture("1", 1)),
83
+ ).toMatchObject({ from_release_id: null, to_release_id: null });
84
+ });
85
+
86
+ it.each(["from_release_id", "to_release_id"])(
87
+ "rejects an omitted Analytics field: %s",
88
+ (field) => {
89
+ const row: Record<string, unknown> = {
90
+ ...createBundleEventRowFixture("1", 1),
91
+ };
92
+ delete row[field];
93
+
94
+ expect(() => parseD1Row("bundle_events", row)).toThrow(
95
+ "D1 returned an invalid bundle_events row",
96
+ );
97
+ },
98
+ );
99
+
100
+ it.each([
101
+ { from_bundle_id: null },
102
+ { to_bundle_id: null },
103
+ { type: "UNCHANGED", from_bundle_id: "bundle-old", update_strategy: null },
104
+ ])("rejects an invalid Analytics direction shape", (overrides) => {
105
+ expect(() =>
106
+ parseD1Row("bundle_events", {
107
+ ...createBundleEventRowFixture("1", 1),
108
+ ...overrides,
109
+ }),
110
+ ).toThrow("D1 returned an invalid bundle_events row");
111
+ });
package/src/d1Rows.ts ADDED
@@ -0,0 +1,326 @@
1
+ import type {
2
+ BundlePatchRow,
3
+ BundleRow,
4
+ BundleEventRow,
5
+ ChannelRow,
6
+ ApiKeyRow,
7
+ ReleaseCatalogRow,
8
+ ReleaseRow,
9
+ } from "@hot-updater/plugin-core";
10
+ import { isDatabaseMetadataObject } from "@hot-updater/plugin-core";
11
+ import type {
12
+ DatabaseModel,
13
+ DatabaseRow,
14
+ } from "@hot-updater/plugin-core/internal";
15
+
16
+ class InvalidD1RowError extends Error {
17
+ readonly name = "InvalidD1RowError";
18
+ readonly model: DatabaseModel;
19
+
20
+ constructor(model: DatabaseModel) {
21
+ super(`D1 returned an invalid ${model} row`);
22
+ this.model = model;
23
+ }
24
+ }
25
+
26
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
27
+ typeof value === "object" && value !== null && !Array.isArray(value);
28
+
29
+ const stringValue = (
30
+ row: Record<string, unknown>,
31
+ field: string,
32
+ model: DatabaseModel,
33
+ ): string => {
34
+ const value = row[field];
35
+ if (typeof value !== "string") throw new InvalidD1RowError(model);
36
+ return value;
37
+ };
38
+
39
+ const nullableString = (
40
+ row: Record<string, unknown>,
41
+ field: string,
42
+ model: DatabaseModel,
43
+ ): string | null => {
44
+ const value = row[field];
45
+ if (value === null) return null;
46
+ if (typeof value !== "string") throw new InvalidD1RowError(model);
47
+ return value;
48
+ };
49
+
50
+ const numberValue = (
51
+ row: Record<string, unknown>,
52
+ field: string,
53
+ model: DatabaseModel,
54
+ ): number => {
55
+ const value = row[field];
56
+ if (typeof value !== "number") throw new InvalidD1RowError(model);
57
+ return value;
58
+ };
59
+
60
+ const byteSizeValue = (
61
+ row: Record<string, unknown>,
62
+ field: string,
63
+ model: DatabaseModel,
64
+ ): number => {
65
+ const value = row[field];
66
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
67
+ throw new InvalidD1RowError(model);
68
+ }
69
+ return value;
70
+ };
71
+
72
+ const booleanValue = (
73
+ row: Record<string, unknown>,
74
+ field: string,
75
+ model: DatabaseModel,
76
+ ): boolean => {
77
+ const value = row[field];
78
+ if (value === true || value === 1) return true;
79
+ if (value === false || value === 0) return false;
80
+ throw new InvalidD1RowError(model);
81
+ };
82
+
83
+ const jsonValue = (value: unknown, model: DatabaseModel): unknown => {
84
+ if (typeof value !== "string") throw new InvalidD1RowError(model);
85
+ try {
86
+ return JSON.parse(value);
87
+ } catch (error) {
88
+ if (error instanceof SyntaxError) throw new InvalidD1RowError(model);
89
+ throw error;
90
+ }
91
+ };
92
+
93
+ const metadata = (row: Record<string, unknown>) => {
94
+ const value = jsonValue(row["metadata"], "bundles");
95
+ if (!isDatabaseMetadataObject(value)) {
96
+ throw new InvalidD1RowError("bundles");
97
+ }
98
+ return value;
99
+ };
100
+
101
+ const bundleRow = (row: Record<string, unknown>): BundleRow => {
102
+ const platform = stringValue(row, "platform", "bundles");
103
+ if (platform !== "ios" && platform !== "android") {
104
+ throw new InvalidD1RowError("bundles");
105
+ }
106
+ return {
107
+ id: stringValue(row, "id", "bundles"),
108
+ platform,
109
+ file_hash: stringValue(row, "file_hash", "bundles"),
110
+ git_commit_hash: nullableString(row, "git_commit_hash", "bundles"),
111
+ storage_uri: stringValue(row, "storage_uri", "bundles"),
112
+ archive_byte_size:
113
+ row["archive_byte_size"] === undefined
114
+ ? 0
115
+ : byteSizeValue(row, "archive_byte_size", "bundles"),
116
+ metadata: metadata(row),
117
+ manifest_storage_uri: nullableString(
118
+ row,
119
+ "manifest_storage_uri",
120
+ "bundles",
121
+ ),
122
+ manifest_file_hash: nullableString(row, "manifest_file_hash", "bundles"),
123
+ asset_base_storage_uri: nullableString(
124
+ row,
125
+ "asset_base_storage_uri",
126
+ "bundles",
127
+ ),
128
+ };
129
+ };
130
+
131
+ const patchRow = (row: Record<string, unknown>): BundlePatchRow => ({
132
+ id: stringValue(row, "id", "bundle_patches"),
133
+ bundle_id: stringValue(row, "bundle_id", "bundle_patches"),
134
+ base_bundle_id: stringValue(row, "base_bundle_id", "bundle_patches"),
135
+ base_file_hash: stringValue(row, "base_file_hash", "bundle_patches"),
136
+ patch_file_hash: stringValue(row, "patch_file_hash", "bundle_patches"),
137
+ patch_storage_uri: stringValue(row, "patch_storage_uri", "bundle_patches"),
138
+ byte_size: byteSizeValue(row, "byte_size", "bundle_patches"),
139
+ order_index: numberValue(row, "order_index", "bundle_patches"),
140
+ });
141
+
142
+ const channelRow = (row: Record<string, unknown>): ChannelRow => ({
143
+ id: stringValue(row, "id", "channels"),
144
+ name: stringValue(row, "name", "channels"),
145
+ });
146
+
147
+ const eventRow = (row: Record<string, unknown>): BundleEventRow => {
148
+ const type = stringValue(row, "type", "bundle_events");
149
+ const platform = stringValue(row, "platform", "bundle_events");
150
+ const fromBundleId = nullableString(row, "from_bundle_id", "bundle_events");
151
+ const updateStrategy = nullableString(
152
+ row,
153
+ "update_strategy",
154
+ "bundle_events",
155
+ );
156
+ if (
157
+ (platform !== "ios" && platform !== "android") ||
158
+ !(
159
+ ((type === "UPDATE_APPLIED" ||
160
+ type === "RECOVERED" ||
161
+ type === "RELEASE_ADOPTED") &&
162
+ typeof fromBundleId === "string" &&
163
+ (updateStrategy === "fingerprint" ||
164
+ updateStrategy === "appVersion")) ||
165
+ (type === "UNCHANGED" && fromBundleId === null && updateStrategy === null)
166
+ )
167
+ ) {
168
+ throw new InvalidD1RowError("bundle_events");
169
+ }
170
+ return {
171
+ id: stringValue(row, "id", "bundle_events"),
172
+ type,
173
+ install_id: stringValue(row, "install_id", "bundle_events"),
174
+ user_id: nullableString(row, "user_id", "bundle_events"),
175
+ username: nullableString(row, "username", "bundle_events"),
176
+ from_release_id: nullableString(row, "from_release_id", "bundle_events"),
177
+ from_bundle_id: fromBundleId,
178
+ to_release_id: nullableString(row, "to_release_id", "bundle_events"),
179
+ to_bundle_id: stringValue(row, "to_bundle_id", "bundle_events"),
180
+ platform,
181
+ app_version: stringValue(row, "app_version", "bundle_events"),
182
+ channel: stringValue(row, "channel", "bundle_events"),
183
+ cohort: stringValue(row, "cohort", "bundle_events"),
184
+ update_strategy: updateStrategy,
185
+ fingerprint_hash: nullableString(row, "fingerprint_hash", "bundle_events"),
186
+ sdk_version: nullableString(row, "sdk_version", "bundle_events"),
187
+ received_at_ms: numberValue(row, "received_at_ms", "bundle_events"),
188
+ } as BundleEventRow;
189
+ };
190
+
191
+ const apiKeyRow = (row: Record<string, unknown>): ApiKeyRow => {
192
+ const role = stringValue(row, "role", "api_keys");
193
+ if (role !== "client") throw new InvalidD1RowError("api_keys");
194
+ const revokedAt = row["revoked_at_ms"];
195
+ return {
196
+ id: stringValue(row, "id", "api_keys"),
197
+ hash: stringValue(row, "hash", "api_keys"),
198
+ name: stringValue(row, "name", "api_keys"),
199
+ prefix: stringValue(row, "prefix", "api_keys"),
200
+ role,
201
+ created_at_ms: numberValue(row, "created_at_ms", "api_keys"),
202
+ revoked_at_ms:
203
+ revokedAt === null ? null : numberValue(row, "revoked_at_ms", "api_keys"),
204
+ };
205
+ };
206
+
207
+ const releaseTargetCohorts = (
208
+ row: Record<string, unknown>,
209
+ ): readonly string[] => {
210
+ const value = jsonValue(row["target_cohorts"], "releases");
211
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
212
+ throw new InvalidD1RowError("releases");
213
+ }
214
+ return value.filter((item): item is string => typeof item === "string");
215
+ };
216
+
217
+ const releaseRow = (row: Record<string, unknown>): ReleaseRow => {
218
+ const platform = stringValue(row, "platform", "releases");
219
+ const kind = stringValue(row, "kind", "releases");
220
+ const strategy = stringValue(row, "strategy", "releases");
221
+ const operation = stringValue(row, "operation", "releases");
222
+ if (
223
+ (platform !== "ios" && platform !== "android") ||
224
+ (kind !== "BUNDLE" && kind !== "EMBEDDED") ||
225
+ (strategy !== "APP_VERSION" && strategy !== "FINGERPRINT") ||
226
+ (operation !== "DEPLOY" &&
227
+ operation !== "PROMOTE" &&
228
+ operation !== "ROLLBACK")
229
+ ) {
230
+ throw new InvalidD1RowError("releases");
231
+ }
232
+ return {
233
+ id: stringValue(row, "id", "releases"),
234
+ revision: numberValue(row, "revision", "releases"),
235
+ scope_key: stringValue(row, "scope_key", "releases"),
236
+ channel_id: stringValue(row, "channel_id", "releases"),
237
+ platform,
238
+ kind,
239
+ bundle_id: nullableString(row, "bundle_id", "releases"),
240
+ strategy,
241
+ target_app_version: nullableString(row, "target_app_version", "releases"),
242
+ fingerprint_hash: nullableString(row, "fingerprint_hash", "releases"),
243
+ enabled: booleanValue(row, "enabled", "releases"),
244
+ should_force_update: booleanValue(row, "should_force_update", "releases"),
245
+ message: nullableString(row, "message", "releases"),
246
+ rollout_cohort_count: numberValue(row, "rollout_cohort_count", "releases"),
247
+ target_cohorts: releaseTargetCohorts(row),
248
+ operation,
249
+ source_release_id: nullableString(row, "source_release_id", "releases"),
250
+ created_at_ms: numberValue(row, "created_at_ms", "releases"),
251
+ updated_at_ms: numberValue(row, "updated_at_ms", "releases"),
252
+ };
253
+ };
254
+
255
+ const releaseCatalogRow = (row: Record<string, unknown>): ReleaseCatalogRow => {
256
+ const platform = stringValue(row, "platform", "release_catalogs");
257
+ const strategy = stringValue(row, "strategy", "release_catalogs");
258
+ if (
259
+ (platform !== "ios" && platform !== "android") ||
260
+ (strategy !== "APP_VERSION" && strategy !== "FINGERPRINT")
261
+ ) {
262
+ throw new InvalidD1RowError("release_catalogs");
263
+ }
264
+ return {
265
+ scope_key: stringValue(row, "scope_key", "release_catalogs"),
266
+ catalog_id: stringValue(row, "catalog_id", "release_catalogs"),
267
+ strategy,
268
+ channel_id: stringValue(row, "channel_id", "release_catalogs"),
269
+ channel_key: stringValue(row, "channel_key", "release_catalogs"),
270
+ platform,
271
+ fingerprint_hash: nullableString(
272
+ row,
273
+ "fingerprint_hash",
274
+ "release_catalogs",
275
+ ),
276
+ generation: numberValue(row, "generation", "release_catalogs"),
277
+ payload: stringValue(row, "payload", "release_catalogs"),
278
+ catalog_hash: stringValue(row, "catalog_hash", "release_catalogs"),
279
+ byte_size: numberValue(row, "byte_size", "release_catalogs"),
280
+ is_tombstone: booleanValue(row, "is_tombstone", "release_catalogs"),
281
+ updated_at_ms: numberValue(row, "updated_at_ms", "release_catalogs"),
282
+ };
283
+ };
284
+
285
+ export function parseD1Row(model: "bundles", value: unknown): BundleRow;
286
+ export function parseD1Row(model: "channels", value: unknown): ChannelRow;
287
+ export function parseD1Row(
288
+ model: "bundle_patches",
289
+ value: unknown,
290
+ ): BundlePatchRow;
291
+ export function parseD1Row(
292
+ model: "bundle_events",
293
+ value: unknown,
294
+ ): BundleEventRow;
295
+ export function parseD1Row(model: "api_keys", value: unknown): ApiKeyRow;
296
+ export function parseD1Row(model: "releases", value: unknown): ReleaseRow;
297
+ export function parseD1Row(
298
+ model: "release_catalogs",
299
+ value: unknown,
300
+ ): ReleaseCatalogRow;
301
+ export function parseD1Row(
302
+ model: DatabaseModel,
303
+ value: unknown,
304
+ ): DatabaseRow<DatabaseModel>;
305
+ export function parseD1Row(
306
+ model: DatabaseModel,
307
+ value: unknown,
308
+ ): DatabaseRow<DatabaseModel> {
309
+ if (!isRecord(value)) throw new InvalidD1RowError(model);
310
+ switch (model) {
311
+ case "bundles":
312
+ return bundleRow(value);
313
+ case "bundle_patches":
314
+ return patchRow(value);
315
+ case "channels":
316
+ return channelRow(value);
317
+ case "bundle_events":
318
+ return eventRow(value);
319
+ case "api_keys":
320
+ return apiKeyRow(value);
321
+ case "releases":
322
+ return releaseRow(value);
323
+ case "release_catalogs":
324
+ return releaseCatalogRow(value);
325
+ }
326
+ }
@@ -0,0 +1,30 @@
1
+ import { expect, it } from "vitest";
2
+
3
+ import { buildD1Where } from "./d1Sql";
4
+
5
+ it("composes predicates left to right with one json_each bind for sets", () => {
6
+ const query = buildD1Where([
7
+ { field: "enabled", value: true },
8
+ { field: "id", operator: "in", value: ["a", "b"], connector: "OR" },
9
+ {
10
+ field: "target_app_version",
11
+ operator: "ne",
12
+ value: null,
13
+ connector: "AND",
14
+ },
15
+ ]);
16
+
17
+ expect(query.sql).toBe(
18
+ " WHERE ((enabled = json_extract(?, '$') OR id IN (SELECT value FROM json_each(?))) AND target_app_version IS NOT NULL)",
19
+ );
20
+ expect(query.params).toEqual(["true", '["a","b"]']);
21
+ });
22
+
23
+ it("makes empty inclusion and exclusion predicates deterministic", () => {
24
+ expect(buildD1Where([{ field: "id", operator: "in", value: [] }]).sql).toBe(
25
+ " WHERE 1 = 0",
26
+ );
27
+ expect(
28
+ buildD1Where([{ field: "id", operator: "not_in", value: [] }]).sql,
29
+ ).toBe(" WHERE 1 = 1");
30
+ });