@hot-updater/plugin-insights 1.0.0-rc.36 → 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,4 +1,5 @@
1
1
  const require_eventRow = require("./eventRow.cjs");
2
+ const require_schema = require("./schema.cjs");
2
3
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
3
4
  //#region src/server/contract.ts
4
5
  const encoder = new TextEncoder();
@@ -30,7 +31,7 @@ const isBundleFilter = (value, withKind = false) => {
30
31
  "type",
31
32
  ...withKind ? ["kind"] : []
32
33
  ];
33
- return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED") && isText(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
34
+ return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED" || value.type === "UNCHANGED") && isText(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
34
35
  };
35
36
  const isEventFilter = (value) => {
36
37
  if (!require_eventRow.isRecord(value)) return false;
@@ -49,12 +50,15 @@ const validateRow = (model, row, result = false) => {
49
50
  };
50
51
  /** Throws `invalid-data` unless `row` is a complete, well-formed bundle event row. */
51
52
  const assertBundleEventRow = (row) => validateRow("bundle_events", row);
52
- /** Downloads, applies, recoveries, and failed updates belong in installation history. */
53
- const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED";
53
+ /**
54
+ * Downloads, applies, recoveries, failed updates, and the UNCHANGED rows
55
+ * kept for a change belong in installation history.
56
+ */
57
+ const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED" || event.type === "UNCHANGED";
54
58
  const matchesInsightsEventFilter = (event, filter) => {
55
59
  if (filter.kind === "all") return true;
56
60
  if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
57
- return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && (filter.type === "RECOVERED" ? event.from_bundle_id === filter.fromBundleId : event.to_bundle_id === filter.toBundleId && event.metadata.failure?.stage !== "check");
61
+ return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && require_schema.bundleRefsOf(event).includes(require_schema.bundleRefOfFilter(filter));
58
62
  };
59
63
  const invalidQuery = () => {
60
64
  throw new _hot_updater_plugin_core.DatabaseAdapterInputError("invalid-query");
@@ -1,4 +1,5 @@
1
1
  import { isRecord, validateBundleEventFields } from "./eventRow.mjs";
2
+ import { bundleRefOfFilter, bundleRefsOf } from "./schema.mjs";
2
3
  import { DatabaseAdapterInputError, isUUIDv7 } from "@hot-updater/plugin-core";
3
4
  //#region src/server/contract.ts
4
5
  const encoder = new TextEncoder();
@@ -30,7 +31,7 @@ const isBundleFilter = (value, withKind = false) => {
30
31
  "type",
31
32
  ...withKind ? ["kind"] : []
32
33
  ];
33
- return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED") && isText(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
34
+ return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED" || value.type === "UNCHANGED") && isText(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
34
35
  };
35
36
  const isEventFilter = (value) => {
36
37
  if (!isRecord(value)) return false;
@@ -49,12 +50,15 @@ const validateRow = (model, row, result = false) => {
49
50
  };
50
51
  /** Throws `invalid-data` unless `row` is a complete, well-formed bundle event row. */
51
52
  const assertBundleEventRow = (row) => validateRow("bundle_events", row);
52
- /** Downloads, applies, recoveries, and failed updates belong in installation history. */
53
- const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED";
53
+ /**
54
+ * Downloads, applies, recoveries, failed updates, and the UNCHANGED rows
55
+ * kept for a change belong in installation history.
56
+ */
57
+ const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED" || event.type === "UNCHANGED";
54
58
  const matchesInsightsEventFilter = (event, filter) => {
55
59
  if (filter.kind === "all") return true;
56
60
  if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
57
- return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && (filter.type === "RECOVERED" ? event.from_bundle_id === filter.fromBundleId : event.to_bundle_id === filter.toBundleId && event.metadata.failure?.stage !== "check");
61
+ return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && bundleRefsOf(event).includes(bundleRefOfFilter(filter));
58
62
  };
59
63
  const invalidQuery = () => {
60
64
  throw new DatabaseAdapterInputError("invalid-query");
@@ -1,4 +1,4 @@
1
- import { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, DatabaseBundleEventMetadata } from "./eventRow.cjs";
1
+ import { BundleEventChangeKind, BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, DatabaseBundleEventMetadata } from "./eventRow.cjs";
2
2
  import { InsightsScope } from "./modelTypes.cjs";
3
3
  import { UpdateHttpResponse } from "@hot-updater/protocol";
4
4
  //#region src/server/domain.d.ts
@@ -82,6 +82,22 @@ type CreateBundleEventRequest = (Movement & {
82
82
  readonly updateStrategy: null;
83
83
  });
84
84
  type ActiveInstallationWindow = "24h" | "7d" | "30d";
85
+ /**
86
+ * What a kept UNCHANGED report changed against its installation's previous
87
+ * report: the running bundle or release, the app version, or the channel,
88
+ * with the previous values; `first_seen` alone for an installation's first
89
+ * report to this server.
90
+ */
91
+ type EventHistoryChange = {
92
+ readonly kinds: readonly BundleEventChangeKind[];
93
+ readonly previous?: {
94
+ readonly bundleId: string;
95
+ readonly releaseId: string | null;
96
+ readonly appVersion: string;
97
+ readonly channel: string;
98
+ readonly minBundleId?: string;
99
+ };
100
+ };
85
101
  type EventHistoryRow = {
86
102
  readonly toReleaseId?: string;
87
103
  readonly sdkVersion?: string;
@@ -105,11 +121,21 @@ type EventHistoryRow = {
105
121
  readonly delivery?: NonNullable<DatabaseBundleEventMetadata["delivery"]>;
106
122
  /** `UPDATE_DOWNLOADED`: a patch failed, and the files or the archive came instead. */
107
123
  readonly patchFallback?: true;
124
+ /** `UNCHANGED`: what the report changed, the reason it was kept. */
125
+ readonly change?: EventHistoryChange;
126
+ /** A download or apply that arrived after its target already ran: it counted nothing. */
127
+ readonly late?: true;
128
+ /** A launch or crash whose download report never arrived, counted with it. */
129
+ readonly impliedDownload?: true;
108
130
  };
109
- type InstallationHistoryRow = EventHistoryRow & {
131
+ type InstallationHistoryRow = EventHistoryRow & ({
110
132
  readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "RECOVERED" | "UPDATE_FAILED";
111
133
  readonly fromBundleId: string;
112
- };
134
+ } | {
135
+ readonly type: "UNCHANGED";
136
+ readonly fromBundleId: null;
137
+ readonly change: EventHistoryChange;
138
+ });
113
139
  type InsightsHttpResponse = UpdateHttpResponse & {
114
140
  readonly receivedAtMs: number;
115
141
  };
@@ -160,4 +186,4 @@ type ReportingOverview = InsightsScope & {
160
186
  };
161
187
  };
162
188
  //#endregion
163
- export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview };
189
+ export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryChange, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview };
@@ -1,4 +1,4 @@
1
- import { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, DatabaseBundleEventMetadata } from "./eventRow.mjs";
1
+ import { BundleEventChangeKind, BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, DatabaseBundleEventMetadata } from "./eventRow.mjs";
2
2
  import { InsightsScope } from "./modelTypes.mjs";
3
3
  import { UpdateHttpResponse } from "@hot-updater/protocol";
4
4
  //#region src/server/domain.d.ts
@@ -82,6 +82,22 @@ type CreateBundleEventRequest = (Movement & {
82
82
  readonly updateStrategy: null;
83
83
  });
84
84
  type ActiveInstallationWindow = "24h" | "7d" | "30d";
85
+ /**
86
+ * What a kept UNCHANGED report changed against its installation's previous
87
+ * report: the running bundle or release, the app version, or the channel,
88
+ * with the previous values; `first_seen` alone for an installation's first
89
+ * report to this server.
90
+ */
91
+ type EventHistoryChange = {
92
+ readonly kinds: readonly BundleEventChangeKind[];
93
+ readonly previous?: {
94
+ readonly bundleId: string;
95
+ readonly releaseId: string | null;
96
+ readonly appVersion: string;
97
+ readonly channel: string;
98
+ readonly minBundleId?: string;
99
+ };
100
+ };
85
101
  type EventHistoryRow = {
86
102
  readonly toReleaseId?: string;
87
103
  readonly sdkVersion?: string;
@@ -105,11 +121,21 @@ type EventHistoryRow = {
105
121
  readonly delivery?: NonNullable<DatabaseBundleEventMetadata["delivery"]>;
106
122
  /** `UPDATE_DOWNLOADED`: a patch failed, and the files or the archive came instead. */
107
123
  readonly patchFallback?: true;
124
+ /** `UNCHANGED`: what the report changed, the reason it was kept. */
125
+ readonly change?: EventHistoryChange;
126
+ /** A download or apply that arrived after its target already ran: it counted nothing. */
127
+ readonly late?: true;
128
+ /** A launch or crash whose download report never arrived, counted with it. */
129
+ readonly impliedDownload?: true;
108
130
  };
109
- type InstallationHistoryRow = EventHistoryRow & {
131
+ type InstallationHistoryRow = EventHistoryRow & ({
110
132
  readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "RECOVERED" | "UPDATE_FAILED";
111
133
  readonly fromBundleId: string;
112
- };
134
+ } | {
135
+ readonly type: "UNCHANGED";
136
+ readonly fromBundleId: null;
137
+ readonly change: EventHistoryChange;
138
+ });
113
139
  type InsightsHttpResponse = UpdateHttpResponse & {
114
140
  readonly receivedAtMs: number;
115
141
  };
@@ -160,4 +186,4 @@ type ReportingOverview = InsightsScope & {
160
186
  };
161
187
  };
162
188
  //#endregion
163
- export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview };
189
+ export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryChange, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview };
@@ -1,11 +1,20 @@
1
1
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
2
2
  //#region src/server/eventRow.ts
