@hot-updater/cloudflare 1.0.0-rc.5 → 1.0.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 (51) hide show
  1. package/dist/index.cjs +90 -924
  2. package/dist/index.d.cts +16 -6
  3. package/dist/index.d.mts +16 -6
  4. package/dist/index.mjs +91 -926
  5. package/dist/init/index.cjs +11777 -0
  6. package/dist/init/index.d.cts +5 -1
  7. package/dist/init/index.d.mts +5 -1
  8. package/dist/init/index.mjs +11771 -1
  9. package/dist/worker/index.cjs +117 -916
  10. package/dist/worker/index.d.cts +45 -11
  11. package/dist/worker/index.d.mts +45 -11
  12. package/dist/worker/index.mjs +118 -917
  13. package/package.json +9 -14
  14. package/sql/bundles.sql +121 -212
  15. package/src/d1Database.ts +58 -43
  16. package/src/d1Executor.ts +84 -0
  17. package/src/d1Schema.ts +26 -0
  18. package/src/presignR2Url.ts +103 -0
  19. package/src/r2S3Storage.ts +20 -19
  20. package/src/r2Storage.ts +5 -3
  21. package/src/r2WorkerStorage.ts +55 -17
  22. package/src/worker/d1Database.ts +21 -39
  23. package/worker/dist/README.md +1 -1
  24. package/worker/dist/index.js +9859 -5696
  25. package/worker/dist/index.js.map +4 -4
  26. package/worker/migrations/0001_hot-updater_1.0.0.sql +121 -222
  27. package/worker/src/index.ts +15 -9
  28. package/worker/wrangler.json +1 -0
  29. package/dist/iac/index.cjs +0 -12687
  30. package/dist/iac/index.d.cts +0 -5
  31. package/dist/iac/index.d.mts +0 -5
  32. package/dist/iac/index.mjs +0 -12679
  33. package/src/d1Database.spec.ts +0 -277
  34. package/src/d1Implementation.spec.ts +0 -342
  35. package/src/d1Implementation.ts +0 -781
  36. package/src/d1Query.spec.ts +0 -46
  37. package/src/d1Query.ts +0 -92
  38. package/src/d1Rows.spec.ts +0 -143
  39. package/src/d1Rows.ts +0 -357
  40. package/src/d1Sql.spec.ts +0 -30
  41. package/src/d1Sql.ts +0 -175
  42. package/src/r2Storage.spec.ts +0 -346
  43. package/src/r2WorkerStorage.spec.ts +0 -184
  44. package/src/utils/createWrangler.spec.ts +0 -75
  45. package/src/worker/d1Database.spec.ts +0 -69
  46. package/worker/src/databaseMigration.integration.spec.ts +0 -191
  47. package/worker/src/databasePlugins.integration.spec.ts +0 -326
  48. package/worker/src/index.integration.spec.ts +0 -190
  49. package/worker/src/insightsMovement.integration.spec.ts +0 -73
  50. package/worker/src/storagePlugin.integration.spec.ts +0 -59
  51. package/worker/src/testWorker.ts +0 -5
