@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.
Files changed (48) hide show
  1. package/dist/iac/index.cjs +988 -10
  2. package/dist/iac/index.mjs +991 -14
  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 +205 -0
  35. package/worker/src/databaseMigration.integration.spec.ts +130 -0
  36. package/worker/src/databasePlugins.integration.spec.ts +292 -0
  37. package/worker/src/index.integration.spec.ts +102 -294
  38. package/worker/src/index.ts +14 -66
  39. package/worker/src/storagePlugin.integration.spec.ts +59 -0
  40. package/worker/wrangler.json +5 -2
  41. package/src/cloudflareWorkerDatabase.spec.ts +0 -260
  42. package/src/cloudflareWorkerDatabase.ts +0 -651
  43. package/src/r2WranglerStorage.ts +0 -193
  44. package/worker/migrations/0001_hot-updater_init.sql +0 -16
  45. package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
  46. package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
  47. package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
  48. package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
@@ -0,0 +1,205 @@
1
+ -- HotUpdater schema 1.0.0
2
+
3
+ CREATE TABLE channels (
4
+ id TEXT PRIMARY KEY NOT NULL,
5
+ name TEXT NOT NULL,
6
+ CONSTRAINT channels_id_length_check CHECK (length(id) BETWEEN 1 AND 255),
7
+ CONSTRAINT channels_name_length_check CHECK (length(name) BETWEEN 1 AND 255)
8
+ );
9
+
10
+ CREATE TABLE bundles (
11
+ id TEXT PRIMARY KEY NOT NULL,
12
+ platform TEXT NOT NULL,
13
+ file_hash TEXT NOT NULL,
14
+ git_commit_hash TEXT,
15
+ storage_uri TEXT NOT NULL,
16
+ archive_byte_size REAL NOT NULL,
17
+ metadata TEXT NOT NULL DEFAULT '{}',
18
+ manifest_storage_uri TEXT,
19
+ manifest_file_hash TEXT,
20
+ asset_base_storage_uri TEXT,
21
+ CONSTRAINT bundles_archive_byte_size_check CHECK (
22
+ archive_byte_size BETWEEN 0 AND 9007199254740991
23
+ )
24
+ );
25
+
26
+ CREATE TABLE bundle_patches (
27
+ id TEXT PRIMARY KEY NOT NULL,
28
+ bundle_id TEXT NOT NULL,
29
+ base_bundle_id TEXT NOT NULL,
30
+ base_file_hash TEXT NOT NULL,
31
+ patch_file_hash TEXT NOT NULL,
32
+ patch_storage_uri TEXT NOT NULL,
33
+ byte_size REAL NOT NULL,
34
+ order_index INTEGER NOT NULL DEFAULT 0,
35
+ CONSTRAINT bundle_patches_byte_size_check CHECK (
36
+ byte_size BETWEEN 0 AND 9007199254740991
37
+ ),
38
+ CONSTRAINT bundle_patches_bundle_id_fk FOREIGN KEY (bundle_id)
39
+ REFERENCES bundles(id) ON UPDATE RESTRICT ON DELETE CASCADE,
40
+ CONSTRAINT bundle_patches_base_bundle_id_fk FOREIGN KEY (base_bundle_id)
41
+ REFERENCES bundles(id) ON UPDATE RESTRICT ON DELETE CASCADE
42
+ );
43
+
44
+ CREATE TABLE releases (
45
+ id TEXT PRIMARY KEY NOT NULL,
46
+ revision INTEGER NOT NULL,
47
+ scope_key TEXT COLLATE BINARY NOT NULL,
48
+ channel_id TEXT COLLATE BINARY NOT NULL,
49
+ platform TEXT NOT NULL,
50
+ kind TEXT NOT NULL,
51
+ bundle_id TEXT,
52
+ strategy TEXT NOT NULL,
53
+ target_app_version TEXT,
54
+ fingerprint_hash TEXT,
55
+ enabled INTEGER NOT NULL,
56
+ should_force_update INTEGER NOT NULL,
57
+ message TEXT,
58
+ rollout_cohort_count INTEGER NOT NULL DEFAULT 1000,
59
+ target_cohorts TEXT NOT NULL DEFAULT '[]',
60
+ operation TEXT NOT NULL,
61
+ source_release_id TEXT,
62
+ created_at_ms REAL NOT NULL,
63
+ updated_at_ms REAL NOT NULL,
64
+ CONSTRAINT releases_revision_check CHECK (revision >= 1),
65
+ CONSTRAINT releases_kind_bundle_check CHECK (
66
+ (kind = 'BUNDLE' AND bundle_id IS NOT NULL)
67
+ OR (kind = 'EMBEDDED' AND bundle_id IS NULL)
68
+ ),
69
+ CONSTRAINT releases_strategy_target_check CHECK (
70
+ (strategy = 'APP_VERSION' AND target_app_version IS NOT NULL AND fingerprint_hash IS NULL)
71
+ OR (strategy = 'FINGERPRINT' AND target_app_version IS NULL AND fingerprint_hash IS NOT NULL)
72
+ ),
73
+ CONSTRAINT releases_rollout_cohort_count_check CHECK (
74
+ rollout_cohort_count BETWEEN 0 AND 1000
75
+ ),
76
+ CONSTRAINT releases_operation_check CHECK (
77
+ operation IN ('DEPLOY', 'PROMOTE', 'ROLLBACK')
78
+ ),
79
+ CONSTRAINT releases_channel_id_fk FOREIGN KEY (channel_id)
80
+ REFERENCES channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
81
+ CONSTRAINT releases_bundle_id_fk FOREIGN KEY (bundle_id)
82
+ REFERENCES bundles(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
83
+ CONSTRAINT releases_source_release_id_fk FOREIGN KEY (source_release_id)
84
+ REFERENCES releases(id) ON UPDATE RESTRICT ON DELETE SET NULL
85
+ );
86
+
87
+ CREATE TABLE release_catalogs (
88
+ scope_key TEXT COLLATE BINARY PRIMARY KEY NOT NULL,
89
+ catalog_id TEXT NOT NULL,
90
+ strategy TEXT NOT NULL,
91
+ channel_id TEXT COLLATE BINARY NOT NULL,
92
+ channel_key TEXT COLLATE BINARY NOT NULL,
93
+ platform TEXT NOT NULL,
94
+ fingerprint_hash TEXT,
95
+ generation REAL NOT NULL,
96
+ payload TEXT NOT NULL,
97
+ catalog_hash TEXT NOT NULL,
98
+ byte_size INTEGER NOT NULL,
99
+ is_tombstone INTEGER NOT NULL,
100
+ updated_at_ms REAL NOT NULL,
101
+ CONSTRAINT release_catalogs_strategy_target_check CHECK (
102
+ (strategy = 'APP_VERSION' AND fingerprint_hash IS NULL)
103
+ OR (strategy = 'FINGERPRINT' AND fingerprint_hash IS NOT NULL)
104
+ ),
105
+ CONSTRAINT release_catalogs_generation_check CHECK (
106
+ generation BETWEEN 1 AND 9007199254740991
107
+ ),
108
+ CONSTRAINT release_catalogs_byte_size_check CHECK (
109
+ byte_size BETWEEN 0 AND 262144
110
+ ),
111
+ CONSTRAINT release_catalogs_channel_id_fk FOREIGN KEY (channel_id)
112
+ REFERENCES channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT
113
+ );
114
+
115
+ CREATE TABLE bundle_events (
116
+ id TEXT PRIMARY KEY NOT NULL,
117
+ type TEXT NOT NULL,
118
+ install_id TEXT NOT NULL,
119
+ user_id TEXT,
120
+ username TEXT,
121
+ from_release_id TEXT,
122
+ from_bundle_id TEXT,
123
+ to_release_id TEXT,
124
+ to_bundle_id TEXT NOT NULL,
125
+ platform TEXT NOT NULL,
126
+ app_version TEXT NOT NULL,
127
+ channel TEXT NOT NULL,
128
+ cohort TEXT NOT NULL,
129
+ update_strategy TEXT,
130
+ fingerprint_hash TEXT,
131
+ sdk_version TEXT,
132
+ received_at_ms REAL NOT NULL,
133
+ CONSTRAINT bundle_events_type_check CHECK (
134
+ type IN ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED', 'UNCHANGED')
135
+ ),
136
+ CONSTRAINT bundle_events_platform_check CHECK (
137
+ platform IN ('ios', 'android')
138
+ ),
139
+ CONSTRAINT bundle_events_shape_check CHECK (
140
+ (
141
+ type IN ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED')
142
+ AND from_bundle_id IS NOT NULL
143
+ AND update_strategy IS NOT NULL
144
+ AND update_strategy IN ('fingerprint', 'appVersion')
145
+ ) OR (
146
+ type = 'UNCHANGED'
147
+ AND from_bundle_id IS NULL
148
+ AND update_strategy IS NULL
149
+ )
150
+ ),
151
+ CONSTRAINT bundle_events_received_at_check CHECK (received_at_ms >= 0)
152
+ );
153
+
154
+ CREATE TABLE api_keys (
155
+ id TEXT PRIMARY KEY NOT NULL,
156
+ hash TEXT NOT NULL,
157
+ name TEXT NOT NULL,
158
+ prefix TEXT NOT NULL,
159
+ role TEXT NOT NULL,
160
+ created_at_ms REAL NOT NULL,
161
+ revoked_at_ms REAL,
162
+ CONSTRAINT api_keys_role_check CHECK (role = 'client'),
163
+ CONSTRAINT api_keys_created_at_check CHECK (created_at_ms >= 0),
164
+ CONSTRAINT api_keys_revoked_at_check
165
+ CHECK (revoked_at_ms IS NULL OR revoked_at_ms >= 0)
166
+ );
167
+
168
+ CREATE TABLE private_hot_updater_settings (
169
+ key TEXT PRIMARY KEY NOT NULL,
170
+ value TEXT NOT NULL DEFAULT '1.0.0'
171
+ );
172
+
173
+ CREATE UNIQUE INDEX channels_name_key ON channels(name);
174
+ CREATE INDEX bundle_patches_bundle_id_idx ON bundle_patches(bundle_id);
175
+ CREATE INDEX bundle_patches_base_bundle_id_idx ON bundle_patches(base_bundle_id);
176
+ CREATE INDEX releases_scope_order_idx ON releases(scope_key, id);
177
+ CREATE INDEX releases_channel_platform_order_idx
178
+ ON releases(channel_id, platform, id);
179
+ CREATE INDEX releases_bundle_id_idx ON releases(bundle_id);
180
+ CREATE INDEX releases_fingerprint_hash_idx ON releases(fingerprint_hash);
181
+ CREATE INDEX releases_enabled_idx ON releases(enabled);
182
+ CREATE INDEX release_catalogs_channel_idx ON release_catalogs(channel_id);
183
+ CREATE INDEX bundle_events_received_at_idx
184
+ ON bundle_events(received_at_ms, id);
185
+ CREATE INDEX bundle_events_install_idx
186
+ ON bundle_events(install_id, received_at_ms, id);
187
+ CREATE INDEX bundle_events_user_id_idx
188
+ ON bundle_events(user_id, received_at_ms, id);
189
+ CREATE INDEX bundle_events_username_idx
190
+ ON bundle_events(username, received_at_ms, id);
191
+ CREATE INDEX bundle_events_to_bundle_idx
192
+ ON bundle_events(type, to_bundle_id, received_at_ms, id);
193
+ CREATE INDEX bundle_events_from_bundle_idx
194
+ ON bundle_events(type, from_bundle_id, received_at_ms, id);
195
+ CREATE INDEX bundle_events_to_release_idx
196
+ ON bundle_events(type, to_release_id, received_at_ms, id);
197
+ CREATE INDEX bundle_events_from_release_idx
198
+ ON bundle_events(type, from_release_id, received_at_ms, id);
199
+ CREATE UNIQUE INDEX api_keys_hash_key ON api_keys(hash);
200
+ CREATE INDEX api_keys_created_at_idx
201
+ ON api_keys(created_at_ms, id);
202
+
203
+ INSERT INTO private_hot_updater_settings (key, value)
204
+ VALUES ('schema.core', '1.0.0')
205
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value;
@@ -0,0 +1,130 @@
1
+ import { env } from "cloudflare:test";
2
+ import { expect, inject, it } from "vitest";
3
+
4
+ declare module "vitest" {
5
+ export interface ProvidedContext {
6
+ d1Migrations: readonly {
7
+ readonly name: string;
8
+ readonly sql: string;
9
+ }[];
10
+ }
11
+ }
12
+
13
+ it("ships a single D1 1.0.0 CREATE migration", () => {
14
+ expect(inject("d1Migrations").map(({ name }) => name)).toEqual([
15
+ "0001_hot-updater_1.0.0.sql",
16
+ ]);
17
+ });
18
+
19
+ it("creates the current schema with required artifact sizes", async () => {
20
+ const [createMigration] = inject("d1Migrations");
21
+ await env.DB.prepare(createMigration!.sql).run();
22
+ await env.DB.prepare(`
23
+ INSERT INTO bundles (
24
+ id, platform, file_hash, storage_uri, archive_byte_size, metadata
25
+ ) VALUES (
26
+ '00000000-0000-0000-0000-000000000001', 'ios', 'hash',
27
+ 'storage://bundle', 3000000001, '{}'
28
+ )
29
+ `).run();
30
+ await env.DB.prepare(`
31
+ INSERT INTO bundle_patches (
32
+ id, bundle_id, base_bundle_id, base_file_hash, patch_file_hash,
33
+ patch_storage_uri, byte_size
34
+ ) VALUES (
35
+ 'patch-1', '00000000-0000-0000-0000-000000000001',
36
+ '00000000-0000-0000-0000-000000000001', 'base-hash', 'patch-hash',
37
+ 'storage://patch', 3000000002
38
+ )
39
+ `).run();
40
+
41
+ const tables = await env.DB.prepare(`
42
+ SELECT name FROM sqlite_master
43
+ WHERE type = 'table'
44
+ ORDER BY name
45
+ `).all<{ name: string }>();
46
+
47
+ expect(tables.results.map(({ name }) => name)).toEqual(
48
+ expect.arrayContaining([
49
+ "bundle_events",
50
+ "bundle_patches",
51
+ "bundles",
52
+ "channels",
53
+ "api_keys",
54
+ "private_hot_updater_settings",
55
+ "release_catalogs",
56
+ "releases",
57
+ ]),
58
+ );
59
+
60
+ const version = await env.DB.prepare(
61
+ "SELECT value FROM private_hot_updater_settings WHERE key = 'schema.core'",
62
+ ).first<string>("value");
63
+ expect(version).toBe("1.0.0");
64
+
65
+ await env.DB.prepare(
66
+ "INSERT INTO channels (id, name) VALUES ('channel-1', 'production')",
67
+ ).run();
68
+ await env.DB.prepare(`
69
+ INSERT INTO releases (
70
+ id, revision, scope_key, channel_id, platform, kind, bundle_id,
71
+ strategy, target_app_version, fingerprint_hash, enabled,
72
+ should_force_update, message, rollout_cohort_count, target_cohorts,
73
+ operation, source_release_id, created_at_ms, updated_at_ms
74
+ ) VALUES (
75
+ '00000000-0000-0000-0000-000000000001', 1, 'scope', 'channel-1',
76
+ 'ios', 'BUNDLE', '00000000-0000-0000-0000-000000000001',
77
+ 'APP_VERSION', '1.0.0', NULL, 1, 0, NULL, 1000, '[]',
78
+ 'DEPLOY', NULL, 0, 0
79
+ )
80
+ `).run();
81
+
82
+ const release = await env.DB.prepare(
83
+ "SELECT channel_id, bundle_id FROM releases",
84
+ ).first();
85
+ expect(release).toEqual({
86
+ channel_id: "channel-1",
87
+ bundle_id: "00000000-0000-0000-0000-000000000001",
88
+ });
89
+ const sizes = await env.DB.prepare(`
90
+ SELECT bundle.archive_byte_size, patch.byte_size
91
+ FROM bundles AS bundle
92
+ JOIN bundle_patches AS patch ON patch.bundle_id = bundle.id
93
+ `).first();
94
+ expect(sizes).toEqual({
95
+ archive_byte_size: 3_000_000_001,
96
+ byte_size: 3_000_000_002,
97
+ });
98
+
99
+ for (const size of [-1, Number.MAX_SAFE_INTEGER + 1, null]) {
100
+ await expect(
101
+ env.DB.prepare("UPDATE bundles SET archive_byte_size = ?")
102
+ .bind(size)
103
+ .run(),
104
+ ).rejects.toThrow(/constraint failed/);
105
+ await expect(
106
+ env.DB.prepare("UPDATE bundle_patches SET byte_size = ?")
107
+ .bind(size)
108
+ .run(),
109
+ ).rejects.toThrow(/constraint failed/);
110
+ }
111
+
112
+ await expect(
113
+ env.DB.prepare(`
114
+ INSERT INTO bundles (id, platform, file_hash, storage_uri)
115
+ VALUES ('missing-size', 'ios', 'hash', 'storage://bundle')
116
+ `).run(),
117
+ ).rejects.toThrow(/NOT NULL constraint failed/);
118
+ await expect(
119
+ env.DB.prepare(`
120
+ INSERT INTO bundle_patches (
121
+ id, bundle_id, base_bundle_id, base_file_hash, patch_file_hash,
122
+ patch_storage_uri
123
+ ) VALUES (
124
+ 'missing-size', '00000000-0000-0000-0000-000000000001',
125
+ '00000000-0000-0000-0000-000000000001', 'base-hash', 'patch-hash',
126
+ 'storage://patch'
127
+ )
128
+ `).run(),
129
+ ).rejects.toThrow(/NOT NULL constraint failed/);
130
+ });
@@ -0,0 +1,292 @@
1
+ import type {
2
+ BundleRow,
3
+ ChannelRow,
4
+ ReleaseRow,
5
+ } from "@hot-updater/plugin-core";
6
+ import { setupDatabasePluginTestSuite } from "@hot-updater/test-utils";
7
+ import { env } from "cloudflare:test";
8
+ import {
9
+ afterAll,
10
+ beforeAll,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ inject,
15
+ it,
16
+ vi,
17
+ } from "vitest";
18
+
19
+ import { d1Database } from "../../src/d1Database";
20
+ import { d1Database as d1RuntimeDatabase } from "../../src/worker";
21
+
22
+ const state = vi.hoisted<{ db: D1Database | undefined }>(() => ({
23
+ db: undefined,
24
+ }));
25
+
26
+ class D1TestStateError extends Error {
27
+ readonly name = "D1TestStateError";
28
+ }
29
+
30
+ const getDb = (): D1Database => {
31
+ if (state.db === undefined) {
32
+ throw new D1TestStateError();
33
+ }
34
+ return state.db;
35
+ };
36
+
37
+ vi.mock("cloudflare", () => ({
38
+ default: class MockCloudflare {
39
+ readonly d1 = {
40
+ database: {
41
+ query: async (
42
+ _databaseId: string,
43
+ input:
44
+ | { readonly sql: string; readonly params?: readonly string[] }
45
+ | {
46
+ readonly batch: readonly {
47
+ readonly sql: string;
48
+ readonly params?: readonly string[];
49
+ }[];
50
+ },
51
+ ) => {
52
+ const statements =
53
+ "batch" in input
54
+ ? input.batch.map(({ sql, params }) =>
55
+ getDb()
56
+ .prepare(sql)
57
+ .bind(...(params ?? [])),
58
+ )
59
+ : (() => {
60
+ const params = input.params ?? [];
61
+ let paramOffset = 0;
62
+ return input.sql
63
+ .split(";")
64
+ .map((sql) => sql.trim())
65
+ .filter(Boolean)
66
+ .map((sql) => {
67
+ const paramCount = sql.match(/\?/g)?.length ?? 0;
68
+ const statement = getDb()
69
+ .prepare(sql)
70
+ .bind(
71
+ ...params.slice(
72
+ paramOffset,
73
+ paramOffset + paramCount,
74
+ ),
75
+ );
76
+ paramOffset += paramCount;
77
+ return statement;
78
+ });
79
+ })();
80
+ const results = await getDb().batch(statements);
81
+ return {
82
+ async *iterPages() {
83
+ yield { result: results };
84
+ },
85
+ };
86
+ },
87
+ },
88
+ };
89
+ },
90
+ }));
91
+
92
+ const reset = async (): Promise<void> => {
93
+ await getDb()
94
+ .prepare(
95
+ "DELETE FROM bundle_events; DELETE FROM api_keys; DELETE FROM bundle_patches; DELETE FROM release_catalogs; DELETE FROM releases; DELETE FROM bundles; DELETE FROM channels;",
96
+ )
97
+ .run();
98
+ };
99
+
100
+ const createChannelRow = (name: string): ChannelRow => ({
101
+ id: `channel-${name}`,
102
+ name,
103
+ });
104
+
105
+ const createBundleRow = (): BundleRow => ({
106
+ id: "00000000-0000-0000-0000-000000000902",
107
+ platform: "ios",
108
+ file_hash: "hash",
109
+ git_commit_hash: null,
110
+ storage_uri: "storage://bundle",
111
+ archive_byte_size: 3_000_000_001,
112
+ metadata: {},
113
+ manifest_storage_uri: null,
114
+ manifest_file_hash: null,
115
+ asset_base_storage_uri: null,
116
+ });
117
+
118
+ const createReleaseRow = (
119
+ channel: ChannelRow,
120
+ bundle: BundleRow,
121
+ ): ReleaseRow => ({
122
+ id: "00000000-0000-7000-8000-000000000903",
123
+ revision: 1,
124
+ scope_key: `v1:test:${channel.name}:ios:app-version`,
125
+ channel_id: channel.id,
126
+ platform: bundle.platform,
127
+ kind: "BUNDLE",
128
+ bundle_id: bundle.id,
129
+ strategy: "APP_VERSION",
130
+ target_app_version: "1.0.0",
131
+ fingerprint_hash: null,
132
+ enabled: true,
133
+ should_force_update: false,
134
+ message: null,
135
+ rollout_cohort_count: 1000,
136
+ target_cohorts: [],
137
+ operation: "DEPLOY",
138
+ source_release_id: null,
139
+ created_at_ms: 100,
140
+ updated_at_ms: 100,
141
+ });
142
+
143
+ setupDatabasePluginTestSuite({
144
+ name: "cloudflare d1 http fixed-model database plugin",
145
+ migrate: async () => {
146
+ state.db = env.DB;
147
+ await getDb().prepare(inject("prepareSql")).run();
148
+ },
149
+ createPlugin: () =>
150
+ d1Database({
151
+ accountId: "account-id",
152
+ cloudflareApiToken: "api-token",
153
+ databaseId: "database-id",
154
+ }),
155
+ reset,
156
+ dispose: () => undefined,
157
+ });
158
+
159
+ setupDatabasePluginTestSuite({
160
+ name: "cloudflare worker d1 fixed-model database plugin",
161
+ migrate: () => undefined,
162
+ createPlugin: () => d1RuntimeDatabase(env.DB),
163
+ reset,
164
+ dispose: () => {
165
+ state.db = undefined;
166
+ },
167
+ });
168
+
169
+ describe.each([
170
+ {
171
+ name: "cloudflare d1 http",
172
+ createPlugin: () =>
173
+ d1Database({
174
+ accountId: "account-id",
175
+ cloudflareApiToken: "api-token",
176
+ databaseId: "database-id",
177
+ }),
178
+ },
179
+ {
180
+ name: "cloudflare worker d1",
181
+ createPlugin: () => d1RuntimeDatabase(env.DB),
182
+ },
183
+ ])("$name Channel deletion", ({ createPlugin }) => {
184
+ beforeAll(() => {
185
+ state.db = env.DB;
186
+ });
187
+
188
+ beforeEach(async () => {
189
+ await reset();
190
+ });
191
+
192
+ afterAll(() => {
193
+ state.db = undefined;
194
+ });
195
+
196
+ it("deletes only empty channels", async () => {
197
+ const plugin = createPlugin();
198
+ const channel = createChannelRow("empty");
199
+ await plugin.models.channels.insert({
200
+ row: channel,
201
+ onConflict: "returnExisting",
202
+ });
203
+
204
+ await expect(
205
+ plugin.models.channels.delete({ id: channel.id }),
206
+ ).resolves.toEqual({ deleted: true });
207
+ await expect(
208
+ plugin.models.channels.delete({ id: channel.id }),
209
+ ).resolves.toEqual({ deleted: false, reason: "not_found" });
210
+ });
211
+
212
+ it("rejects direct and committed deletion while a Release references the channel", async () => {
213
+ const plugin = createPlugin();
214
+ const channel = createChannelRow("active");
215
+ const bundle = createBundleRow();
216
+ const release = createReleaseRow(channel, bundle);
217
+ await plugin.models.channels.insert({
218
+ row: channel,
219
+ onConflict: "returnExisting",
220
+ });
221
+ await plugin.commit({
222
+ changes: [
223
+ { model: "bundles", operation: "insert", row: bundle },
224
+ { model: "releases", operation: "insert", row: release },
225
+ ],
226
+ });
227
+
228
+ await expect(
229
+ plugin.models.channels.delete({ id: channel.id }),
230
+ ).resolves.toEqual({ deleted: false, reason: "not_empty" });
231
+ await expect(
232
+ plugin.commit({
233
+ changes: [
234
+ {
235
+ model: "channels",
236
+ operation: "delete",
237
+ where: { id: channel.id },
238
+ },
239
+ ],
240
+ }),
241
+ ).resolves.toEqual({
242
+ committed: false,
243
+ conflict: { changeIndex: 0, reason: "referenced" },
244
+ });
245
+ await expect(plugin.models.bundles.findById(bundle.id)).resolves.toEqual(
246
+ bundle,
247
+ );
248
+ });
249
+
250
+ it("atomically deletes a Release before deleting its newly empty channel", async () => {
251
+ const plugin = createPlugin();
252
+ const channel = createChannelRow("retired");
253
+ const bundle = createBundleRow();
254
+ const release = createReleaseRow(channel, bundle);
255
+ await plugin.models.channels.insert({
256
+ row: channel,
257
+ onConflict: "returnExisting",
258
+ });
259
+ await plugin.commit({
260
+ changes: [
261
+ { model: "bundles", operation: "insert", row: bundle },
262
+ { model: "releases", operation: "insert", row: release },
263
+ ],
264
+ });
265
+
266
+ await expect(
267
+ plugin.commit({
268
+ changes: [
269
+ {
270
+ model: "releases",
271
+ operation: "delete",
272
+ where: { id: release.id },
273
+ },
274
+ {
275
+ model: "channels",
276
+ operation: "delete",
277
+ where: { id: channel.id },
278
+ },
279
+ ],
280
+ }),
281
+ ).resolves.toEqual({ committed: true });
282
+ await expect(
283
+ plugin.models.releases.findById(release.id),
284
+ ).resolves.toBeNull();
285
+ await expect(plugin.models.bundles.findById(bundle.id)).resolves.toEqual(
286
+ bundle,
287
+ );
288
+ await expect(plugin.models.channels.list({})).resolves.toEqual({
289
+ channels: [],
290
+ });
291
+ });
292
+ });