@hot-updater/cloudflare 1.0.0-rc.2 → 1.0.0-rc.3

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.
@@ -151,6 +151,27 @@ CREATE TABLE bundle_events (
151
151
  CONSTRAINT bundle_events_received_at_check CHECK (received_at_ms >= 0)
152
152
  );
153
153
 
154
+ CREATE TABLE bundle_installations (
155
+ install_id TEXT PRIMARY KEY NOT NULL,
156
+ id TEXT NOT NULL,
157
+ user_id TEXT,
158
+ username TEXT,
159
+ to_bundle_id TEXT NOT NULL,
160
+ type TEXT NOT NULL,
161
+ platform TEXT NOT NULL,
162
+ app_version TEXT NOT NULL,
163
+ channel TEXT NOT NULL,
164
+ cohort TEXT NOT NULL,
165
+ received_at_ms REAL NOT NULL,
166
+ CONSTRAINT bundle_installations_type_check CHECK (
167
+ type IN ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED', 'UNCHANGED')
168
+ ),
169
+ CONSTRAINT bundle_installations_platform_check CHECK (
170
+ platform IN ('ios', 'android')
171
+ ),
172
+ CONSTRAINT bundle_installations_received_at_check CHECK (received_at_ms >= 0)
173
+ );
174
+
154
175
  CREATE TABLE api_keys (
155
176
  id TEXT PRIMARY KEY NOT NULL,
156
177
  hash TEXT NOT NULL,
@@ -183,19 +204,15 @@ CREATE INDEX release_catalogs_channel_idx ON release_catalogs(channel_id);
183
204
  CREATE INDEX bundle_events_received_at_idx
184
205
  ON bundle_events(received_at_ms, id);
185
206
  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);
207
+ ON bundle_events(install_id, type, received_at_ms, id);
208
+ CREATE INDEX bundle_installations_user_id_idx
209
+ ON bundle_installations(user_id, install_id);
210
+ CREATE INDEX bundle_installations_received_at_idx
211
+ ON bundle_installations(received_at_ms);
212
+ CREATE INDEX bundle_events_from_bundle_idx ON bundle_events(type, platform, channel, from_bundle_id, received_at_ms, id);
213
+ CREATE INDEX bundle_events_to_bundle_idx ON bundle_events(type, platform, channel, to_bundle_id, received_at_ms, id);
214
+ CREATE INDEX bundle_installations_scope_idx ON bundle_installations(platform, channel, received_at_ms);
215
+ CREATE INDEX bundle_installations_bundle_idx ON bundle_installations(platform, channel, to_bundle_id, received_at_ms);
199
216
  CREATE UNIQUE INDEX api_keys_hash_key ON api_keys(hash);
200
217
  CREATE INDEX api_keys_created_at_idx
201
218
  ON api_keys(created_at_ms, id);
@@ -10,7 +10,7 @@ declare module "vitest" {
10
10
  }
11
11
  }
12
12
 
