@hot-updater/plugin-insights 1.0.0-rc.35 → 1.0.0-rc.37

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,6 +1,7 @@
1
+ const require_eventRow = require("./eventRow.cjs");
2
+ const require_schema = require("./schema.cjs");
1
3
  const require_contract = require("./contract.cjs");
2
4
  const require_overview = require("./overview.cjs");
3
- const require_schema = require("./schema.cjs");
4
5
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
5
6
  //#region src/server/recordEvent.ts
6
7
  const hourOf = (ms) => ms - ms % require_schema.HOUR_MS;
@@ -13,13 +14,16 @@ const insightsIdentity = (identity) => require_overview.insightsOverviewId({
13
14
  bucketStartMs: 0
14
15
  });
15
16
  /**
16
- * The bucket a head's gauges count it in, the UTC day of its event, and the
17
- * release its distribution row names: the one it runs, which a download
18
- * leaves at its source.
17
+ * The bucket a head's gauges count it in, the UTC day of its event, and what
18
+ * its distribution row names: the release it runs, which a download leaves
19
+ * at its source, and the built-in bundle, when it runs the native build's.
20
+ * A head stored without the built-in bundle ID counts in no built-in row, so
21
+ * taking it back moves none.
19
22
  */
20
23
  const gaugeSlot = (head) => ({
21
24
  bucket: dayOf(head.received_at_ms),
22
- releaseId: head.type === "UPDATE_DOWNLOADED" ? head.from_release_id : head.to_release_id
25
+ releaseId: head.type === "UPDATE_DOWNLOADED" ? head.from_release_id : head.to_release_id,
26
+ builtinBundleId: require_eventRow.runningBuiltinBundleId(head)
23
27
  });
24
28
  /**
25
29
  * The `insights_latest_by_bundle` field of a head's (from, to) pair. A head
@@ -30,11 +34,12 @@ const PAIR_FIELD = "from_to";
30
34
  /** The pair gauge's `bundle_id`: a hash of the two bundle ids, which fit no single 36-character column. */
31
35
  const bundlePairKey = (from, to) => require_overview.insightsKey(`${from.length}:${from}${to.length}:${to}`);
32
36
  /**
33
- * Moves a head's gauges: the distribution row, one row per bundle it
34
- * references, and its pair; -1 takes back the head an event replaces.
37
+ * Moves a head's gauges: the distribution row, and the built-in one when it
38
+ * runs its build's built-in bundle, one row per bundle it references, and
39
+ * its pair; -1 takes back the head an event replaces.
35
40
  */