@@ -1,75 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const mocks = vi.hoisted(() => ({
4
- command: vi.fn(),
5
- execa: vi.fn(),
6
- }));
7
-
8
- vi.mock("execa", () => ({
9
- execa: mocks.execa,
10
- }));
11
-
12
- import { createWrangler } from "./createWrangler";
13
-
14
- describe("createWrangler", () => {
15
- beforeEach(() => {
16
- vi.clearAllMocks();
17
- mocks.execa.mockReturnValue(mocks.command);
18
- mocks.command.mockResolvedValue({});
19
- });
20
-
21
- it("marks replay commands as CI so Wrangler skips nested prompts", async () => {
22
- const wrangler = createWrangler({
23
- accountId: "account-id",
24
- cloudflareApiToken: "api-token",
25
- cwd: "/tmp/cloudflare-init",
26
- nonInteractive: true,
27
- stdio: "inherit",
28
- });
29
-
30
- await wrangler("d1", "migrations", "apply", "hot-updater", "--remote");
31
-
32
- expect(mocks.execa).toHaveBeenCalledWith({
33
- cwd: "/tmp/cloudflare-init",
34
- env: {
35
- CI: "true",
36
- CLOUDFLARE_ACCOUNT_ID: "account-id",
37
- CLOUDFLARE_API_TOKEN: "api-token",
38
- },
39
- extendsEnv: true,
40
- shell: false,
41
- stdio: "inherit",
42
- });
43
- expect(mocks.command).toHaveBeenCalledWith("npx", [
44
- "wrangler",
45
- "d1",
46
- "migrations",
47
- "apply",
48
- "hot-updater",
49
- "--remote",
50
- ]);
51
- });
52
-
53
- it("passes shell metacharacters as one argument", async () => {
54
- const wrangler = createWrangler({
55
- accountId: "account-id",
56
- cloudflareApiToken: "api-token",
57
- cwd: "/tmp/cloudflare-init",
58
- stdio: "inherit",
59
- });
60
-
61
- await wrangler("deploy", "--name", "safe; printf INJECTED");
62
-
63
- expect(mocks.execa).toHaveBeenCalledWith(
64
- expect.objectContaining({
65
- shell: false,
66
- }),
67
- );
68
- expect(mocks.command).toHaveBeenCalledWith("npx", [
69
- "wrangler",
70
- "deploy",
71
- "--name",
72
- "safe; printf INJECTED",
73
- ]);
74
- });
75
- });
@@ -1,69 +0,0 @@
1
- import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
2
- import { expect, it } from "vitest";
3
-
4
- import { createBundleEventRowFixture } from "../../../../packages/test-utils/src/databaseTestFixtures";
5
- import { d1Database, type D1Like } from "./d1Database";
6
-
7
- const database: D1Like = {
8
- prepare: () => ({
9
- bind: () => ({
10
- all: async () => ({
11
- results: [{ id: "channel-production", name: "production" }],
12
- }),
13
- }),
14
- }),
15
- batch: async (statements) =>
16
- Promise.all(statements.map((item) => item.all())),
17
- };
18
-
19
- it("uses the D1 binding supplied at the Worker composition boundary", async () => {
20
- const plugin = d1Database(database);
21
-
22
- expect(plugin.name).toBe("d1Database");
23
- expect(Object.keys(plugin.models).sort()).toEqual([
24
- "apiKeys",
25
- "bundlePatches",
26
- "bundles",
27
- "channels",
28
- "insights",
29
- "releaseCatalogs",
30
- "releases",
31
- ]);
32
- await expect(plugin.models.channels.list({})).resolves.toEqual({
33
- channels: [{ id: "channel-production", name: "production" }],
34
- });
35
- });
36
-
37
- it("does not report a failed D1 query as empty Insights history", async () => {
38
- const plugin = d1Database({
39
- ...database,
40
- prepare: () => ({
41
- bind: () => ({ all: async () => ({ success: false, results: [] }) }),
42
- }),
43
- });
44
- await expect(
45
- plugin.models.insights.listEvents({
46
- filter: { kind: "all" },
47
- beforeReceivedAtMs: 100,
48
- limit: 10,
49
- }),
50
- ).rejects.toThrow(
51
- "D1 did not successfully execute every requested statement",
52
- );
53
- });
54
-
55
- it("rejects an incomplete Insights batch response", async () => {
56
- const plugin = d1Database({
57
- ...database,
58
- batch: async () => [{ success: true, results: [] }],
59
- });
60
- const event = createBundleEventRowFixture("1", 1);
61
- await expect(
62
- plugin.models.insights.record({
63
- event,
64
- installation: toInsightsInstallationRow(event),
65
- }),
66
- ).rejects.toThrow(
67
- "D1 did not successfully execute every requested statement",
68
- );
69
- });
@@ -1,191 +0,0 @@
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 1.0.0 initialization 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_installations",
51
- "bundle_patches",
52
- "bundles",
53
- "channels",
54
- "api_keys",
55
- "private_hot_updater_settings",
56
- "release_catalogs",
57
- "releases",
58
- ]),
59
- );
60
-
61
- const version = await env.DB.prepare(
62
- "SELECT value FROM private_hot_updater_settings WHERE key = 'schema.core'",
63
- ).first<string>("value");
64
- expect(version).toBe("1.0.0");
65
-
66
- await env.DB.prepare(
67
- "INSERT INTO channels (id, name) VALUES ('channel-1', 'production')",
68
- ).run();
69
- await env.DB.prepare(`
70
- INSERT INTO releases (
71
- id, revision, scope_key, channel_id, platform, kind, bundle_id,
72
- strategy, target_app_version, fingerprint_hash, enabled,
73
- should_force_update, message, rollout_cohort_count, target_cohorts,
74
- operation, source_release_id, created_at_ms, updated_at_ms
75
- ) VALUES (
76
- '00000000-0000-0000-0000-000000000001', 1, 'scope', 'channel-1',
77
- 'ios', 'BUNDLE', '00000000-0000-0000-0000-000000000001',
78
- 'APP_VERSION', '1.0.0', NULL, 1, 0, NULL, 1000, '[]',
79
- 'DEPLOY', NULL, 0, 0
80
- )
81
- `).run();
82
-
83
- const release = await env.DB.prepare(
84
- "SELECT channel_id, bundle_id FROM releases",
85
- ).first();
86
- expect(release).toEqual({
87
- channel_id: "channel-1",
88
- bundle_id: "00000000-0000-0000-0000-000000000001",
89
- });
90
- const sizes = await env.DB.prepare(`
91
- SELECT bundle.archive_byte_size, patch.byte_size
92
- FROM bundles AS bundle
93
- JOIN bundle_patches AS patch ON patch.bundle_id = bundle.id
94
- `).first();
95
- expect(sizes).toEqual({
96
- archive_byte_size: 3_000_000_001,
97
- byte_size: 3_000_000_002,
98
- });
99
-
100
- await env.DB.prepare(`
101
- INSERT INTO bundle_installations (
102
- install_id, id, user_id, username, to_bundle_id, type, platform,
103
- app_version, channel, cohort, received_at_ms
104
- ) VALUES (
105
- 'install-1', 'event-1', 'user-1', 'Demo User',
106
- '00000000-0000-0000-0000-000000000001', 'UPDATE_APPLIED', 'ios',
107
- '1.0.0', 'production', 'cohort-1', 100
108
- )
109
- `).run();
110
- await expect(
111
- env.DB.prepare(
112
- "SELECT install_id, received_at_ms FROM bundle_installations",
113
- ).first(),
114
- ).resolves.toEqual({ install_id: "install-1", received_at_ms: 100 });
115
-
116
- const installationIndexes = await env.DB.prepare(`
117
- SELECT name FROM sqlite_master
118
- WHERE type = 'index' AND tbl_name = 'bundle_installations'
119
- ORDER BY name
120
- `).all<{ name: string }>();
121
- expect(installationIndexes.results.map(({ name }) => name)).toEqual(
122
- expect.arrayContaining([
123
- "bundle_installations_received_at_idx",
124
- "bundle_installations_user_id_idx",
125
- "bundle_installations_scope_idx",
126
- "bundle_installations_bundle_idx",
127
- ]),
128
- );
129
-
130
- const movementIndex = await env.DB.prepare(
131
- "PRAGMA index_info(bundle_events_install_idx)",
132
- ).all<{ name: string }>();
133
- expect(movementIndex.results.map(({ name }) => name)).toEqual([
134
- "install_id",
135
- "type",
136
- "received_at_ms",
137
- "id",
138
- ]);
139
-
140
- for (const [direction, type] of [
141
- ["from", "RECOVERED"],
142
- ["to", "UPDATE_APPLIED"],
143
- ]) {
144
- const plan = await env.DB.prepare(`
145
- EXPLAIN QUERY PLAN SELECT * FROM bundle_events
146
- WHERE type = ? AND platform = 'ios' AND channel = 'production'
147
- AND ${direction}_bundle_id = ? AND received_at_ms >= 100 AND received_at_ms < 200
148
- ORDER BY received_at_ms DESC, id DESC LIMIT 101
149
- `)
150
- .bind(type, "00000000-0000-0000-0000-000000000001")
151
- .all<{ detail: string }>();
152
- expect(plan.results.map(({ detail }) => detail).join("\n")).toContain(
153
- `bundle_events_${direction}_bundle_idx`,
154
- );
155
- expect(plan.results.map(({ detail }) => detail).join("\n")).not.toContain(
156
- "TEMP B-TREE",
157
- );
158
- }
159
-
160
- for (const size of [-1, Number.MAX_SAFE_INTEGER + 1, null]) {
161
- await expect(
162
- env.DB.prepare("UPDATE bundles SET archive_byte_size = ?")
163
- .bind(size)
164
- .run(),
165
- ).rejects.toThrow(/constraint failed/);
166
- await expect(
167
- env.DB.prepare("UPDATE bundle_patches SET byte_size = ?")
168
- .bind(size)
169
- .run(),
170
- ).rejects.toThrow(/constraint failed/);
171
- }
172
-
173
- await expect(
174
- env.DB.prepare(`
175
- INSERT INTO bundles (id, platform, file_hash, storage_uri)
176
- VALUES ('missing-size', 'ios', 'hash', 'storage://bundle')
177
- `).run(),
178
- ).rejects.toThrow(/NOT NULL constraint failed/);
179
- await expect(
180
- env.DB.prepare(`
181
- INSERT INTO bundle_patches (
182
- id, bundle_id, base_bundle_id, base_file_hash, patch_file_hash,
183
- patch_storage_uri
184
- ) VALUES (
185
- 'missing-size', '00000000-0000-0000-0000-000000000001',
186
- '00000000-0000-0000-0000-000000000001', 'base-hash', 'patch-hash',
187
- 'storage://patch'
188
- )
189
- `).run(),
190
- ).rejects.toThrow(/NOT NULL constraint failed/);
191
- });
@@ -1,326 +0,0 @@
1
- import type {
2
- BundleRow,
3
- ChannelRow,
4
- ReleaseRow,
5
- } from "@hot-updater/plugin-core";
6
- import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
7
- import { setupDatabasePluginTestSuite } from "@hot-updater/test-utils";
8
- import { env } from "cloudflare:test";
9
- import {
10
- afterAll,
11
- beforeAll,
12
- beforeEach,
13
- describe,
14
- expect,
15
- inject,
16
- it,
17
- vi,
18
- } from "vitest";
19
-
20
- import { createBundleEventRowFixture } from "../../../../packages/test-utils/src/databaseTestFixtures";
21
- import { d1Database } from "../../src/d1Database";
22
- import { d1Database as d1RuntimeDatabase } from "../../src/worker";
23
-
24
- const state = vi.hoisted<{ db: D1Database | undefined }>(() => ({
25
- db: undefined,
26
- }));
27
-
28
- class D1TestStateError extends Error {
29
- readonly name = "D1TestStateError";
30
- }
31
-
32
- const getDb = (): D1Database => {
33
- if (state.db === undefined) {
34
- throw new D1TestStateError();
35
- }
36
- return state.db;
37
- };
38
-
39
- vi.mock("cloudflare", () => ({
40
- default: class MockCloudflare {
41
- readonly d1 = {
42
- database: {
43
- query: async (
44
- _databaseId: string,
45
- input:
46
- | { readonly sql: string; readonly params?: readonly string[] }
47
- | {
48
- readonly batch: readonly {
49
- readonly sql: string;
50
- readonly params?: readonly string[];
51
- }[];
52
- },
53
- ) => {
54
- const statements =
55
- "batch" in input
56
- ? input.batch.map(({ sql, params }) =>
57
- getDb()
58
- .prepare(sql)
59
- .bind(...(params ?? [])),
60
- )
61
- : (() => {
62
- const params = input.params ?? [];
63
- let paramOffset = 0;
64
- return input.sql
65
- .split(";")
66
- .map((sql) => sql.trim())
67
- .filter(Boolean)
68
- .map((sql) => {
69
- const paramCount = sql.match(/\?/g)?.length ?? 0;
70
- const statement = getDb()
71
- .prepare(sql)
72
- .bind(
73
- ...params.slice(
74
- paramOffset,
75
- paramOffset + paramCount,
76
- ),
77
- );
78
- paramOffset += paramCount;
79
- return statement;
80
- });
81
- })();
82
- const results = await getDb().batch(statements);
83
- return {
84
- async *iterPages() {
85
- yield { result: results };
86
- },
87
- };
88
- },
89
- },
90
- };
91
- },
92
- }));
93
-
94
- const reset = async (): Promise<void> => {
95
- await getDb()
96
- .prepare(
97
- "DELETE FROM bundle_events; DELETE FROM bundle_installations; DELETE FROM api_keys; DELETE FROM bundle_patches; DELETE FROM release_catalogs; DELETE FROM releases; DELETE FROM bundles; DELETE FROM channels;",
98
- )
99
- .run();
100
- };
101
-
102
- const createChannelRow = (name: string): ChannelRow => ({
103
- id: `channel-${name}`,
104
- name,
105
- });
106
-
107
- const createBundleRow = (): BundleRow => ({
108
- id: "00000000-0000-0000-0000-000000000902",
109
- platform: "ios",
110
- file_hash: "hash",
111
- git_commit_hash: null,
112
- storage_uri: "storage://bundle",
113
- archive_byte_size: 3_000_000_001,
114
- metadata: {},
115
- manifest_storage_uri: null,
116
- manifest_file_hash: null,
117
- asset_base_storage_uri: null,
118
- });
119
-
120
- const createReleaseRow = (
121
- channel: ChannelRow,
122
- bundle: BundleRow,
123
- ): ReleaseRow => ({
124
- id: "00000000-0000-7000-8000-000000000903",
125
- revision: 1,
126
- scope_key: `v1:test:${channel.name}:ios:app-version`,
127
- channel_id: channel.id,
128
- platform: bundle.platform,
129
- kind: "BUNDLE",
130
- bundle_id: bundle.id,
131
- strategy: "APP_VERSION",
132
- target_app_version: "1.0.0",
133
- fingerprint_hash: null,
134
- enabled: true,
135
- should_force_update: false,
136
- message: null,
137
- rollout_cohort_count: 1000,
138
- target_cohorts: [],
139
- operation: "DEPLOY",
140
- source_release_id: null,
141
- created_at_ms: 100,
142
- updated_at_ms: 100,
143
- });
144
-
145
- setupDatabasePluginTestSuite({
146
- name: "cloudflare d1 http fixed-model database plugin",
147
- migrate: async () => {
148
- state.db = env.DB;
149
- await getDb().prepare(inject("prepareSql")).run();
150
- },
151
- createPlugin: () =>
152
- d1Database({
153
- accountId: "account-id",
154
- cloudflareApiToken: "api-token",
155
- databaseId: "database-id",
156
- }),
157
- reset,
158
- dispose: () => undefined,
159
- });
160
-
161
- setupDatabasePluginTestSuite({
162
- name: "cloudflare worker d1 fixed-model database plugin",
163
- migrate: () => undefined,
164
- createPlugin: () => d1RuntimeDatabase(env.DB),
165
- reset,
166
- dispose: () => {
167
- state.db = undefined;
168
- },
169
- });
170
-
171
- describe.each([
172
- {
173
- name: "cloudflare d1 http",
174
- createPlugin: () =>
175
- d1Database({
176
- accountId: "account-id",
177
- cloudflareApiToken: "api-token",
178
- databaseId: "database-id",
179
- }),
180
- },
181
- {
182
- name: "cloudflare worker d1",
183
- createPlugin: () => d1RuntimeDatabase(env.DB),
184
- },
185
- ])("$name Channel deletion", ({ createPlugin }) => {
186
- beforeAll(() => {
187
- state.db = env.DB;
188
- });
189
-
190
- beforeEach(async () => {
191
- await reset();
192
- });
193
-
194
- afterAll(() => {
195
- state.db = undefined;
196
- });
197
-
198
- it("deletes only empty channels", async () => {
199
- const plugin = createPlugin();
200
- const channel = createChannelRow("empty");
201
- await plugin.models.channels.insert({
202
- row: channel,
203
- onConflict: "returnExisting",
204
- });
205
-
206
- await expect(
207
- plugin.models.channels.delete({ id: channel.id }),
208
- ).resolves.toEqual({ deleted: true });
209
- await expect(
210
- plugin.models.channels.delete({ id: channel.id }),
211
- ).resolves.toEqual({ deleted: false, reason: "not_found" });
212
- });
213
-
214
- it("rolls back the snapshot when the later event insert fails, then safely retries", async () => {
215
- const plugin = createPlugin();
216
- const event = createBundleEventRowFixture("9101", 100);
217
- const input = { event, installation: toInsightsInstallationRow(event) };
218
- await env.DB.prepare(`
219
- CREATE TRIGGER fail_insights_event BEFORE INSERT ON bundle_events
220
- BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;
221
- `).run();
222
- try {
223
- await expect(plugin.models.insights.record(input)).rejects.toThrow(
224
- "injected event failure",
225
- );
226
- expect(
227
- (await env.DB.prepare("SELECT * FROM bundle_events").all()).results,
228
- ).toEqual([]);
229
- expect(
230
- (await env.DB.prepare("SELECT * FROM bundle_installations").all())
231
- .results,
232
- ).toEqual([]);
233
- } finally {
234
- await env.DB.prepare("DROP TRIGGER fail_insights_event").run();
235
- }
236
- await plugin.models.insights.record(input);
237
- await plugin.models.insights.record(input);
238
- await expect(
239
- plugin.models.insights.findInstallations({ installId: event.install_id }),
240
- ).resolves.toEqual([input.installation]);
241
- expect(
242
- (await env.DB.prepare("SELECT id FROM bundle_events").all()).results,
243
- ).toEqual([{ id: event.id }]);
244
- });
245
-
246
- it("rejects direct and committed deletion while a Release references the channel", async () => {
247
- const plugin = createPlugin();
248
- const channel = createChannelRow("active");
249
- const bundle = createBundleRow();
250
- const release = createReleaseRow(channel, bundle);
251
- await plugin.models.channels.insert({
252
- row: channel,
253
- onConflict: "returnExisting",
254
- });
255
- await plugin.commit({
256
- changes: [
257
- { model: "bundles", operation: "insert", row: bundle },
258
- { model: "releases", operation: "insert", row: release },
259
- ],
260
- });
261
-
262
- await expect(
263
- plugin.models.channels.delete({ id: channel.id }),
264
- ).resolves.toEqual({ deleted: false, reason: "not_empty" });
265
- await expect(
266
- plugin.commit({
267
- changes: [
268
- {
269
- model: "channels",
270
- operation: "delete",
271
- where: { id: channel.id },
272
- },
273
- ],
274
- }),
275
- ).resolves.toEqual({
276
- committed: false,
277
- conflict: { changeIndex: 0, reason: "referenced" },
278
- });
279
- await expect(plugin.models.bundles.findById(bundle.id)).resolves.toEqual(
280
- bundle,
281
- );
282
- });
283
-
284
- it("atomically deletes a Release before deleting its newly empty channel", async () => {
285
- const plugin = createPlugin();
286
- const channel = createChannelRow("retired");
287
- const bundle = createBundleRow();
288
- const release = createReleaseRow(channel, bundle);
289
- await plugin.models.channels.insert({
290
- row: channel,
291
- onConflict: "returnExisting",
292
- });
293
- await plugin.commit({
294
- changes: [
295
- { model: "bundles", operation: "insert", row: bundle },
296
- { model: "releases", operation: "insert", row: release },
297
- ],
298
- });
299
-
300
- await expect(
301
- plugin.commit({
302
- changes: [
303
- {
304
- model: "releases",
305
- operation: "delete",
306
- where: { id: release.id },
307
- },
308
- {
309
- model: "channels",
310
- operation: "delete",
311
- where: { id: channel.id },
312
- },
313
- ],
314
- }),
315
- ).resolves.toEqual({ committed: true });
316
- await expect(
317
- plugin.models.releases.findById(release.id),
318
- ).resolves.toBeNull();
319
- await expect(plugin.models.bundles.findById(bundle.id)).resolves.toEqual(
320
- bundle,
321
- );
322
- await expect(plugin.models.channels.list({})).resolves.toEqual({
323
- channels: [],
324
- });
325
- });
326
- });