3
+ const CHANGE_KINDS = /* @__PURE__ */ new Set([
4
+ "first_seen",
5
+ "bundle",
6
+ "release",
7
+ "app_version",
8
+ "channel",
9
+ "native_build"
10
+ ]);
11
+ const isBundleEventChange = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && Array.isArray(value.kinds) && value.kinds.length > 0 && value.kinds.every((kind) => CHANGE_KINDS.has(kind)) && (value.previous === null || (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value.previous) && typeof value.previous.bundle_id === "string" && (value.previous.release_id === null || typeof value.previous.release_id === "string") && typeof value.previous.app_version === "string" && typeof value.previous.channel === "string" && isOptional(value.previous, "min_bundle_id", isString));
3
12
  const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
4
13
  const isString = (value) => typeof value === "string";
5
14
  const isDatabaseHttpResponse = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && (value.resource === "catalog" || value.resource === "artifact") && isString(value.path) && Number.isSafeInteger(value.status) && (value.body === null || isString(value.body)) && typeof value.body_truncated === "boolean" && Number.isSafeInteger(value.received_at_ms);
6
15
  /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
7
16
  const isDatabaseBundleEventFailure = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "error_message", isString) && isOptional(value, "error_stack", isString);
8
- const isDatabaseBundleEventMetadata = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "min_bundle_id", isString) && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean");
17
+ const isDatabaseBundleEventMetadata = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "min_bundle_id", isString) && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "change", isBundleEventChange) && isOptional(value, "late", (flag) => flag === true) && isOptional(value, "implied_download", (flag) => flag === true);
9
18
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
19
  /**
11
20
  * The built-in bundle a report says its installation runs: the running
@@ -36,13 +45,14 @@ const BUNDLE_EVENT_FIELDS = {
36
45
  metadata: isDatabaseBundleEventMetadata,
37
46
  received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
38
47
  };
48
+ const hasMetadata = (row, key) => isRecord(row.metadata) && Object.hasOwn(row.metadata, key);
39
49
  const updateStrategyOf = (row) => isRecord(row.metadata) ? row.metadata.update_strategy : void 0;
40
50
  /**
41
51
  * A movement, or a failed update, names the bundle it left and how the
42
52
  * device updates, and a failed update says what failed; an unchanged report
43
53
  * names neither.
44
54
  */
45
- const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord(row.metadata) && isRecord(row.metadata.failure)) || row.type === "UNCHANGED" && row.from_bundle_id === null && updateStrategyOf(row) === null;
55
+ const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord(row.metadata) && isRecord(row.metadata.failure)) && !hasMetadata(row, "change") && (!hasMetadata(row, "late") || row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED") && (!hasMetadata(row, "implied_download") || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED") || row.type === "UNCHANGED" && !hasMetadata(row, "late") && row.from_bundle_id === null && updateStrategyOf(row) === null;
46
56
  /**
47
57
  * Throws unless `row` has exactly the bundle event fields, each of its type,
48
58
  * and the invariants of its event type: `invalid-field` for an unknown field,
@@ -25,6 +25,25 @@ type DatabaseHttpResponse = DatabaseJsonObject & {
25
25
  readonly body_truncated: boolean;
26
26
  readonly received_at_ms: number;
27
27
  };
28
+ /** What a kept UNCHANGED row changed against its installation's head. */
29
+ type BundleEventChangeKind = "first_seen" | "bundle" | "release" | "app_version" | "channel" | "native_build";
30
+ /**
31
+ * An UNCHANGED report the server keeps: what it changed against its
32
+ * installation's head, and the running values the head held before it. A
33
+ * report from an installation without a head is `first_seen` alone, with no
34
+ * previous values: the installation is new to this server, not necessarily
35
+ * to its device.
36
+ */
37
+ type BundleEventChange = DatabaseJsonObject & {
38
+ readonly kinds: BundleEventChangeKind[];
39
+ readonly previous: (DatabaseJsonObject & {
40
+ readonly bundle_id: string;
41
+ readonly release_id: string | null;
42
+ readonly app_version: string;
43
+ readonly channel: string;
44
+ readonly min_bundle_id?: string;
45
+ }) | null;
46
+ };
28
47
  /** Ancillary report data; queryable identity and lifecycle fields stay on the row. */