36
41
  const countHead = (tx, head, delta) => {
37
- const { bucket, releaseId } = gaugeSlot(head);
42
+ const { bucket, releaseId, builtinBundleId } = gaugeSlot(head);
38
43
  const shardBy = head.install_id;
39
44
  tx.aggregate("insights_distribution", {
40
45
  channel: head.channel,
@@ -43,6 +48,14 @@ const countHead = (tx, head, delta) => {
43
48
  release_id: releaseId ?? "",
44
49
  bucket_start_ms: bucket
45
50
  }, { latest_installations: delta }, { shardBy });
51
+ if (builtinBundleId !== null) tx.aggregate("insights_builtin_distribution", {
52
+ channel: head.channel,
53
+ platform: head.platform,
54
+ app_version: head.app_version,
55
+ release_id: releaseId ?? "",
56
+ builtin_bundle_id: builtinBundleId,
57
+ bucket_start_ms: bucket
58
+ }, { latest_installations: delta }, { shardBy });
46
59
  for (const field of ["from_bundle_id", "to_bundle_id"]) {
47
60
  const bundleId = head[field];
48
61
  if (bundleId === null) continue;
@@ -123,16 +136,16 @@ const countEvent = (tx, event) => {
123
136
  }
124
137
  };
125
138
  /**
126
- * A stored event's outcome rows: its bundle filter's hour, which a failed
127
- * check has none of, and every stored event's UTC day.
139
+ * A stored event's outcome rows: its bundle filter's hour, when it has one
140
+ * (`bundleRefsOf`), and every stored event's UTC day.
128
141
  */
129
142
  const countOutcome = (tx, event) => {
130
143
  const shardBy = event.install_id;
131
- if (!require_schema.isFailedCheck(event)) tx.aggregate("insights_outcomes", {
144
+ for (const bundleRef of require_schema.bundleRefsOf(event)) tx.aggregate("insights_outcomes", {
132
145
  platform: event.platform,
133
146
  channel: event.channel,
134
147
  type: event.type,
135
- bundle_ref: event.type === "RECOVERED" ? `from:${event.from_bundle_id}` : `to:${event.to_bundle_id}`,
148
+ bundle_ref: bundleRef,
136
149
  bucket_start_ms: hourOf(event.received_at_ms)
137
150
  }, { events: 1 }, { shardBy });
138
151
  tx.aggregate("insights_outcomes", {
@@ -236,6 +249,126 @@ const countFailure = (tx, event) => {
236
249
  }, { events: 1 }, { shardBy });
237
250
  };
238
251
  const isNewer = (event, head) => event.received_at_ms !== head.received_at_ms ? event.received_at_ms > head.received_at_ms : (0, _hot_updater_plugin_core.compareUtf8)(event.id, head.id) > 0;
252
+ /** What a head says its installation runs: a download leaves it at the source. */
253
+ const runningOf = (head) => head.type === "UPDATE_DOWNLOADED" ? {
254
+ bundle_id: head.from_bundle_id,
255
+ release_id: head.from_release_id
256
+ } : {
257
+ bundle_id: head.to_bundle_id,
258
+ release_id: head.to_release_id
259
+ };
260
+ /** The native build a report or head came from, when its SDK says. */
261
+ const minBundleIdOf = (row) => row.metadata?.min_bundle_id;
262
+ /** When the client made a report, from the UUIDv7 it gave it. */
263
+ const madeAt = (id) => (0, _hot_updater_plugin_core.isUUIDv7)(id) ? (0, _hot_updater_plugin_core.extractTimestampFromUUIDv7)(id) : null;
264
+ /**
265
+ * A report that arrived after its installation moved past it: a download or
266
+ * an apply of the bundle the head already runs, or an UNCHANGED report of the
267
+ * bundle an apply head left, made before that apply. A reload can cut one
268
+ * runtime's report short and deliver it after the next runtime's. It stays
269
+ * in history but moves and counts nothing.
270
+ */
271
+ const isLate = (event, head) => {
272
+ if (head === null) return false;
273
+ if (event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED") return event.to_bundle_id === runningOf(head).bundle_id;
274
+ if (event.type === "UNCHANGED" && head.type === "UPDATE_APPLIED" && event.to_bundle_id === head.from_bundle_id) {
275
+ const made = madeAt(event.id);
276
+ const applied = madeAt(head.id);
277
+ return made !== null && applied !== null && made < applied;
278
+ }
279
+ return false;
280
+ };
281
+ /**
282
+ * What an UNCHANGED report changes against its installation's head: the
283
+ * running bundle or release, the app version, the channel, or the native
284
+ * build (when both name it), with the head's values before it; `first_seen`
285
+ * when the installation has no head. A report the head already answers, one
286
+ * older than the head, a user switch and any other event type change
287
+ * nothing, so no row keeps them.
288
+ */
289
+ const changeOf = (event, head) => {
290
+ if (event.type !== "UNCHANGED") return null;
291
+ if (head === null) return {
292
+ kinds: ["first_seen"],
293
+ previous: null
294
+ };
295
+ if (!isNewer(event, head)) return null;
296
+ const running = runningOf(head);
297
+ const build = minBundleIdOf(event);
298
+ const headBuild = minBundleIdOf(head);
299
+ const kinds = [];
300
+ if (event.to_bundle_id !== running.bundle_id) kinds.push("bundle");
301
+ if (event.to_release_id !== running.release_id) kinds.push("release");
302
+ if (event.app_version !== head.app_version) kinds.push("app_version");
303
+ if (event.channel !== head.channel) kinds.push("channel");
304
+ if (build !== void 0 && headBuild !== void 0 && build !== headBuild) kinds.push("native_build");
305
+ return kinds.length === 0 ? null : {
306
+ kinds,
307
+ previous: {
308
+ bundle_id: running.bundle_id,
309
+ release_id: running.release_id,
310
+ app_version: head.app_version,
311
+ channel: head.channel,
312
+ ...headBuild === void 0 ? {} : { min_bundle_id: headBuild }
313
+ }
314
+ };
315
+ };
316
+ /**
317
+ * Whether a launch or a crash comes with no download report for its target:
318
+ * its head shows neither that download nor the target already running. The
319
+ * native build's built-in bundle is never downloaded, and without a head the
320
+ * server knows nothing of the installation's past, so it implies nothing.
321
+ */
322
+ const impliesDownload = (event, head) => {
323
+ const target = event.type === "RECOVERED" ? {
324
+ bundle: event.from_bundle_id,
325
+ release: event.from_release_id
326
+ } : require_schema.isLaunch(event) ? {
327
+ bundle: event.to_bundle_id,
328
+ release: event.to_release_id
329
+ } : null;
330
+ if (target === null || target.release === null || target.bundle === minBundleIdOf(event)) return false;
331
+ if (head === null) return false;
332
+ if (head.type === "UPDATE_DOWNLOADED" && head.to_bundle_id === target.bundle) return false;
333
+ return runningOf(head).bundle_id !== target.bundle;
334
+ };
335
+ /**
336
+ * The event as recorded, with what only the server sets: what a kept
337
+ * UNCHANGED report changed, a late report's flag, and a download its launch
338
+ * or crash implied.
339
+ */
340
+ const recordedOf = (event, head) => {
341
+ const { change: _change, late: _late, implied_download: _implied, ...metadata } = event.metadata;
342
+ const reported = {
343
+ ...event,
344
+ metadata
345
+ };
346
+ const late = isLate(reported, head);
347
+ const change = late ? null : changeOf(reported, head);
348
+ const withChange = change === null ? reported : {
349
+ ...reported,
350
+ metadata: {
351
+ ...metadata,
352
+ change
353
+ }
354
+ };
355
+ return {
356
+ recorded: late && reported.type !== "UNCHANGED" ? {
357
+ ...reported,
358
+ metadata: {
359
+ ...metadata,
360
+ late: true
361
+ }
362
+ } : !late && impliesDownload(withChange, head) ? {
363
+ ...withChange,
364
+ metadata: {
365
+ ...withChange.metadata,
366
+ implied_download: true
367
+ }
368
+ } : withChange,
369
+ late
370
+ };
371
+ };
239
372
  /** What an UNCHANGED report must share with its installation's head to repeat it. */
240
373
  const REPEATED_FIELDS = [
241
374
  "channel",
@@ -270,23 +403,24 @@ const recordEvent = (db, event) => {
270
403
  const [existing, previous] = await Promise.all([tx.findOne("bundle_events", { id: event.id }), tx.findOne("bundle_event_heads", { install_id: event.install_id })]);
271
404
  if (existing !== null || previous?.id === event.id) return;
272
405
  if (previous !== null && repeatsHead(event, previous)) return;
273
- if (event.type !== "UNCHANGED") {
274
- tx.create("bundle_events", event);
275
- countOutcome(tx, event);
406
+ const { recorded, late } = recordedOf(event, previous);
407
+ if (recorded.type !== "UNCHANGED" || recorded.metadata.change !== void 0 && !late) {
408
+ tx.create("bundle_events", recorded);
409
+ countOutcome(tx, recorded);
276
410
  }
277
- if (event.type === "UPDATE_FAILED") {
278
- countFailure(tx, event);
411
+ if (recorded.type === "UPDATE_FAILED") {
412
+ countFailure(tx, recorded);
279
413
  return;
280
414
  }
281
- countEvent(tx, event);
282
- if (previous !== null && !isNewer(event, previous)) return;
283
- if (previous === null) tx.create("bundle_event_heads", event);
415
+ countEvent(tx, recorded);
416
+ if (previous !== null && (late || !isNewer(recorded, previous))) return;
417
+ if (previous === null) tx.create("bundle_event_heads", recorded);
284
418
  else {
285
- const { install_id: _, ...fields } = event;
419
+ const { install_id: _, ...fields } = recorded;
286
420
  countHead(tx, previous, -1);
287
421
  tx.update("bundle_event_heads", previous, fields);
288
422
  }
289
- countHead(tx, event, 1);
423
+ countHead(tx, recorded, 1);
290
424
  });
291
425
  };
292
426
  //#endregion
@@ -1,7 +1,8 @@
1
+ import { runningBuiltinBundleId } from "./eventRow.mjs";
2
+ import { DAILY_EVENTS, DAY_MS, HOUR_MS, bundleRefsOf, isLaunch } from "./schema.mjs";
1
3
  import { assertBundleEventRow } from "./contract.mjs";
2
4
  import { insightsKey, insightsOverviewDeltas, insightsOverviewId } from "./overview.mjs";
3
- import { DAILY_EVENTS, DAY_MS, HOUR_MS, isFailedCheck } from "./schema.mjs";
4
- import { addDistinct, compareUtf8 } from "@hot-updater/plugin-core";
5
+ import { addDistinct, compareUtf8, extractTimestampFromUUIDv7, isUUIDv7 } from "@hot-updater/plugin-core";
5
6
  //#region src/server/recordEvent.ts
6
7
  const hourOf = (ms) => ms - ms % HOUR_MS;
7
8
  const dayOf = (ms) => ms - ms % DAY_MS;
@@ -13,13 +14,16 @@ const insightsIdentity = (identity) => insightsOverviewId({
13
14
  bucketStartMs: 0
14
15
  });
15
16
  /**
16
- * The bucket a head's gauges count it in, the UTC day of its event, and the
17
- * release its distribution row names: the one it runs, which a download
18
- * leaves at its source.
17
+ * The bucket a head's gauges count it in, the UTC day of its event, and what
18
+ * its distribution row names: the release it runs, which a download leaves
19
+ * at its source, and the built-in bundle, when it runs the native build's.
20
+ * A head stored without the built-in bundle ID counts in no built-in row, so
21
+ * taking it back moves none.
19
22
  */
20
23
  const gaugeSlot = (head) => ({
21
24
  bucket: dayOf(head.received_at_ms),
22
- releaseId: head.type === "UPDATE_DOWNLOADED" ? head.from_release_id : head.to_release_id
25
+ releaseId: head.type === "UPDATE_DOWNLOADED" ? head.from_release_id : head.to_release_id,
26
+ builtinBundleId: runningBuiltinBundleId(head)
23
27
  });
24
28
  /**
25
29
  * The `insights_latest_by_bundle` field of a head's (from, to) pair. A head
@@ -30,11 +34,12 @@ const PAIR_FIELD = "from_to";
30
34
  /** The pair gauge's `bundle_id`: a hash of the two bundle ids, which fit no single 36-character column. */
31
35
  const bundlePairKey = (from, to) => insightsKey(`${from.length}:${from}${to.length}:${to}`);
32
36
  /**
33
- * Moves a head's gauges: the distribution row, one row per bundle it
34
- * references, and its pair; -1 takes back the head an event replaces.
37
+ * Moves a head's gauges: the distribution row, and the built-in one when it
38
+ * runs its build's built-in bundle, one row per bundle it references, and
39
+ * its pair; -1 takes back the head an event replaces.
35
40
  */
36
41
  const countHead = (tx, head, delta) => {
37
- const { bucket, releaseId } = gaugeSlot(head);
42
+ const { bucket, releaseId, builtinBundleId } = gaugeSlot(head);
38
43
  const shardBy = head.install_id;
39
44
  tx.aggregate("insights_distribution", {
40
45
  channel: head.channel,
@@ -43,6 +48,14 @@ const countHead = (tx, head, delta) => {
43
48
  release_id: releaseId ?? "",
44
49
  bucket_start_ms: bucket
45
50
  }, { latest_installations: delta }, { shardBy });
51
+ if (builtinBundleId !== null) tx.aggregate("insights_builtin_distribution", {
52
+ channel: head.channel,
53
+ platform: head.platform,
54
+ app_version: head.app_version,
55
+ release_id: releaseId ?? "",
56
+ builtin_bundle_id: builtinBundleId,
57
+ bucket_start_ms: bucket
58
+ }, { latest_installations: delta }, { shardBy });
46
59
  for (const field of ["from_bundle_id", "to_bundle_id"]) {
47
60
  const bundleId = head[field];
48
61
  if (bundleId === null) continue;
@@ -123,16 +136,16 @@ const countEvent = (tx, event) => {
123
136
  }
124
137
  };
125
138
  /**
126
- * A stored event's outcome rows: its bundle filter's hour, which a failed
127
- * check has none of, and every stored event's UTC day.
139
+ * A stored event's outcome rows: its bundle filter's hour, when it has one
140
+ * (`bundleRefsOf`), and every stored event's UTC day.
128
141
  */
129
142
  const countOutcome = (tx, event) => {
130
143
  const shardBy = event.install_id;
131
- if (!isFailedCheck(event)) tx.aggregate("insights_outcomes", {
144
+ for (const bundleRef of bundleRefsOf(event)) tx.aggregate("insights_outcomes", {
132
145
  platform: event.platform,
133
146
  channel: event.channel,
134
147
  type: event.type,
135
- bundle_ref: event.type === "RECOVERED" ? `from:${event.from_bundle_id}` : `to:${event.to_bundle_id}`,
148
+ bundle_ref: bundleRef,
136
149
  bucket_start_ms: hourOf(event.received_at_ms)
137
150
  }, { events: 1 }, { shardBy });
138
151
  tx.aggregate("insights_outcomes", {
@@ -236,6 +249,126 @@ const countFailure = (tx, event) => {
236
249
  }, { events: 1 }, { shardBy });
237
250
  };
238
251
  const isNewer = (event, head) => event.received_at_ms !== head.received_at_ms ? event.received_at_ms > head.received_at_ms : compareUtf8(event.id, head.id) > 0;
252
+ /** What a head says its installation runs: a download leaves it at the source. */
253
+ const runningOf = (head) => head.type === "UPDATE_DOWNLOADED" ? {
254
+ bundle_id: head.from_bundle_id,
255
+ release_id: head.from_release_id
256
+ } : {
257
+ bundle_id: head.to_bundle_id,
258
+ release_id: head.to_release_id
259
+ };
260
+ /** The native build a report or head came from, when its SDK says. */
261
+ const minBundleIdOf = (row) => row.metadata?.min_bundle_id;
262
+ /** When the client made a report, from the UUIDv7 it gave it. */
263
+ const madeAt = (id) => isUUIDv7(id) ? extractTimestampFromUUIDv7(id) : null;
264
+ /**
265
+ * A report that arrived after its installation moved past it: a download or
266
+ * an apply of the bundle the head already runs, or an UNCHANGED report of the
267
+ * bundle an apply head left, made before that apply. A reload can cut one
268
+ * runtime's report short and deliver it after the next runtime's. It stays
269
+ * in history but moves and counts nothing.
270
+ */
271
+ const isLate = (event, head) => {
272
+ if (head === null) return false;
273
+ if (event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED") return event.to_bundle_id === runningOf(head).bundle_id;
274
+ if (event.type === "UNCHANGED" && head.type === "UPDATE_APPLIED" && event.to_bundle_id === head.from_bundle_id) {
275
+ const made = madeAt(event.id);
276
+ const applied = madeAt(head.id);
277
+ return made !== null && applied !== null && made < applied;
278
+ }
279
+ return false;
280
+ };
281
+ /**
282
+ * What an UNCHANGED report changes against its installation's head: the
283
+ * running bundle or release, the app version, the channel, or the native
284
+ * build (when both name it), with the head's values before it; `first_seen`
285
+ * when the installation has no head. A report the head already answers, one
286
+ * older than the head, a user switch and any other event type change
287
+ * nothing, so no row keeps them.
288
+ */
289
+ const changeOf = (event, head) => {
290
+ if (event.type !== "UNCHANGED") return null;
291
+ if (head === null) return {
292
+ kinds: ["first_seen"],
293
+ previous: null
294
+ };
295
+ if (!isNewer(event, head)) return null;
296
+ const running = runningOf(head);
297
+ const build = minBundleIdOf(event);
298
+ const headBuild = minBundleIdOf(head);
299
+ const kinds = [];
300
+ if (event.to_bundle_id !== running.bundle_id) kinds.push("bundle");
301
+ if (event.to_release_id !== running.release_id) kinds.push("release");
302
+ if (event.app_version !== head.app_version) kinds.push("app_version");
303
+ if (event.channel !== head.channel) kinds.push("channel");
304
+ if (build !== void 0 && headBuild !== void 0 && build !== headBuild) kinds.push("native_build");
305
+ return kinds.length === 0 ? null : {
306
+ kinds,
307
+ previous: {
308
+ bundle_id: running.bundle_id,
309
+ release_id: running.release_id,
310
+ app_version: head.app_version,
311
+ channel: head.channel,
312
+ ...headBuild === void 0 ? {} : { min_bundle_id: headBuild }
313
+ }
314
+ };
315
+ };
316
+ /**
317
+ * Whether a launch or a crash comes with no download report for its target:
318
+ * its head shows neither that download nor the target already running. The
319
+ * native build's built-in bundle is never downloaded, and without a head the
320
+ * server knows nothing of the installation's past, so it implies nothing.
321
+ */
322
+ const impliesDownload = (event, head) => {
323
+ const target = event.type === "RECOVERED" ? {
324
+ bundle: event.from_bundle_id,
325
+ release: event.from_release_id
326
+ } : isLaunch(event) ? {
327
+ bundle: event.to_bundle_id,
328
+ release: event.to_release_id
329
+ } : null;
330
+ if (target === null || target.release === null || target.bundle === minBundleIdOf(event)) return false;
331
+ if (head === null) return false;
332
+ if (head.type === "UPDATE_DOWNLOADED" && head.to_bundle_id === target.bundle) return false;
333
+ return runningOf(head).bundle_id !== target.bundle;
334
+ };
335
+ /**
336
+ * The event as recorded, with what only the server sets: what a kept
337
+ * UNCHANGED report changed, a late report's flag, and a download its launch
338
+ * or crash implied.
339
+ */
340
+ const recordedOf = (event, head) => {
341
+ const { change: _change, late: _late, implied_download: _implied, ...metadata } = event.metadata;
342
+ const reported = {
343
+ ...event,
344
+ metadata
345
+ };
346
+ const late = isLate(reported, head);
347
+ const change = late ? null : changeOf(reported, head);
348
+ const withChange = change === null ? reported : {
349
+ ...reported,
350
+ metadata: {
351
+ ...metadata,
352
+ change
353
+ }
354
+ };
355
+ return {
356
+ recorded: late && reported.type !== "UNCHANGED" ? {
357
+ ...reported,
358
+ metadata: {
359
+ ...metadata,
360
+ late: true
361
+ }
362
+ } : !late && impliesDownload(withChange, head) ? {
363
+ ...withChange,
364
+ metadata: {
365
+ ...withChange.metadata,
366
+ implied_download: true
367
+ }
368
+ } : withChange,
369
+ late
370
+ };
371
+ };
239
372
  /** What an UNCHANGED report must share with its installation's head to repeat it. */
240
373
  const REPEATED_FIELDS = [
241
374
  "channel",
@@ -270,23 +403,24 @@ const recordEvent = (db, event) => {
270
403
  const [existing, previous] = await Promise.all([tx.findOne("bundle_events", { id: event.id }), tx.findOne("bundle_event_heads", { install_id: event.install_id })]);
271
404
  if (existing !== null || previous?.id === event.id) return;
272
405
  if (previous !== null && repeatsHead(event, previous)) return;
273
- if (event.type !== "UNCHANGED") {
274
- tx.create("bundle_events", event);
275
- countOutcome(tx, event);
406
+ const { recorded, late } = recordedOf(event, previous);
407
+ if (recorded.type !== "UNCHANGED" || recorded.metadata.change !== void 0 && !late) {
408
+ tx.create("bundle_events", recorded);
409
+ countOutcome(tx, recorded);
276
410
  }
277
- if (event.type === "UPDATE_FAILED") {
278
- countFailure(tx, event);
411
+ if (recorded.type === "UPDATE_FAILED") {
412
+ countFailure(tx, recorded);
279
413
  return;
280
414
  }
281
- countEvent(tx, event);
282
- if (previous !== null && !isNewer(event, previous)) return;
283
- if (previous === null) tx.create("bundle_event_heads", event);
415
+ countEvent(tx, recorded);
416
+ if (previous !== null && (late || !isNewer(recorded, previous))) return;
417
+ if (previous === null) tx.create("bundle_event_heads", recorded);
284
418
  else {
285
- const { install_id: _, ...fields } = event;
419
+ const { install_id: _, ...fields } = recorded;
286
420
  countHead(tx, previous, -1);
287
421
  tx.update("bundle_event_heads", previous, fields);
288
422
  }
289
- countHead(tx, event, 1);
423
+ countHead(tx, recorded, 1);
290
424
  });
291
425
  };
292
426
  //#endregion
@@ -10,18 +10,53 @@ const bucketRetention = (days) => ({
10
10
  field: "bucket_start_ms",
11
11
  days
12
12
  });
13
+ /** Every kind of row the table keeps belongs in installation history. */
13
14
  const MOVEMENTS = /* @__PURE__ */ new Set([
14
15
  "UPDATE_DOWNLOADED",
15
16
  "UPDATE_APPLIED",
16
17
  "RECOVERED",
17
- "UPDATE_FAILED"
18
+ "UPDATE_FAILED",
19
+ "UNCHANGED"
18
20
  ]);
19
21
  /** A failed update check: it names no target bundle, so no bundle list shows it. */
20
22
  const isFailedCheck = (row) => row.type === "UPDATE_FAILED" && row.metadata?.failure?.stage === "check";
23
+ const metadataOf = (row) => row.metadata ?? {};
24
+ /** A download or apply that arrived after its target ran: it counts nothing. */
25
+ const isLateEvent = (row) => metadataOf(row).late === true;
21
26
  /**
22
- * Downloads, applies, recoveries, and update failures, whole. An UNCHANGED
23
- * report counts as a launch and moves its installation's head, but no list
24
- * shows it, so no row keeps it.
27
+ * Whether a stored row launched its installation into a bundle release: an
28
+ * apply, or a kept UNCHANGED row that moved the installation to another
29
+ * bundle of a release, as when the apply report never arrived or a rollback
30
+ * to the built-in bundle took it there. A first report, a release change of
31
+ * the bundle already running, and a late row launch nothing.
32
+ */
33
+ const isLaunch = (row) => {
34
+ if (isLateEvent(row)) return false;
35
+ if (row.type === "UPDATE_APPLIED") return true;
36
+ if (row.type !== "UNCHANGED") return false;
37
+ const kinds = metadataOf(row).change?.kinds ?? [];
38
+ return kinds.includes("bundle") && !kinds.includes("first_seen") && row.to_release_id !== null;
39
+ };
40
+ /**
41
+ * The bundle a stored row's bundle filter matches: `from:<bundle>` for a
42
+ * recovery; `to:<bundle>` for a download, an apply, and an update failure;
43
+ * `on:<bundle>` for an UNCHANGED row that launched a bundle (`isLaunch`), a
44
+ * key no other row uses. None for a failed check, which targets no bundle, a
45
+ * late row, or any other UNCHANGED row.
46
+ */
47
+ const bundleRefsOf = (row) => {
48
+ if (isFailedCheck(row) || isLateEvent(row)) return [];
49
+ if (row.type === "RECOVERED") return [`from:${row.from_bundle_id}`];
50
+ if (row.type === "UNCHANGED") return isLaunch(row) ? [`on:${row.to_bundle_id}`] : [];
51
+ return [`to:${row.to_bundle_id}`];
52
+ };
53
+ /** The key a bundle filter reads, as `bundleRefsOf` writes it. */
54
+ const bundleRefOfFilter = (filter) => filter.type === "RECOVERED" ? `from:${filter.fromBundleId}` : filter.type === "UNCHANGED" ? `on:${filter.toBundleId}` : `to:${filter.toBundleId}`;
55
+ /**
56
+ * Downloads, applies, recoveries, update failures, and the UNCHANGED
57
+ * reports that changed what their installation runs, whole. An UNCHANGED
58
+ * report that changes nothing counts as a launch and moves its
59
+ * installation's head, but no row keeps it.
25
60
  */
26
61
  const bundleEvents = (days) => (0, _hot_updater_plugin_core.defineTable)({
27
62
  id: {
@@ -76,19 +111,16 @@ const bundleEvents = (days) => (0, _hot_updater_plugin_core.defineTable)({
76
111
  type: "integer",
77
112
  compute: (row) => row.received_at_ms - row.received_at_ms % DAY_MS
78
113
  },
79
- /** Set only for events that belong in installation history. */
114
+ /** Every stored event belongs in installation history. */
80
115
  movement_install_id: {
81
116
  type: "string",
82
117
  compute: (row) => MOVEMENTS.has(row.type) ? row.install_id : null
83
118
  },
84
- /**
85
- * The bundle a bundle filter matches: `from:<bundle>` for RECOVERED,
86
- * none for a failed check, else `to:<bundle>`.
87
- */
119
+ /** The bundle a bundle filter matches (`bundleRefsOf`). */
88
120
  bundle_ref: {
89
121
  type: "string",
90
122
  multi: true,
91
- compute: (row) => isFailedCheck(row) ? [] : [row.type === "RECOVERED" && row.from_bundle_id !== null ? `from:${row.from_bundle_id}` : `to:${row.to_bundle_id}`]
123
+ compute: (row) => bundleRefsOf(row)
92
124
  }
93
125
  },
94
126
  indexes: {
@@ -354,6 +386,55 @@ const insightsDistribution = (retention) => (0, _hot_updater_plugin_core.defineA
354
386
  },
355
387
  retention
356
388
  });
389
+ /**
390
+ * The installations of `insights_distribution` that run their native build's
391
+ * built-in bundle, by its ID (`builtin_bundle_id`), within the same rows:
392
+ * reads move them out of the row of their release, or of no release.
393
+ */
394
+ const insightsBuiltinDistribution = (retention) => (0, _hot_updater_plugin_core.defineAggregate)({
395
+ channel: { type: "string" },
396
+ platform: {
397
+ type: "string",
398
+ maxLength: 16
399
+ },
400
+ app_version: { type: "string" },
401
+ release_id: {
402
+ type: "string",
403
+ maxLength: 36
404
+ },
405
+ builtin_bundle_id: {
406
+ type: "string",
407
+ maxLength: 36
408
+ },
409
+ bucket_start_ms: { type: "integer" }
410
+ }, {
411
+ key: [
412
+ "channel",
413
+ "platform",
414
+ "app_version",
415
+ "release_id",
416
+ "builtin_bundle_id",
417
+ "bucket_start_ms"
418
+ ],
419
+ gauges: ["latest_installations"],
420
+ shards: GAUGE_SHARDS,
421
+ batched: true,
422
+ indexes: {
423
+ byScope: {
424
+ eq: ["channel", "platform"],
425
+ sort: ["bucket_start_ms"]
426
+ },
427
+ byVersion: {
428
+ eq: [
429
+ "channel",
430
+ "platform",
431
+ "app_version"
432
+ ],
433
+ sort: ["bucket_start_ms"]
434
+ }
435
+ },
436
+ retention
437
+ });
357
438
  /** Latest events by the bundle they came from or went to, bucketed like the distribution. */
358
439
  const insightsLatestByBundle = (retention) => (0, _hot_updater_plugin_core.defineAggregate)({
359
440
  platform: {
@@ -473,6 +554,7 @@ const createInsightsSchema = ({ rawDays, dailyDays } = DEFAULT_INSIGHTS_RETENTIO
473
554
  insights_overview_lifetime: lifetimeCounters(),
474
555
  insights_sketches_lifetime: lifetimeSketches(),
475
556
  insights_distribution: insightsDistribution(daily),
557
+ insights_builtin_distribution: insightsBuiltinDistribution(daily),
476
558
  insights_latest_by_bundle: insightsLatestByBundle(daily),
477
559
  insights_outcomes: insightsOutcomes(hourly),
478
560
  insights_failures: insightsFailures(hourly)
@@ -484,6 +566,10 @@ exports.DAILY_EVENTS = DAILY_EVENTS;
484
566
  exports.DAY_MS = DAY_MS;
485
567
  exports.DEFAULT_INSIGHTS_RETENTION = DEFAULT_INSIGHTS_RETENTION;
486
568
  exports.HOUR_MS = HOUR_MS;
569
+ exports.bundleRefOfFilter = bundleRefOfFilter;
570
+ exports.bundleRefsOf = bundleRefsOf;
487
571
  exports.createInsightsSchema = createInsightsSchema;
488
572
  exports.insightsSchema = insightsSchema;
489
573
  exports.isFailedCheck = isFailedCheck;
574
+ exports.isLateEvent = isLateEvent;
575
+ exports.isLaunch = isLaunch;