@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.
@@ -1,9 +1,8 @@
1
- import * as _$_hot_updater_plugin_core0 from "@hot-updater/plugin-core";
2
1
  import { StoragePluginWith } from "@hot-updater/plugin-core";
3
-
4
2
  //#region src/worker/d1Database.d.ts
5
3
  type D1Result = {
6
4
  readonly results?: readonly unknown[];
5
+ readonly success?: boolean;
7
6
  };
8
7
  type D1BoundStatement = {
9
8
  all: () => Promise<D1Result>;
@@ -15,7 +14,7 @@ type D1Like = {
15
14
  prepare(sql: string): D1PreparedStatement;
16
15
  batch(statements: D1BoundStatement[]): Promise<readonly D1Result[]>;
17
16
  };
18
- declare const d1Database: (database: D1Like) => _$_hot_updater_plugin_core0.DatabasePlugin;
17
+ declare const d1Database: (database: D1Like) => import("@hot-updater/plugin-core").DatabasePlugin;
19
18
  //#endregion
20
19
  //#region src/r2WorkerStorage.d.ts
21
20
  interface CloudflareWorkerStorageConfig {
@@ -107,6 +107,29 @@ const eventRow = (row) => {
107
107
  received_at_ms: numberValue(row, "received_at_ms", "bundle_events")
108
108
  };
109
109
  };
110
+ const installationRow = (row) => {
111
+ const type = stringValue(row, "type", "bundle_installations");
112
+ const platform = stringValue(row, "platform", "bundle_installations");
113
+ if (![
114
+ "UPDATE_APPLIED",
115
+ "RECOVERED",
116
+ "RELEASE_ADOPTED",
117
+ "UNCHANGED"
118
+ ].includes(type) || platform !== "ios" && platform !== "android") throw new InvalidD1RowError("bundle_installations");
119
+ return {
120
+ id: stringValue(row, "id", "bundle_installations"),
121
+ install_id: stringValue(row, "install_id", "bundle_installations"),
122
+ user_id: nullableString(row, "user_id", "bundle_installations"),
123
+ username: nullableString(row, "username", "bundle_installations"),
124
+ to_bundle_id: stringValue(row, "to_bundle_id", "bundle_installations"),
125
+ type,
126
+ platform,
127
+ app_version: stringValue(row, "app_version", "bundle_installations"),
128
+ channel: stringValue(row, "channel", "bundle_installations"),
129
+ cohort: stringValue(row, "cohort", "bundle_installations"),
130
+ received_at_ms: numberValue(row, "received_at_ms", "bundle_installations")
131
+ };
132
+ };
110
133
  const apiKeyRow = (row) => {
111
134
  const role = stringValue(row, "role", "api_keys");
112
135
  if (role !== "client") throw new InvalidD1RowError("api_keys");
@@ -181,6 +204,7 @@ function parseD1Row(model, value) {
181
204
  case "bundle_patches": return patchRow(value);
182
205
  case "channels": return channelRow(value);
183
206
  case "bundle_events": return eventRow(value);
207
+ case "bundle_installations": return installationRow(value);
184
208
  case "api_keys": return apiKeyRow(value);
185
209
  case "releases": return releaseRow(value);
186
210
  case "release_catalogs": return releaseCatalogRow(value);
@@ -217,7 +241,8 @@ const stringPredicate = (condition, operator) => {
217
241
  };
218
242
  }
219
243
  const literal = escapeLikePattern(condition.value);
220
- const parameter = bind(operator === "contains" ? `%${literal}%` : operator === "starts_with" ? `${literal}%` : `%${literal}`);
244
+ const pattern = operator === "contains" ? `%${literal}%` : operator === "starts_with" ? `${literal}%` : `%${literal}`;
245
+ const parameter = bind(pattern);
221
246
  return {
222
247
  sql: `lower(${condition.field}) LIKE lower(${parameter.sql}) ESCAPE '\\'`,
223
248
  params: parameter.params
@@ -302,11 +327,13 @@ const d1TableNames = {
302
327
  bundle_patches: "bundle_patches",
303
328
  channels: "channels",
304
329
  bundle_events: "bundle_events",
330
+ bundle_installations: "bundle_installations",
305
331
  api_keys: "api_keys",
306
332
  release_catalogs: "release_catalogs",
307
333
  releases: "releases"
308
334
  };
309
335
  var D1QueryResultError = class extends Error {
336
+ result;
310
337
  name = "D1QueryResultError";
311
338
  operation = "count";
312
339
  constructor(result) {
@@ -337,6 +364,7 @@ const findManyD1Rows = async (executor, input) => {
337
364
  case "bundle_patches": return rows.map((row) => parseD1Row("bundle_patches", row));
338
365
  case "channels": return rows.map((row) => parseD1Row("channels", row));
339
366
  case "bundle_events": return rows.map((row) => parseD1Row("bundle_events", row));
367
+ case "bundle_installations": return rows.map((row) => parseD1Row("bundle_installations", row));
340
368
  case "api_keys": return rows.map((row) => parseD1Row("api_keys", row));
341
369
  case "releases": return rows.map((row) => parseD1Row("releases", row));
342
370
  case "release_catalogs": return rows.map((row) => parseD1Row("release_catalogs", row));
@@ -344,6 +372,12 @@ const findManyD1Rows = async (executor, input) => {
344
372
  };
345
373
  //#endregion
346
374
  //#region src/d1Implementation.ts
375
+ var D1ExecutionError = class extends Error {
376
+ name = "D1ExecutionError";
377
+ constructor() {
378
+ super("D1 did not successfully execute every requested statement");
379
+ }
380
+ };
347
381
  const EXPECTATION_CONFLICT_MARKER = "HOT_UPDATER_COMMIT_EXPECTATION_CONFLICT";
348
382
  const expectationGuard = (expectations) => {
349
383
  const clauses = [];
@@ -381,7 +415,8 @@ const expectationConflict = async (executor, expectations) => {
381
415
  const versionField = isRelease ? "revision" : "generation";
382
416
  const key = isRelease ? expectation.id : expectation.scopeKey;
383
417
  const expectedVersion = isRelease ? expectation.revision : expectation.generation;
384
- const actualVersion = readVersion((await executor.query(`SELECT ${versionField} FROM ${table} WHERE ${keyField} = json_extract(?, '$') LIMIT 1`, encodeD1Values([key])))[0], versionField);
418
+ const rows = await executor.query(`SELECT ${versionField} FROM ${table} WHERE ${keyField} = json_extract(?, '$') LIMIT 1`, encodeD1Values([key]));
419
+ const actualVersion = readVersion(rows[0], versionField);
385
420
  if (actualVersion !== expectedVersion) return {
386
421
  committed: false,
387
422
  conflict: {
@@ -460,14 +495,14 @@ const insertQuery = (input, guard, conflictMode = "returnExisting") => {
460
495
  values = channelValues(input.data);
461
496
  break;
462
497
  case "bundle_events":
498
+ case "bundle_installations":
463
499
  case "api_keys":
464
500
  case "release_catalogs":
465
501
  case "releases":
466
502
  columns = Object.keys(input.data);
467
503
  values = Object.values(input.data);
468
- break;
469
504
  }
470
- 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" : "";
505
+ 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" : " ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id";
471
506
  return {
472
507
  sql: `INSERT INTO ${d1TableNames[input.model]} (${columns.join(", ")}) ${guard === void 0 ? `VALUES (${d1Placeholders(values.length)})` : `SELECT ${d1Placeholders(values.length)} WHERE ${guard.sql}`}${conflict} RETURNING *`,
473
508
  params: [...encodeD1Values(values), ...guard?.params ?? []]
@@ -531,9 +566,7 @@ const channelDeletePrecondition = (changes, changeIndex, channelId) => {
531
566
  releaseEffects.set(change.row.id, change.row.channel_id);
532
567
  break;
533
568
  case "update": break;
534
- case "delete":
535
- releaseEffects.set(change.where.id, null);
536
- break;
569
+ case "delete": releaseEffects.set(change.where.id, null);
537
570
  }
538
571
  }
539
572
  return {
@@ -608,10 +641,6 @@ const changeQuery = (change, guard) => {
608
641
  sql: `DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS (SELECT 1 FROM releases WHERE channel_id = json_extract(?, '$')) AND ${guard.sql}`,
609
642
  params: [...encodeD1Values([change.where.id, change.where.id]), ...guard.params]
610
643
  };
611
- case "insights": return insertQuery({
612
- model: "bundle_events",
613
- data: change.row
614
- }, guard);
615
644
  case "apiKeys": return change.operation === "insert" ? insertQuery({
616
645
  model: "api_keys",
617
646
  data: change.row,
@@ -780,6 +809,25 @@ const deleteChannel = async (executor, input) => {
780
809
  };
781
810
  };
782
811
  const createD1Implementation = (executor) => ({
812
+ async recordInsights({ event, installation }) {
813
+ const columns = Object.keys(installation);
814
+ const eventInsert = insertQuery({
815
+ model: "bundle_events",
816
+ data: event
817
+ });
818
+ await executor.batch([{
819
+ sql: `INSERT INTO bundle_installations (${columns.join(", ")})
820
+ SELECT ${d1Placeholders(columns.length)}
821
+ WHERE NOT EXISTS (SELECT 1 FROM bundle_events WHERE id = json_extract(?, '$'))
822
+ ON CONFLICT(install_id) DO UPDATE SET ${columns.filter((column) => column !== "install_id").map((column) => `${column} = excluded.${column}`).join(", ")}
823
+ WHERE (excluded.received_at_ms, excluded.id) >
824
+ (bundle_installations.received_at_ms, bundle_installations.id)`,
825
+ params: encodeD1Values([...Object.values(installation), event.id])
826
+ }, {
827
+ ...eventInsert,
828
+ sql: eventInsert.sql.replace(" RETURNING *", " ON CONFLICT(id) DO NOTHING")
829
+ }]);
830
+ },
783
831
  async create(input) {
784
832
  const query = insertQuery(input);
785
833
  const rows = await executor.query(query.sql, query.params);
@@ -788,6 +836,7 @@ const createD1Implementation = (executor) => ({
788
836
  case "bundle_patches": return parseD1Row("bundle_patches", rows[0]);
789
837
  case "channels": return parseD1Row("channels", rows[0]);
790
838
  case "bundle_events": return parseD1Row("bundle_events", rows[0]);
839
+ case "bundle_installations": return parseD1Row("bundle_installations", rows[0]);
791
840
  case "api_keys": return parseD1Row("api_keys", rows[0]);
792
841
  case "releases": return parseD1Row("releases", rows[0]);
793
842
  case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
@@ -802,6 +851,7 @@ const createD1Implementation = (executor) => ({
802
851
  case "api_keys": return parseD1Row("api_keys", rows[0]);
803
852
  case "releases": return parseD1Row("releases", rows[0]);
804
853
  case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
854
+ case "bundle_installations": return parseD1Row("bundle_installations", rows[0]);
805
855
  }
806
856
  },
807
857
  async delete(input) {
@@ -820,6 +870,7 @@ const createD1Implementation = (executor) => ({
820
870
  case "api_keys": return parseD1Row("api_keys", rows[0]);
821
871
  case "releases": return parseD1Row("releases", rows[0]);
822
872
  case "release_catalogs": return parseD1Row("release_catalogs", rows[0]);
873
+ case "bundle_installations": return parseD1Row("bundle_installations", rows[0]);
823
874
  }
824
875
  },
825
876
  findMany: (input) => findManyD1Rows(executor, input),
@@ -852,14 +903,19 @@ const createD1Implementation = (executor) => ({
852
903
  //#endregion
853
904
  //#region src/worker/d1Database.ts
854
905
  const d1Database = (database) => {
855
- const adapter = createDatabasePluginAdapter("d1Database", createD1Implementation({
906
+ const implementation = createD1Implementation({
856
907
  async query(sql, params) {
857
- return (await database.prepare(sql).bind(...params).all()).results ?? [];
908
+ const result = await database.prepare(sql).bind(...params).all();
909
+ if (result.success === false) throw new D1ExecutionError();
910
+ return result.results ?? [];
858
911
  },
859
912
  async batch(statements) {
860
- return (await database.batch(statements.map(({ sql, params }) => database.prepare(sql).bind(...params)))).map(({ results }) => results ?? []);
913
+ const results = await database.batch(statements.map(({ sql, params }) => database.prepare(sql).bind(...params)));
914
+ if (results.length !== statements.length || results.some(({ success }) => success === false)) throw new D1ExecutionError();
915
+ return results.map(({ results }) => results ?? []);
861
916
  }
862
- }));
917
+ });
918
+ const adapter = createDatabasePluginAdapter("d1Database", implementation);
863
919
  return createDatabasePlugin({
864
920
  name: "d1Database",
865
921
  models: adapter.models,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/cloudflare",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.2",
4
+ "version": "1.0.0-rc.3",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.mjs",
@@ -60,10 +60,10 @@
60
60
  "cloudflare": "4.2.0",
61
61
  "hono": "4.12.34",
62
62
  "uuidv7": "^1.0.2",
63
- "@hot-updater/cli-tools": "1.0.0-rc.1",
64
- "@hot-updater/plugin-core": "1.0.0-rc.1",
65
63
  "@hot-updater/core": "1.0.0-rc.0",
66
- "@hot-updater/server": "1.0.0-rc.1"
64
+ "@hot-updater/plugin-core": "1.0.0-rc.2",
65
+ "@hot-updater/server": "1.0.0-rc.2",
66
+ "@hot-updater/cli-tools": "1.0.0-rc.2"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@cloudflare/vitest-pool-workers": "0.13.0",
@@ -75,11 +75,11 @@
75
75
  "toml": "^3.0.0",
76
76
  "@typescript/native": "npm:typescript@^7.0.2",
77
77
  "typescript": "npm:@typescript/typescript6@^6.0.2",
78
- "verkit": "0.3.2",
78
+ "verkit": "0.4.0",
79
79
  "vitest": "4.1.4",
80
80
  "wrangler": "^4.69.0",
81
81
  "xdg-app-paths": "^8.3.0",
82
- "@hot-updater/test-utils": "1.0.0-rc.1"
82
+ "@hot-updater/test-utils": "1.0.0-rc.2"
83
83
  },
84
84
  "scripts": {
85
85
  "build": "tsdown && pnpm build:worker",
package/sql/bundles.sql CHANGED
@@ -149,6 +149,27 @@ CREATE TABLE bundle_events (
149
149
  CONSTRAINT bundle_events_received_at_check CHECK (received_at_ms >= 0)
150
150
  );
151
151
 
152
+ CREATE TABLE bundle_installations (
153
+ install_id TEXT PRIMARY KEY NOT NULL,
154
+ id TEXT NOT NULL,
155
+ user_id TEXT,
156
+ username TEXT,
157
+ to_bundle_id TEXT NOT NULL,
158
+ type TEXT NOT NULL,
159
+ platform TEXT NOT NULL,
160
+ app_version TEXT NOT NULL,
161
+ channel TEXT NOT NULL,
162
+ cohort TEXT NOT NULL,
163
+ received_at_ms REAL NOT NULL,
164
+ CONSTRAINT bundle_installations_type_check CHECK (
165
+ type IN ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED', 'UNCHANGED')
166
+ ),
167
+ CONSTRAINT bundle_installations_platform_check CHECK (
168
+ platform IN ('ios', 'android')
169
+ ),
170
+ CONSTRAINT bundle_installations_received_at_check CHECK (received_at_ms >= 0)
171
+ );
172
+
152
173
  CREATE TABLE api_keys (
153
174
  id TEXT PRIMARY KEY NOT NULL,
154
175
  hash TEXT NOT NULL,
@@ -179,12 +200,13 @@ CREATE INDEX releases_fingerprint_hash_idx ON releases(fingerprint_hash);
179
200
  CREATE INDEX releases_enabled_idx ON releases(enabled);
180
201
  CREATE INDEX release_catalogs_channel_idx ON release_catalogs(channel_id);
181
202
  CREATE INDEX bundle_events_received_at_idx ON bundle_events(received_at_ms, id);
182
- CREATE INDEX bundle_events_install_idx ON bundle_events(install_id, received_at_ms, id);
183
- CREATE INDEX bundle_events_user_id_idx ON bundle_events(user_id, received_at_ms, id);
184
- CREATE INDEX bundle_events_username_idx ON bundle_events(username, received_at_ms, id);
185
- CREATE INDEX bundle_events_to_bundle_idx ON bundle_events(type, to_bundle_id, received_at_ms, id);
186
- CREATE INDEX bundle_events_from_bundle_idx ON bundle_events(type, from_bundle_id, received_at_ms, id);
187
- CREATE INDEX bundle_events_to_release_idx ON bundle_events(type, to_release_id, received_at_ms, id);
188
- CREATE INDEX bundle_events_from_release_idx ON bundle_events(type, from_release_id, received_at_ms, id);
203
+ CREATE INDEX bundle_events_install_idx ON bundle_events(install_id, type, received_at_ms, id);
204
+ CREATE INDEX bundle_installations_user_id_idx ON bundle_installations(user_id, install_id);
205
+ CREATE INDEX bundle_installations_received_at_idx ON bundle_installations(received_at_ms);
189
206
  CREATE UNIQUE INDEX api_keys_hash_key ON api_keys(hash);
190
207
  CREATE INDEX api_keys_created_at_idx ON api_keys(created_at_ms, id);
208
+
209
+ CREATE INDEX bundle_events_from_bundle_idx ON bundle_events(type, platform, channel, from_bundle_id, received_at_ms, id);
210
+ CREATE INDEX bundle_events_to_bundle_idx ON bundle_events(type, platform, channel, to_bundle_id, received_at_ms, id);
211
+ CREATE INDEX bundle_installations_scope_idx ON bundle_installations(platform, channel, received_at_ms);
212
+ CREATE INDEX bundle_installations_bundle_idx ON bundle_installations(platform, channel, to_bundle_id, received_at_ms);
@@ -1,3 +1,4 @@
1
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
1
2
  import { beforeEach, expect, it, vi } from "vitest";
2
3
 
3
4
  import { d1Database } from "./d1Database";
@@ -26,6 +27,26 @@ const bundleD1Row = {
26
27
  asset_base_storage_uri: null,
27
28
  } as const;
28
29
 
30
+ const eventD1Row = {
31
+ id: "00000000-0000-7000-8000-000000000001",
32
+ type: "UPDATE_APPLIED",
33
+ install_id: "install-1",
34
+ user_id: "user-1",
35
+ username: "Demo User",
36
+ from_release_id: null,
37
+ from_bundle_id: "bundle-previous",
38
+ to_release_id: null,
39
+ to_bundle_id: "bundle-current",
40
+ platform: "ios",
41
+ app_version: "1.0.0",
42
+ channel: "production",
43
+ cohort: "cohort-1",
44
+ update_strategy: "appVersion",
45
+ fingerprint_hash: null,
46
+ sdk_version: "1.0.0",
47
+ received_at_ms: 100,
48
+ } as const;
49
+
29
50
  vi.mock("cloudflare", () => ({
30
51
  default: class MockCloudflare {
31
52
  readonly d1 = {
@@ -176,3 +197,81 @@ it("sends parameterized commits through the D1 batch body", async () => {
176
197
  expect(state.batches[0]?.[0]?.params.length).toBeGreaterThan(0);
177
198
  expect(state.batches[0]?.[1]?.params.length).toBeGreaterThan(0);
178
199
  });
200
+
201
+ it("stores the event and current installation in one parameterized atomic batch", async () => {
202
+ const plugin = d1Database({
203
+ accountId: "account",
204
+ cloudflareApiToken: "token",
205
+ databaseId: "database",
206
+ });
207
+ await expect(
208
+ plugin.models.insights.record({
209
+ event: eventD1Row,
210
+ installation: toInsightsInstallationRow(eventD1Row),
211
+ }),
212
+ ).resolves.toBeUndefined();
213
+ expect(state.queries).toHaveLength(0);
214
+ expect(state.batches).toHaveLength(1);
215
+ expect(state.batches[0]).toHaveLength(2);
216
+ expect(state.batches[0]?.[0]?.sql).toContain(
217
+ "WHERE NOT EXISTS (SELECT 1 FROM bundle_events",
218
+ );
219
+ expect(state.batches[0]?.[1]?.sql).toContain("ON CONFLICT(id) DO NOTHING");
220
+ });
221
+
222
+ it("queries each movement type through a bounded descending range", async () => {
223
+ const plugin = d1Database({
224
+ accountId: "account",
225
+ cloudflareApiToken: "token",
226
+ databaseId: "database",
227
+ });
228
+
229
+ await expect(
230
+ plugin.models.insights.listEvents({
231
+ filter: {
232
+ kind: "installationMovement",
233
+ installId: eventD1Row.install_id,
234
+ },
235
+ beforeReceivedAtMs: 200,
236
+ limit: 101,
237
+ }),
238
+ ).resolves.toEqual([]);
239
+
240
+ expect(state.queries).toHaveLength(2);
241
+ for (const query of state.queries) {
242
+ expect(query.sql).toContain("SELECT * FROM bundle_events");
243
+ expect(query.sql).toContain("type = json_extract(?, '$')");
244
+ expect(query.sql).toContain("ORDER BY received_at_ms DESC, id DESC");
245
+ expect(query.params).toContain("101");
246
+ }
247
+ expect(state.queries.flatMap(({ params }) => params)).toEqual(
248
+ expect.arrayContaining([
249
+ JSON.stringify("UPDATE_APPLIED"),
250
+ JSON.stringify("RECOVERED"),
251
+ ]),
252
+ );
253
+ });
254
+
255
+ it("counts active installations without reading event history", async () => {
256
+ state.results.push({ count: 2 });
257
+ const plugin = d1Database({
258
+ accountId: "account",
259
+ cloudflareApiToken: "token",
260
+ databaseId: "database",
261
+ });
262
+
263
+ await expect(
264
+ plugin.models.insights.countInstallations({
265
+ platform: "ios",
266
+ channel: "production",
267
+ sinceMs: 100,
268
+ }),
269
+ ).resolves.toBe(2);
270
+
271
+ expect(state.queries).toHaveLength(1);
272
+ expect(state.queries[0]?.sql).toContain(
273
+ "SELECT COUNT(*) AS count FROM bundle_installations",
274
+ );
275
+ expect(state.queries[0]?.sql).toContain("received_at_ms >=");
276
+ expect(state.queries[0]?.sql).not.toContain("bundle_events");
277
+ });
package/src/d1Database.ts CHANGED
@@ -2,7 +2,11 @@ import { createDatabasePlugin } from "@hot-updater/plugin-core";
2
2
  import { createDatabasePluginAdapter } from "@hot-updater/plugin-core/internal";
3
3
  import Cloudflare from "cloudflare";
4
4
 
5
- import { createD1Implementation, type D1Statement } from "./d1Implementation";
5
+ import {
6
+ createD1Implementation,
7
+ D1ExecutionError,
8
+ type D1Statement,
9
+ } from "./d1Implementation";
6
10
 
7
11
  export interface D1DatabaseConfig {
8
12
  readonly databaseId: string;
@@ -41,9 +45,11 @@ export const d1Database = (config: D1DatabaseConfig) => {
41
45
  const results: unknown[][] = [];
42
46
  for await (const resultPage of page.iterPages()) {
43
47
  for (const result of resultPage.result) {
48
+ if (result.success === false) throw new D1ExecutionError();
44
49
  results.push(result.results ?? []);
45
50
  }
46
51
  }
52
+ if (results.length !== statements.length) throw new D1ExecutionError();
47
53
  return results;
48
54
  };
49
55
 
@@ -2,6 +2,20 @@ import { expect, it } from "vitest";
2
2
 
3
3
  import { createD1Implementation, type D1Statement } from "./d1Implementation";
4
4
 
5
+ const installationRow = {
6
+ id: "event-1",
7
+ install_id: "install-1",
8
+ user_id: "user-1",
9
+ username: "Demo User",
10
+ to_bundle_id: "bundle-1",
11
+ type: "UPDATE_APPLIED" as const,
12
+ platform: "ios" as const,
13
+ app_version: "1.0.0",
14
+ channel: "production",
15
+ cohort: "cohort-1",
16
+ received_at_ms: 100,
17
+ };
18
+
5
19
  it("guards every write and reports the missing change index", async () => {
6
20
  let recorded: readonly D1Statement[] = [];
7
21
  const implementation = createD1Implementation({
@@ -250,3 +264,79 @@ it("guards a generic Channel delete and reports a referenced conflict", async ()
250
264
  expect(recorded[1]?.sql).toContain("DELETE FROM channels");
251
265
  expect(recorded[1]?.sql).toContain("NOT EXISTS");
252
266
  });
267
+
268
+ it("returns the canonical current installation on an idempotent create", async () => {
269
+ const queries: D1Statement[] = [];
270
+ const implementation = createD1Implementation({
271
+ async query(sql, params) {
272
+ queries.push({ sql, params });
273
+ return [installationRow];
274
+ },
275
+ batch: () => Promise.reject(new Error("unexpected batch")),
276
+ });
277
+
278
+ await expect(
279
+ implementation.create({
280
+ model: "bundle_installations",
281
+ data: installationRow,
282
+ onConflict: "ignore",
283
+ }),
284
+ ).resolves.toEqual(installationRow);
285
+
286
+ expect(queries[0]?.sql).toContain("INSERT INTO bundle_installations");
287
+ expect(queries[0]?.sql).toContain(
288
+ "ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id",
289
+ );
290
+ expect(queries[0]?.sql).toContain("RETURNING *");
291
+ });
292
+
293
+ it("updates a current installation only through the supplied latest-row condition", async () => {
294
+ const queries: D1Statement[] = [];
295
+ const implementation = createD1Implementation({
296
+ async query(sql, params) {
297
+ queries.push({ sql, params });
298
+ return [installationRow];
299
+ },
300
+ batch: () => Promise.reject(new Error("unexpected batch")),
301
+ });
302
+ const { install_id: _installId, ...update } = installationRow;
303
+
304
+ await expect(
305
+ implementation.update({
306
+ model: "bundle_installations",
307
+ where: [
308
+ { field: "install_id", value: installationRow.install_id },
309
+ {
310
+ field: "received_at_ms",
311
+ operator: "lt",
312
+ value: installationRow.received_at_ms,
313
+ },
314
+ ],
315
+ update,
316
+ }),
317
+ ).resolves.toEqual(installationRow);
318
+
319
+ await expect(
320
+ implementation.update({
321
+ model: "bundle_installations",
322
+ where: [
323
+ { field: "install_id", value: installationRow.install_id },
324
+ {
325
+ field: "received_at_ms",
326
+ value: installationRow.received_at_ms,
327
+ },
328
+ { field: "id", operator: "lt", value: installationRow.id },
329
+ ],
330
+ update,
331
+ }),
332
+ ).resolves.toEqual(installationRow);
333
+
334
+ expect(queries[0]?.sql).toContain("UPDATE bundle_installations SET");
335
+ expect(queries[0]?.sql).toContain(
336
+ "WHERE (install_id = json_extract(?, '$') AND received_at_ms < json_extract(?, '$'))",
337
+ );
338
+ expect(queries[0]?.sql).toContain("RETURNING *");
339
+ expect(queries[1]?.sql).toContain(
340
+ "WHERE ((install_id = json_extract(?, '$') AND received_at_ms = json_extract(?, '$')) AND id < json_extract(?, '$'))",
341
+ );
342
+ });
@@ -35,6 +35,13 @@ export interface D1Statement {
35
35
  readonly params: readonly string[];
36
36
  }
37
37
 
38
+ export class D1ExecutionError extends Error {
39
+ readonly name = "D1ExecutionError";
40
+ constructor() {
41
+ super("D1 did not successfully execute every requested statement");
42
+ }
43
+ }
44
+
38
45
  type D1Guard = {
39
46
  readonly sql: string;
40
47
  readonly params: readonly string[];
@@ -191,6 +198,7 @@ const insertQuery = (
191
198
  values = channelValues(input.data);
192
199
  break;
193
200
  case "bundle_events":
201
+ case "bundle_installations":
194
202
  case "api_keys":
195
203
  case "release_catalogs":
196
204
  case "releases":
@@ -210,7 +218,7 @@ const insertQuery = (
210
218
  ? conflictMode === "ignore"
211
219
  ? " ON CONFLICT(hash) DO NOTHING"
212
220
  : " ON CONFLICT(hash) DO UPDATE SET hash = excluded.hash"
213
- : "";
221
+ : " ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id";
214
222
 
215
223
  return {
216
224
  sql: `INSERT INTO ${d1TableNames[input.model]} (${columns.join(", ")}) ${
@@ -439,8 +447,6 @@ const changeQuery = (change: DatabaseChange, guard: D1Guard): D1Statement => {
439
447
  ...guard.params,
440
448
  ],
441
449
  };
442
- case "insights":
443
- return insertQuery({ model: "bundle_events", data: change.row }, guard);
444
450
  case "apiKeys":
445
451
  return change.operation === "insert"
446
452
  ? insertQuery(
@@ -638,6 +644,33 @@ const deleteChannel = async (
638
644
  export const createD1Implementation = (
639
645
  executor: D1Executor,
640
646
  ): DatabasePluginImplementation => ({
647
+ async recordInsights({ event, installation }) {
648
+ const columns = Object.keys(installation);
649
+ const eventInsert = insertQuery({ model: "bundle_events", data: event });
650
+ // D1 executes the whole batch as one serial transaction. The snapshot
651
+ // guard runs before the event insert so a duplicate cannot mutate it.
652
+ await executor.batch([
653
+ {
654
+ sql: `INSERT INTO bundle_installations (${columns.join(", ")})
655
+ SELECT ${d1Placeholders(columns.length)}
656
+ WHERE NOT EXISTS (SELECT 1 FROM bundle_events WHERE id = json_extract(?, '$'))
657
+ ON CONFLICT(install_id) DO UPDATE SET ${columns
658
+ .filter((column) => column !== "install_id")
659
+ .map((column) => `${column} = excluded.${column}`)
660
+ .join(", ")}
661
+ WHERE (excluded.received_at_ms, excluded.id) >
662
+ (bundle_installations.received_at_ms, bundle_installations.id)`,
663
+ params: encodeD1Values([...Object.values(installation), event.id]),
664
+ },
665
+ {
666
+ ...eventInsert,
667
+ sql: eventInsert.sql.replace(
668
+ " RETURNING *",
669
+ " ON CONFLICT(id) DO NOTHING",
670
+ ),
671
+ },
672
+ ]);
673
+ },
641
674
  async create(input) {
642
675
  const query = insertQuery(input);
643
676
  const rows = await executor.query(query.sql, query.params);
@@ -650,6 +683,8 @@ export const createD1Implementation = (
650
683
  return parseD1Row("channels", rows[0]);
651
684
  case "bundle_events":
652
685
  return parseD1Row("bundle_events", rows[0]);
686
+ case "bundle_installations":
687
+ return parseD1Row("bundle_installations", rows[0]);
653
688
  case "api_keys":
654
689
  return parseD1Row("api_keys", rows[0]);
655
690
  case "releases":
@@ -671,6 +706,8 @@ export const createD1Implementation = (
671
706
  return parseD1Row("releases", rows[0]);
672
707
  case "release_catalogs":
673
708
  return parseD1Row("release_catalogs", rows[0]);
709
+ case "bundle_installations":
710
+ return parseD1Row("bundle_installations", rows[0]);
674
711
  }
675
712
  },
676
713
  async delete(input) {
@@ -698,6 +735,8 @@ export const createD1Implementation = (
698
735
  return parseD1Row("releases", rows[0]);
699
736
  case "release_catalogs":
700
737
  return parseD1Row("release_catalogs", rows[0]);
738
+ case "bundle_installations":
739
+ return parseD1Row("bundle_installations", rows[0]);
701
740
  }
702
741
  },
703
742
  findMany: (input) => findManyD1Rows(executor, input),
package/src/d1Query.ts CHANGED
@@ -14,6 +14,7 @@ export const d1TableNames = {
14
14
  bundle_patches: "bundle_patches",
15
15
  channels: "channels",
16
16
  bundle_events: "bundle_events",
17
+ bundle_installations: "bundle_installations",
17
18
  api_keys: "api_keys",
18
19
  release_catalogs: "release_catalogs",
19
20
  releases: "releases",
@@ -79,6 +80,8 @@ export const findManyD1Rows = async (
79
80
  return rows.map((row) => parseD1Row("channels", row));
80
81
  case "bundle_events":
81
82
  return rows.map((row) => parseD1Row("bundle_events", row));
83
+ case "bundle_installations":
84
+ return rows.map((row) => parseD1Row("bundle_installations", row));
82
85
  case "api_keys":
83
86
  return rows.map((row) => parseD1Row("api_keys", row));
84
87
  case "releases":