29
48
  type DatabaseBundleEventMetadata = DatabaseJsonObject & {
30
49
  readonly cohort: string;
@@ -40,6 +59,19 @@ type DatabaseBundleEventMetadata = DatabaseJsonObject & {
40
59
  readonly delivery?: "patch" | "manifest" | "archive" | "unknown";
41
60
  /** `UPDATE_DOWNLOADED`: a patch failed and the full files came instead. */
42
61
  readonly patch_fallback?: boolean;
62
+ /** A kept `UNCHANGED` row: what it changed, which the server sets. */
63
+ readonly change?: BundleEventChange;
64
+ /**
65
+ * A download or apply that arrived after its installation already ran its
66
+ * target, as when a reload cut the first runtime's report short: the
67
+ * server keeps it in history, but it moves and counts nothing.
68
+ */
69
+ readonly late?: true;
70
+ /**
71
+ * A launch or crash of a bundle whose download report never arrived: the
72
+ * server counted that download with it.
73
+ */
74
+ readonly implied_download?: true;
43
75
  };
44
76
  type BundleEventRowBase = {
45
77
  readonly id: string;
@@ -70,4 +102,4 @@ type BundleEventRow = BundleEventRowBase & ({
70
102
  readonly from_bundle_id: null;
71
103
  });
72
104
  //#endregion
73
- export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata, DatabaseHttpResponse };
105
+ export { BundleEventChange, BundleEventChangeKind, BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata, DatabaseHttpResponse };
@@ -25,6 +25,25 @@ type DatabaseHttpResponse = DatabaseJsonObject & {
25
25
  readonly body_truncated: boolean;
26
26
  readonly received_at_ms: number;
27
27
  };
28
+ /** What a kept UNCHANGED row changed against its installation's head. */
29
+ type BundleEventChangeKind = "first_seen" | "bundle" | "release" | "app_version" | "channel" | "native_build";
30
+ /**
31
+ * An UNCHANGED report the server keeps: what it changed against its
32
+ * installation's head, and the running values the head held before it. A
33
+ * report from an installation without a head is `first_seen` alone, with no
34
+ * previous values: the installation is new to this server, not necessarily
35
+ * to its device.
36
+ */
37
+ type BundleEventChange = DatabaseJsonObject & {
38
+ readonly kinds: BundleEventChangeKind[];
39
+ readonly previous: (DatabaseJsonObject & {
40
+ readonly bundle_id: string;
41
+ readonly release_id: string | null;
42
+ readonly app_version: string;
43
+ readonly channel: string;
44
+ readonly min_bundle_id?: string;
45
+ }) | null;
46
+ };
28
47
  /** Ancillary report data; queryable identity and lifecycle fields stay on the row. */
29
48
  type DatabaseBundleEventMetadata = DatabaseJsonObject & {
30
49
  readonly cohort: string;
@@ -40,6 +59,19 @@ type DatabaseBundleEventMetadata = DatabaseJsonObject & {
40
59
  readonly delivery?: "patch" | "manifest" | "archive" | "unknown";
41
60
  /** `UPDATE_DOWNLOADED`: a patch failed and the full files came instead. */
42
61
  readonly patch_fallback?: boolean;
62
+ /** A kept `UNCHANGED` row: what it changed, which the server sets. */
63
+ readonly change?: BundleEventChange;
64
+ /**
65
+ * A download or apply that arrived after its installation already ran its
66
+ * target, as when a reload cut the first runtime's report short: the
67
+ * server keeps it in history, but it moves and counts nothing.
68
+ */
69
+ readonly late?: true;
70
+ /**
71
+ * A launch or crash of a bundle whose download report never arrived: the
72
+ * server counted that download with it.
73
+ */
74
+ readonly implied_download?: true;
43
75
  };
44
76
  type BundleEventRowBase = {
45
77
  readonly id: string;
@@ -70,4 +102,4 @@ type BundleEventRow = BundleEventRowBase & ({
70
102
  readonly from_bundle_id: null;
71
103
  });
72
104
  //#endregion
73
- export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata, DatabaseHttpResponse };
105
+ export { BundleEventChange, BundleEventChangeKind, BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata, DatabaseHttpResponse };
@@ -1,11 +1,20 @@
1
1
  import { DatabaseAdapterInputError, isDatabaseJsonObject } from "@hot-updater/plugin-core";
2
2
  //#region src/server/eventRow.ts
3
+ const CHANGE_KINDS = /* @__PURE__ */ new Set([
4
+ "first_seen",
5
+ "bundle",
6
+ "release",
7
+ "app_version",
8
+ "channel",
9
+ "native_build"
10
+ ]);
11
+ const isBundleEventChange = (value) => isDatabaseJsonObject(value) && Array.isArray(value.kinds) && value.kinds.length > 0 && value.kinds.every((kind) => CHANGE_KINDS.has(kind)) && (value.previous === null || isDatabaseJsonObject(value.previous) && typeof value.previous.bundle_id === "string" && (value.previous.release_id === null || typeof value.previous.release_id === "string") && typeof value.previous.app_version === "string" && typeof value.previous.channel === "string" && isOptional(value.previous, "min_bundle_id", isString));
3
12
  const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
4
13
  const isString = (value) => typeof value === "string";
5
14
  const isDatabaseHttpResponse = (value) => isDatabaseJsonObject(value) && (value.resource === "catalog" || value.resource === "artifact") && isString(value.path) && Number.isSafeInteger(value.status) && (value.body === null || isString(value.body)) && typeof value.body_truncated === "boolean" && Number.isSafeInteger(value.received_at_ms);
6
15
  /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
7
16
  const isDatabaseBundleEventFailure = (value) => isDatabaseJsonObject(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "error_message", isString) && isOptional(value, "error_stack", isString);
8
- const isDatabaseBundleEventMetadata = (value) => isDatabaseJsonObject(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "min_bundle_id", isString) && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean");
17
+ const isDatabaseBundleEventMetadata = (value) => isDatabaseJsonObject(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "min_bundle_id", isString) && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "change", isBundleEventChange) && isOptional(value, "late", (flag) => flag === true) && isOptional(value, "implied_download", (flag) => flag === true);
9
18
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
19
  /**
11
20
  * The built-in bundle a report says its installation runs: the running
@@ -36,13 +45,14 @@ const BUNDLE_EVENT_FIELDS = {
36
45
  metadata: isDatabaseBundleEventMetadata,
37
46
  received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
38
47
  };
48
+ const hasMetadata = (row, key) => isRecord(row.metadata) && Object.hasOwn(row.metadata, key);
39
49
  const updateStrategyOf = (row) => isRecord(row.metadata) ? row.metadata.update_strategy : void 0;
40
50
  /**
41
51
  * A movement, or a failed update, names the bundle it left and how the
42
52
  * device updates, and a failed update says what failed; an unchanged report
43
53
  * names neither.
44
54
  */
45
- const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord(row.metadata) && isRecord(row.metadata.failure)) || row.type === "UNCHANGED" && row.from_bundle_id === null && updateStrategyOf(row) === null;
55
+ const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord(row.metadata) && isRecord(row.metadata.failure)) && !hasMetadata(row, "change") && (!hasMetadata(row, "late") || row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED") && (!hasMetadata(row, "implied_download") || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED") || row.type === "UNCHANGED" && !hasMetadata(row, "late") && row.from_bundle_id === null && updateStrategyOf(row) === null;
46
56
  /**
47
57
  * Throws unless `row` has exactly the bundle event fields, each of its type,
48
58
  * and the invariants of its event type: `invalid-field` for an unknown field,
@@ -1,9 +1,9 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_schema = require("./schema.cjs");
2
3
  const require_model = require("./model.cjs");
3
4
  const require_official = require("./official.cjs");
4
5
  const require_errors = require("./errors.cjs");
5
6
  const require_provider = require("./provider.cjs");
6
- const require_schema = require("./schema.cjs");
7
7
  const require_recordEvent = require("./recordEvent.cjs");
8
8
  const require_reads = require("./reads.cjs");
9
9
  const require_routes = require("./routes.cjs");
@@ -2,7 +2,7 @@ import { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage,
2
2
  import { InsightsBundleEventFilter, InsightsCountEventSeriesInput, InsightsCountEventsInput, InsightsCountLatestEventsInput, InsightsCoverage, InsightsEventCursor, InsightsEventFilter, InsightsEventSeriesPoint, InsightsFindLatestEventsInput, InsightsGetAppUsageInput, InsightsGetAppUsageResult, InsightsGetReleaseActivityInput, InsightsGetReleaseActivityResult, InsightsListEventsInput, InsightsModel, InsightsRecordEventInput, InsightsScope, InsightsTimeRange, ReleaseActivityMetrics, ReleaseReference } from "./modelTypes.cjs";
3
3
  import { DEFAULT_INSIGHTS_RETENTION, InsightsRetention, InsightsSchema, createInsightsSchema, insightsSchema } from "./schema.cjs";
4
4
  import { InsightsFailureBreakdown, InsightsUpdateFailures, InsightsUpdateFailuresInput } from "./reads.cjs";
5
- import { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview } from "./domain.cjs";
5
+ import { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryChange, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview } from "./domain.cjs";
6
6
  import { InsightsBadRequestError } from "./errors.cjs";
7
7
  import { createInsightsModel } from "./model.cjs";
8
8
  import { InsightsEventPageInput, InsightsInstallationEventPageInput, InsightsProvider, InsightsUserInstallationPageInput } from "./types.cjs";
@@ -422,4 +422,4 @@ declare const insights: (options?: InsightsOptions) => import("@hot-updater/plug
422
422
  endpoints: PluginEndpoint[];
423
423
  }, {}>;
424
424
  //#endregion
425
- export { type ActiveInstallationWindow, type BundleEventFailure, type BundleEventFailureInput, type BundleEventFailureReason, type BundleEventFailureStage, type BundleEventRow, type BundleEventRowBase, type CreateBundleEventRequest, type CreateBundleEventRequestBase, type CursorPage, DEFAULT_INSIGHTS_RETENTION, type DatabaseBundleEventMetadata, type EventCursorPage, type EventHistoryRow, InsightsApi, InsightsBadRequestError, type InsightsBundleEventFilter, type InsightsBundleSelection, type InsightsCountEventSeriesInput, type InsightsCountEventsInput, type InsightsCountLatestEventsInput, type InsightsCountMeasurement, type InsightsCoverage, type InsightsEventCursor, type InsightsEventFilter, type InsightsEventPageInput, type InsightsEventSeriesPoint, type InsightsFailureBreakdown, type InsightsFindLatestEventsInput, type InsightsGetAppUsageInput, type InsightsGetAppUsageResult, type InsightsGetReleaseActivityInput, type InsightsGetReleaseActivityResult, type InsightsHttpResponse, type InsightsIdentityParts, type InsightsInstallationEventPageInput, type InsightsListEventsInput, type InsightsModel, InsightsOptions, type InsightsProvider, type InsightsRecordEventInput, type InsightsRetention, type InsightsSchema, type InsightsScope, type InsightsTimeRange, type InsightsUpdateFailures, type InsightsUpdateFailuresInput, type InsightsUserInstallationPageInput, type InstallationHistoryRow, type InstallationRow, type ReleaseActivityMetrics, type ReleaseReference, type ReportingOverview, createInsightsModel, createInsightsProvider, createInsightsSchema, insights, insightsIdentity, insightsSchema };
425
+ export { type ActiveInstallationWindow, type BundleEventFailure, type BundleEventFailureInput, type BundleEventFailureReason, type BundleEventFailureStage, type BundleEventRow, type BundleEventRowBase, type CreateBundleEventRequest, type CreateBundleEventRequestBase, type CursorPage, DEFAULT_INSIGHTS_RETENTION, type DatabaseBundleEventMetadata, type EventCursorPage, type EventHistoryChange, type EventHistoryRow, InsightsApi, InsightsBadRequestError, type InsightsBundleEventFilter, type InsightsBundleSelection, type InsightsCountEventSeriesInput, type InsightsCountEventsInput, type InsightsCountLatestEventsInput, type InsightsCountMeasurement, type InsightsCoverage, type InsightsEventCursor, type InsightsEventFilter, type InsightsEventPageInput, type InsightsEventSeriesPoint, type InsightsFailureBreakdown, type InsightsFindLatestEventsInput, type InsightsGetAppUsageInput, type InsightsGetAppUsageResult, type InsightsGetReleaseActivityInput, type InsightsGetReleaseActivityResult, type InsightsHttpResponse, type InsightsIdentityParts, type InsightsInstallationEventPageInput, type InsightsListEventsInput, type InsightsModel, InsightsOptions, type InsightsProvider, type InsightsRecordEventInput, type InsightsRetention, type InsightsSchema, type InsightsScope, type InsightsTimeRange, type InsightsUpdateFailures, type InsightsUpdateFailuresInput, type InsightsUserInstallationPageInput, type InstallationHistoryRow, type InstallationRow, type ReleaseActivityMetrics, type ReleaseReference, type ReportingOverview, createInsightsModel, createInsightsProvider, createInsightsSchema, insights, insightsIdentity, insightsSchema };
@@ -2,7 +2,7 @@ import { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage,
2
2
  import { InsightsBundleEventFilter, InsightsCountEventSeriesInput, InsightsCountEventsInput, InsightsCountLatestEventsInput, InsightsCoverage, InsightsEventCursor, InsightsEventFilter, InsightsEventSeriesPoint, InsightsFindLatestEventsInput, InsightsGetAppUsageInput, InsightsGetAppUsageResult, InsightsGetReleaseActivityInput, InsightsGetReleaseActivityResult, InsightsListEventsInput, InsightsModel, InsightsRecordEventInput, InsightsScope, InsightsTimeRange, ReleaseActivityMetrics, ReleaseReference } from "./modelTypes.mjs";
3
3
  import { DEFAULT_INSIGHTS_RETENTION, InsightsRetention, InsightsSchema, createInsightsSchema, insightsSchema } from "./schema.mjs";
4
4
  import { InsightsFailureBreakdown, InsightsUpdateFailures, InsightsUpdateFailuresInput } from "./reads.mjs";
5
- import { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview } from "./domain.mjs";
5
+ import { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryChange, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview } from "./domain.mjs";
6
6
  import { InsightsBadRequestError } from "./errors.mjs";
7
7
  import { createInsightsModel } from "./model.mjs";
8
8
  import { InsightsEventPageInput, InsightsInstallationEventPageInput, InsightsProvider, InsightsUserInstallationPageInput } from "./types.mjs";
@@ -422,4 +422,4 @@ declare const insights: (options?: InsightsOptions) => import("@hot-updater/plug
422
422
  endpoints: PluginEndpoint[];
423
423
  }, {}>;
424
424
  //#endregion
425
- export { type ActiveInstallationWindow, type BundleEventFailure, type BundleEventFailureInput, type BundleEventFailureReason, type BundleEventFailureStage, type BundleEventRow, type BundleEventRowBase, type CreateBundleEventRequest, type CreateBundleEventRequestBase, type CursorPage, DEFAULT_INSIGHTS_RETENTION, type DatabaseBundleEventMetadata, type EventCursorPage, type EventHistoryRow, InsightsApi, InsightsBadRequestError, type InsightsBundleEventFilter, type InsightsBundleSelection, type InsightsCountEventSeriesInput, type InsightsCountEventsInput, type InsightsCountLatestEventsInput, type InsightsCountMeasurement, type InsightsCoverage, type InsightsEventCursor, type InsightsEventFilter, type InsightsEventPageInput, type InsightsEventSeriesPoint, type InsightsFailureBreakdown, type InsightsFindLatestEventsInput, type InsightsGetAppUsageInput, type InsightsGetAppUsageResult, type InsightsGetReleaseActivityInput, type InsightsGetReleaseActivityResult, type InsightsHttpResponse, type InsightsIdentityParts, type InsightsInstallationEventPageInput, type InsightsListEventsInput, type InsightsModel, InsightsOptions, type InsightsProvider, type InsightsRecordEventInput, type InsightsRetention, type InsightsSchema, type InsightsScope, type InsightsTimeRange, type InsightsUpdateFailures, type InsightsUpdateFailuresInput, type InsightsUserInstallationPageInput, type InstallationHistoryRow, type InstallationRow, type ReleaseActivityMetrics, type ReleaseReference, type ReportingOverview, createInsightsModel, createInsightsProvider, createInsightsSchema, insights, insightsIdentity, insightsSchema };
425
+ export { type ActiveInstallationWindow, type BundleEventFailure, type BundleEventFailureInput, type BundleEventFailureReason, type BundleEventFailureStage, type BundleEventRow, type BundleEventRowBase, type CreateBundleEventRequest, type CreateBundleEventRequestBase, type CursorPage, DEFAULT_INSIGHTS_RETENTION, type DatabaseBundleEventMetadata, type EventCursorPage, type EventHistoryChange, type EventHistoryRow, InsightsApi, InsightsBadRequestError, type InsightsBundleEventFilter, type InsightsBundleSelection, type InsightsCountEventSeriesInput, type InsightsCountEventsInput, type InsightsCountLatestEventsInput, type InsightsCountMeasurement, type InsightsCoverage, type InsightsEventCursor, type InsightsEventFilter, type InsightsEventPageInput, type InsightsEventSeriesPoint, type InsightsFailureBreakdown, type InsightsFindLatestEventsInput, type InsightsGetAppUsageInput, type InsightsGetAppUsageResult, type InsightsGetReleaseActivityInput, type InsightsGetReleaseActivityResult, type InsightsHttpResponse, type InsightsIdentityParts, type InsightsInstallationEventPageInput, type InsightsListEventsInput, type InsightsModel, InsightsOptions, type InsightsProvider, type InsightsRecordEventInput, type InsightsRetention, type InsightsSchema, type InsightsScope, type InsightsTimeRange, type InsightsUpdateFailures, type InsightsUpdateFailuresInput, type InsightsUserInstallationPageInput, type InstallationHistoryRow, type InstallationRow, type ReleaseActivityMetrics, type ReleaseReference, type ReportingOverview, createInsightsModel, createInsightsProvider, createInsightsSchema, insights, insightsIdentity, insightsSchema };
@@ -1,8 +1,8 @@
1
+ import { DEFAULT_INSIGHTS_RETENTION, createInsightsSchema, insightsSchema } from "./schema.mjs";
1
2
  import { createInsightsModel } from "./model.mjs";
2
3
  import { markOfficial } from "./official.mjs";
3
4
  import { InsightsBadRequestError } from "./errors.mjs";
4
5
  import { createInsightsProvider } from "./provider.mjs";
5
- import { DEFAULT_INSIGHTS_RETENTION, createInsightsSchema, insightsSchema } from "./schema.mjs";
6
6
  import { insightsIdentity, recordEvent } from "./recordEvent.mjs";
7
7
  import { countEventSeries, countEvents, countLatestEvents, findLatestEvents, getAppUsage, getReleaseActivity, getUpdateFailures, listEvents } from "./reads.mjs";
8
8
  import { INSIGHTS_ROUTES, createInsightsRouteHandlers } from "./routes.mjs";
@@ -10,13 +10,14 @@ interface InsightsScope {
10
10
  }
11
11
  /**
12
12
  * Raw event predicates; recovery is attributed to the source bundle. An
13
- * UNCHANGED report is kept as no event, so no filter names it.
13
+ * UNCHANGED filter matches the kept rows that moved an installation to the
14
+ * bundle without an apply report.
14
15
  */