13
- it("ships a single D1 1.0.0 CREATE migration", () => {
13
+ it("ships a single 1.0.0 initialization migration", () => {
14
14
  expect(inject("d1Migrations").map(({ name }) => name)).toEqual([
15
15
  "0001_hot-updater_1.0.0.sql",
16
16
  ]);
@@ -47,6 +47,7 @@ it("creates the current schema with required artifact sizes", async () => {
47
47
  expect(tables.results.map(({ name }) => name)).toEqual(
48
48
  expect.arrayContaining([
49
49
  "bundle_events",
50
+ "bundle_installations",
50
51
  "bundle_patches",
51
52
  "bundles",
52
53
  "channels",
@@ -96,6 +97,66 @@ it("creates the current schema with required artifact sizes", async () => {
96
97
  byte_size: 3_000_000_002,
97
98
  });
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
+
99
160
  for (const size of [-1, Number.MAX_SAFE_INTEGER + 1, null]) {
100
161
  await expect(
101
162
  env.DB.prepare("UPDATE bundles SET archive_byte_size = ?")
@@ -3,6 +3,7 @@ import type {
3
3
  ChannelRow,
4
4
  ReleaseRow,
5
5
  } from "@hot-updater/plugin-core";
6
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
6
7
  import { setupDatabasePluginTestSuite } from "@hot-updater/test-utils";
7
8
  import { env } from "cloudflare:test";
8
9
  import {
@@ -16,6 +17,7 @@ import {
16
17
  vi,
17
18
  } from "vitest";
18
19
 
20
+ import { createBundleEventRowFixture } from "../../../../packages/test-utils/src/databaseTestFixtures";
19
21
  import { d1Database } from "../../src/d1Database";
20
22
  import { d1Database as d1RuntimeDatabase } from "../../src/worker";
21
23
 
@@ -92,7 +94,7 @@ vi.mock("cloudflare", () => ({
92
94
  const reset = async (): Promise<void> => {
93
95
  await getDb()
94
96
  .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;",
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;",
96
98
  )
97
99
  .run();
98
100
  };
@@ -209,6 +211,38 @@ describe.each([
209
211
  ).resolves.toEqual({ deleted: false, reason: "not_found" });
210
212
  });
211
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
+
212
246
  it("rejects direct and committed deletion while a Release references the channel", async () => {
213
247
  const plugin = createPlugin();
214
248
  const channel = createChannelRow("active");
@@ -0,0 +1,73 @@
1
+ import { env } from "cloudflare:test";
2
+ import { expect, inject, it } from "vitest";
3
+
4
+ import { d1Database } from "../../src/worker";
5
+
6
+ it("pages sparse movements beyond 50,000 reports through ordered native index ranges", async () => {
7
+ await env.DB.prepare(inject("prepareSql")).run();
8
+ await env.DB.prepare(`
9
+ WITH RECURSIVE reports(n) AS (
10
+ SELECT 1 UNION ALL SELECT n + 1 FROM reports WHERE n < 60000
11
+ )
12
+ INSERT INTO bundle_events (
13
+ id, type, install_id, from_bundle_id, to_bundle_id, platform,
14
+ app_version, channel, cohort, update_strategy, received_at_ms
15
+ )
16
+ SELECT '00000000-0000-7000-8000-' || printf('%012d', n),
17
+ CASE n % 800 WHEN 0 THEN 'UPDATE_APPLIED' WHEN 1 THEN 'RECOVERED' ELSE 'UNCHANGED' END,
18
+ 'sparse-movement-install',
19
+ CASE WHEN n % 800 IN (0, 1) THEN '00000000-0000-7000-8000-000000001001' ELSE NULL END,
20
+ '00000000-0000-7000-8000-000000001002', 'ios', '1.0.0', 'production', '0',
21
+ CASE WHEN n % 800 IN (0, 1) THEN 'appVersion' ELSE NULL END,
22
+ n / 2
23
+ FROM reports
24
+ `).run();
25
+
26
+ const queries: { sql: string; params: readonly unknown[] }[] = [];
27
+ const plugin = d1Database({
28
+ prepare(sql) {
29
+ return {
30
+ bind(...params) {
31
+ queries.push({ sql, params });
32
+ return env.DB.prepare(sql).bind(...params);
33
+ },
34
+ };
35
+ },
36
+ batch: () =>
37
+ Promise.reject(new Error("Unexpected write in movement query")),
38
+ });
39
+ const input = {
40
+ filter: {
41
+ kind: "installationMovement",
42
+ installId: "sparse-movement-install",
43
+ },
44
+ beforeReceivedAtMs: 30001,
45
+ limit: 101,
46
+ } as const;
47
+ const first = await plugin.models.insights.listEvents(input);
48
+ expect(first).toHaveLength(101);
49
+ const last = first[first.length - 1]!;
50
+ const second = await plugin.models.insights.listEvents({
51
+ ...input,
52
+ after: { receivedAtMs: last.received_at_ms, id: last.id },
53
+ });
54
+ expect(second).toHaveLength(49);
55
+ const expected = Array.from({ length: 60000 }, (_, index) => index + 1)
56
+ .filter((index) => index % 800 === 0 || index % 800 === 1)
57
+ .reverse()
58
+ .map(
59
+ (index) => `00000000-0000-7000-8000-${String(index).padStart(12, "0")}`,
60
+ );
61
+ expect([...first, ...second].map(({ id }) => id)).toEqual(expected);
62
+ expect(queries.length).toBeLessThanOrEqual(6);
63
+ for (const { sql, params } of queries) {
64
+ expect(sql).toContain("LIMIT");
65
+ expect(params[params.length - 1]).toBe("0");
66
+ const plan = await env.DB.prepare(`EXPLAIN QUERY PLAN ${sql}`)
67
+ .bind(...params)
68
+ .all<{ detail: string }>();
69
+ const details = plan.results.map(({ detail }) => detail).join("\n");
70
+ expect(details).toContain("bundle_events_install_idx");
71
+ expect(details).not.toContain("TEMP B-TREE");
72
+ }
73
+ });