15
16
  type InsightsBundleEventFilter = InsightsScope & ({
16
17
  readonly type: "RECOVERED";
17
18
  readonly fromBundleId: string;
18
19
  } | {
19
- readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "UPDATE_FAILED";
20
+ readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "UPDATE_FAILED" | "UNCHANGED";
20
21
  readonly toBundleId: string;
21
22
  });
22
23
  type InsightsEventFilter = {
@@ -10,13 +10,14 @@ interface InsightsScope {
10
10
  }
11
11
  /**
12
12
  * Raw event predicates; recovery is attributed to the source bundle. An
13
- * UNCHANGED report is kept as no event, so no filter names it.
13
+ * UNCHANGED filter matches the kept rows that moved an installation to the
14
+ * bundle without an apply report.
14
15
  */
15
16
  type InsightsBundleEventFilter = InsightsScope & ({
16
17
  readonly type: "RECOVERED";
17
18
  readonly fromBundleId: string;
18
19
  } | {
19
- readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "UPDATE_FAILED";
20
+ readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "UPDATE_FAILED" | "UNCHANGED";
20
21
  readonly toBundleId: string;
21
22
  });
22
23
  type InsightsEventFilter = {
@@ -1,3 +1,4 @@
1
+ const require_schema = require("./schema.cjs");
1
2
  //#region src/server/overview.ts
2
3
  const encodeIdentity = (identity) => [
3
4
  identity.scopeKind,
@@ -64,18 +65,19 @@ const insightsOverviewDeltas = (event) => {
64
65
  bucketStartMs
65
66
  }, windowed);
66
67
  };
67
- if (event.type === "UPDATE_DOWNLOADED") metric(event.to_release_id, {
68
+ const implied = event.metadata.implied_download === true ? 1 : 0;
69
+ if (!require_schema.isLateEvent(event) && event.type === "UPDATE_DOWNLOADED") metric(event.to_release_id, {
68
70
  downloads: 1,
69
71
  applies: 0,
70
72
  failedLaunches: 0
71
73
  });
72
- else if (event.type === "UPDATE_APPLIED") metric(event.to_release_id, {
73
- downloads: 0,
74
+ else if (!require_schema.isLateEvent(event) && require_schema.isLaunch(event)) metric(event.to_release_id, {
75
+ downloads: implied,
74
76
  applies: 1,
75
77
  failedLaunches: 0
76
78
  });
77
- else if (event.type === "RECOVERED") metric(event.from_release_id, {
78
- downloads: 0,
79
+ else if (!require_schema.isLateEvent(event) && event.type === "RECOVERED") metric(event.from_release_id, {
80
+ downloads: implied,
79
81
  applies: 0,
80
82
  failedLaunches: 1
81
83
  });
@@ -1,3 +1,4 @@
1
+ import { isLateEvent, isLaunch } from "./schema.mjs";
1
2
  //#region src/server/overview.ts
2
3
  const encodeIdentity = (identity) => [
3
4
  identity.scopeKind,
@@ -64,18 +65,19 @@ const insightsOverviewDeltas = (event) => {
64
65
  bucketStartMs
65
66
  }, windowed);
66
67
  };
67
- if (event.type === "UPDATE_DOWNLOADED") metric(event.to_release_id, {
68
+ const implied = event.metadata.implied_download === true ? 1 : 0;
69
+ if (!isLateEvent(event) && event.type === "UPDATE_DOWNLOADED") metric(event.to_release_id, {
68
70
  downloads: 1,
69
71
  applies: 0,
70
72
  failedLaunches: 0
71
73
  });
72
- else if (event.type === "UPDATE_APPLIED") metric(event.to_release_id, {
73
- downloads: 0,
74
+ else if (!isLateEvent(event) && isLaunch(event)) metric(event.to_release_id, {
75
+ downloads: implied,
74
76
  applies: 1,
75
77
  failedLaunches: 0
76
78
  });
77
- else if (event.type === "RECOVERED") metric(event.from_release_id, {
78
- downloads: 0,
79
+ else if (!isLateEvent(event) && event.type === "RECOVERED") metric(event.from_release_id, {
80
+ downloads: implied,
79
81
  applies: 0,
80
82
  failedLaunches: 1
81
83
  });
@@ -166,7 +166,19 @@ const eventDetails = ({ type, metadata }) => ({
166
166
  } } : {},
167
167
  ...type === "UPDATE_FAILED" && metadata.failure !== void 0 ? { failure: toFailure(metadata.failure) } : {},
168
168
  ...type === "UPDATE_DOWNLOADED" && metadata.delivery !== void 0 ? { delivery: metadata.delivery } : {},
169
- ...type === "UPDATE_DOWNLOADED" && metadata.patch_fallback === true ? { patchFallback: true } : {}
169
+ ...type === "UPDATE_DOWNLOADED" && metadata.patch_fallback === true ? { patchFallback: true } : {},
170
+ ...type === "UNCHANGED" && metadata.change !== void 0 ? { change: {
171
+ kinds: metadata.change.kinds,
172
+ ...metadata.change.previous === null ? {} : { previous: {
173
+ bundleId: metadata.change.previous.bundle_id,
174
+ releaseId: metadata.change.previous.release_id,
175
+ appVersion: metadata.change.previous.app_version,
176
+ channel: metadata.change.previous.channel,
177
+ ...metadata.change.previous.min_bundle_id === void 0 ? {} : { minBundleId: metadata.change.previous.min_bundle_id }
178
+ } }
179
+ } } : {},
180
+ ...metadata.late === true ? { late: true } : {},
181
+ ...metadata.implied_download === true ? { impliedDownload: true } : {}
170
182
  });
171
183
  const toEventHistoryRow = (row) => ({
172
184
  ...row.to_release_id == null ? {} : { toReleaseId: row.to_release_id },
@@ -166,7 +166,19 @@ const eventDetails = ({ type, metadata }) => ({
166
166
  } } : {},
167
167
  ...type === "UPDATE_FAILED" && metadata.failure !== void 0 ? { failure: toFailure(metadata.failure) } : {},
168
168
  ...type === "UPDATE_DOWNLOADED" && metadata.delivery !== void 0 ? { delivery: metadata.delivery } : {},
169
- ...type === "UPDATE_DOWNLOADED" && metadata.patch_fallback === true ? { patchFallback: true } : {}
169
+ ...type === "UPDATE_DOWNLOADED" && metadata.patch_fallback === true ? { patchFallback: true } : {},
170
+ ...type === "UNCHANGED" && metadata.change !== void 0 ? { change: {
171
+ kinds: metadata.change.kinds,
172
+ ...metadata.change.previous === null ? {} : { previous: {
173
+ bundleId: metadata.change.previous.bundle_id,
174
+ releaseId: metadata.change.previous.release_id,
175
+ appVersion: metadata.change.previous.app_version,
176
+ channel: metadata.change.previous.channel,
177
+ ...metadata.change.previous.min_bundle_id === void 0 ? {} : { minBundleId: metadata.change.previous.min_bundle_id }
178
+ } }
179
+ } } : {},
180
+ ...metadata.late === true ? { late: true } : {},
181
+ ...metadata.implied_download === true ? { impliedDownload: true } : {}
170
182
  });
171
183
  const toEventHistoryRow = (row) => ({
172
184
  ...row.to_release_id == null ? {} : { toReleaseId: row.to_release_id },
@@ -1,5 +1,5 @@
1
- require("./provider.cjs");
2
1
  const require_schema = require("./schema.cjs");
2
+ require("./provider.cjs");
3
3
  const require_recordEvent = require("./recordEvent.cjs");
4
4
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
5
5
  //#region src/server/reads.ts
@@ -36,12 +36,11 @@ const EVENT_FIELDS = [
36
36
  ];
37
37
  /** A stored event or head, as the event row it holds. */
38
38
  const toEvent = (row) => Object.fromEntries(EVENT_FIELDS.map((field) => [field, row[field]]));
39
- const bundleRef = (filter) => filter.type === "RECOVERED" ? `from:${filter.fromBundleId}` : `to:${filter.toBundleId}`;
40
39
  const scopeOf = (filter) => ({
41
40
  platform: filter.platform,
42
41
  channel: filter.channel,
43
42
  type: filter.type,
44
- bundle_ref: bundleRef(filter)
43
+ bundle_ref: require_schema.bundleRefOfFilter(filter)
45
44
  });
46
45
  /** One UTC day of a global or bundle list, newest first. */
47
46
  const eventsOfDay = (db, filter, day, range, limit) => filter.kind === "all" ? db.findMany("bundle_events", {
@@ -1,5 +1,5 @@
1
+ import { DAILY_EVENTS, DAY_MS, HOUR_MS, bundleRefOfFilter } from "./schema.mjs";
1
2
  import "./provider.mjs";
2
- import { DAILY_EVENTS, DAY_MS, HOUR_MS } from "./schema.mjs";
3
3
  import { PAIR_FIELD, bundlePairKey, insightsIdentity } from "./recordEvent.mjs";
4
4
  import { DatabaseAdapterInputError, compareUtf8, countDistinct, mergeDistinct } from "@hot-updater/plugin-core";
5
5
  //#region src/server/reads.ts
@@ -36,12 +36,11 @@ const EVENT_FIELDS = [
36
36
  ];
37
37
  /** A stored event or head, as the event row it holds. */
38
38
  const toEvent = (row) => Object.fromEntries(EVENT_FIELDS.map((field) => [field, row[field]]));
39
- const bundleRef = (filter) => filter.type === "RECOVERED" ? `from:${filter.fromBundleId}` : `to:${filter.toBundleId}`;
40
39
  const scopeOf = (filter) => ({
41
40
  platform: filter.platform,
42
41
  channel: filter.channel,
43
42
  type: filter.type,
44
- bundle_ref: bundleRef(filter)
43
+ bundle_ref: bundleRefOfFilter(filter)
45
44
  });
46
45
  /** One UTC day of a global or bundle list, newest first. */
47
46
  const eventsOfDay = (db, filter, day, range, limit) => filter.kind === "all" ? db.findMany("bundle_events", {
@@ -1,7 +1,7 @@
1
1
  const require_eventRow = require("./eventRow.cjs");
2
+ const require_schema = require("./schema.cjs");
2
3
  const require_contract = require("./contract.cjs");
3
4
  const require_overview = require("./overview.cjs");
4
- const require_schema = require("./schema.cjs");
5
5
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
6
6
  //#region src/server/recordEvent.ts
7
7
  const hourOf = (ms) => ms - ms % require_schema.HOUR_MS;
@@ -136,16 +136,16 @@ const countEvent = (tx, event) => {
136
136
  }
137
137
  };
138
138
  /**
139
- * A stored event's outcome rows: its bundle filter's hour, which a failed
140
- * 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.
141
141
  */
142
142
  const countOutcome = (tx, event) => {
143
143
  const shardBy = event.install_id;
144
- if (!require_schema.isFailedCheck(event)) tx.aggregate("insights_outcomes", {
144
+ for (const bundleRef of require_schema.bundleRefsOf(event)) tx.aggregate("insights_outcomes", {
145
145
  platform: event.platform,
146
146
  channel: event.channel,
147
147
  type: event.type,
148
- bundle_ref: event.type === "RECOVERED" ? `from:${event.from_bundle_id}` : `to:${event.to_bundle_id}`,
148
+ bundle_ref: bundleRef,
149
149
  bucket_start_ms: hourOf(event.received_at_ms)
150
150
  }, { events: 1 }, { shardBy });
151
151
  tx.aggregate("insights_outcomes", {
@@ -249,6 +249,126 @@ const countFailure = (tx, event) => {
249
249
  }, { events: 1 }, { shardBy });
250
250
  };
251
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
+ };
252
372
  /** What an UNCHANGED report must share with its installation's head to repeat it. */
253
373
  const REPEATED_FIELDS = [
254
374
  "channel",
@@ -283,23 +403,24 @@ const recordEvent = (db, event) => {
283
403
  const [existing, previous] = await Promise.all([tx.findOne("bundle_events", { id: event.id }), tx.findOne("bundle_event_heads", { install_id: event.install_id })]);
284
404
  if (existing !== null || previous?.id === event.id) return;
285
405
  if (previous !== null && repeatsHead(event, previous)) return;
286
- if (event.type !== "UNCHANGED") {
287
- tx.create("bundle_events", event);
288
- 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);
289
410
  }
290
- if (event.type === "UPDATE_FAILED") {
291
- countFailure(tx, event);
411
+ if (recorded.type === "UPDATE_FAILED") {
412
+ countFailure(tx, recorded);
292
413
  return;
293
414
  }
294
- countEvent(tx, event);
295
- if (previous !== null && !isNewer(event, previous)) return;
296
- 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);
297
418
  else {
298
- const { install_id: _, ...fields } = event;
419
+ const { install_id: _, ...fields } = recorded;
299
420
  countHead(tx, previous, -1);
300
421
  tx.update("bundle_event_heads", previous, fields);
301
422
  }
302
- countHead(tx, event, 1);
423
+ countHead(tx, recorded, 1);
303
424
  });
304
425
  };
305
426
  //#endregion
@@ -1,8 +1,8 @@
1
1
  import { runningBuiltinBundleId } from "./eventRow.mjs";
2
+ import { DAILY_EVENTS, DAY_MS, HOUR_MS, bundleRefsOf, isLaunch } from "./schema.mjs";
2
3
  import { assertBundleEventRow } from "./contract.mjs";
3
4
  import { insightsKey, insightsOverviewDeltas, insightsOverviewId } from "./overview.mjs";
4
- import { DAILY_EVENTS, DAY_MS, HOUR_MS, isFailedCheck } from "./schema.mjs";
5
- import { addDistinct, compareUtf8 } from "@hot-updater/plugin-core";
5
+ import { addDistinct, compareUtf8, extractTimestampFromUUIDv7, isUUIDv7 } from "@hot-updater/plugin-core";
6
6
  //#region src/server/recordEvent.ts
7
7
  const hourOf = (ms) => ms - ms % HOUR_MS;
8
8
  const dayOf = (ms) => ms - ms % DAY_MS;
@@ -136,16 +136,16 @@ const countEvent = (tx, event) => {
136
136
  }
137
137
  };
138
138
  /**
139
- * A stored event's outcome rows: its bundle filter's hour, which a failed
140
- * 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.
141
141
  */
142
142
  const countOutcome = (tx, event) => {
143
143
  const shardBy = event.install_id;
144
- if (!isFailedCheck(event)) tx.aggregate("insights_outcomes", {
144
+ for (const bundleRef of bundleRefsOf(event)) tx.aggregate("insights_outcomes", {
145
145
  platform: event.platform,
146
146
  channel: event.channel,
147
147
  type: event.type,
148
- bundle_ref: event.type === "RECOVERED" ? `from:${event.from_bundle_id}` : `to:${event.to_bundle_id}`,
148
+ bundle_ref: bundleRef,
149
149
  bucket_start_ms: hourOf(event.received_at_ms)
150
150
  }, { events: 1 }, { shardBy });
151
151
  tx.aggregate("insights_outcomes", {
@@ -249,6 +249,126 @@ const countFailure = (tx, event) => {
249
249
  }, { events: 1 }, { shardBy });
250
250
  };
251
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
+ };
252
372
  /** What an UNCHANGED report must share with its installation's head to repeat it. */
253
373
  const REPEATED_FIELDS = [
254
374
  "channel",
@@ -283,23 +403,24 @@ const recordEvent = (db, event) => {
283
403
  const [existing, previous] = await Promise.all([tx.findOne("bundle_events", { id: event.id }), tx.findOne("bundle_event_heads", { install_id: event.install_id })]);
284
404
  if (existing !== null || previous?.id === event.id) return;
285
405
  if (previous !== null && repeatsHead(event, previous)) return;
286
- if (event.type !== "UNCHANGED") {
287
- tx.create("bundle_events", event);
288
- 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);
289
410
  }
290
- if (event.type === "UPDATE_FAILED") {
291
- countFailure(tx, event);
411
+ if (recorded.type === "UPDATE_FAILED") {
412
+ countFailure(tx, recorded);
292
413
  return;
293
414
  }
294
- countEvent(tx, event);
295
- if (previous !== null && !isNewer(event, previous)) return;
296
- 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);
297
418
  else {
298
- const { install_id: _, ...fields } = event;
419
+ const { install_id: _, ...fields } = recorded;
299
420
  countHead(tx, previous, -1);
300
421
  tx.update("bundle_event_heads", previous, fields);
301
422
  }
302
- countHead(tx, event, 1);
423
+ countHead(tx, recorded, 1);
303
424
  });
304
425
  };
305
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: {
@@ -534,6 +566,10 @@ exports.DAILY_EVENTS = DAILY_EVENTS;
534
566
  exports.DAY_MS = DAY_MS;
535
567
  exports.DEFAULT_INSIGHTS_RETENTION = DEFAULT_INSIGHTS_RETENTION;
536
568
  exports.HOUR_MS = HOUR_MS;
569
+ exports.bundleRefOfFilter = bundleRefOfFilter;
570
+ exports.bundleRefsOf = bundleRefsOf;
537
571
  exports.createInsightsSchema = createInsightsSchema;
538
572
  exports.insightsSchema = insightsSchema;
539
573
  exports.isFailedCheck = isFailedCheck;
574
+ exports.isLateEvent = isLateEvent;
575
+ exports.isLaunch = isLaunch;
@@ -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) => defineTable({
27
62
  id: {
@@ -76,19 +111,16 @@ const bundleEvents = (days) => 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: {
@@ -530,4 +562,4 @@ const createInsightsSchema = ({ rawDays, dailyDays } = DEFAULT_INSIGHTS_RETENTIO
530
562
  };
531
563
  const insightsSchema = createInsightsSchema();
532
564
  //#endregion
533
- export { DAILY_EVENTS, DAY_MS, DEFAULT_INSIGHTS_RETENTION, HOUR_MS, createInsightsSchema, insightsSchema, isFailedCheck };
565
+ export { DAILY_EVENTS, DAY_MS, DEFAULT_INSIGHTS_RETENTION, HOUR_MS, bundleRefOfFilter, bundleRefsOf, createInsightsSchema, insightsSchema, isFailedCheck, isLateEvent, isLaunch };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/plugin-insights",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.36",
4
+ "version": "1.0.0-rc.37",
5
5
  "description": "Hot Updater's Insights plugin: launch, download, and update failure events, for the server and the React Native app",
6
6
  "exports": {
7
7
  "./client": {
@@ -33,10 +33,10 @@
33
33
  "package.json"
34
34
  ],
35
35
  "dependencies": {
36
- "@hot-updater/protocol": "1.0.0-rc.36"
36
+ "@hot-updater/protocol": "1.0.0-rc.37"
37
37
  },
38
38
  "peerDependencies": {
39
- "@hot-updater/plugin-core": "^1.0.0-rc.36"
39
+ "@hot-updater/plugin-core": "^1.0.0-rc.37"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@hot-updater/plugin-core": {
@@ -46,8 +46,8 @@
46
46
  "devDependencies": {
47
47
  "@types/node": "^20",
48
48
  "vitest": "4.1.4",
49
- "@hot-updater/plugin-core": "1.0.0-rc.36",
50
- "@hot-updater/test-utils": "1.0.0-rc.36"
49
+ "@hot-updater/test-utils": "1.0.0-rc.37",
50
+ "@hot-updater/plugin-core": "1.0.0-rc.37"
51
51
  },
52
52
  "nx": {
53
53
  "implicitDependencies": [