@hot-updater/firebase 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.
@@ -4524,7 +4524,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
4524
4524
  };
4525
4525
  //#endregion
4526
4526
  //#region ../../packages/server/dist/version.mjs
4527
- const HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.36";
4527
+ const HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.37";
4528
4528
  //#endregion
4529
4529
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
4530
4530
  /**
@@ -7681,1503 +7681,1560 @@ const apiKeys = (options = {}) => {
7681
7681
  return markOfficial$1(plugin);
7682
7682
  };
7683
7683
  //#endregion
7684
- //#region ../insights/dist/server/eventRow.mjs
7685
- const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
7686
- const isString = (value) => typeof value === "string";
7687
- 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);
7688
- /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
7689
- 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);
7690
- 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");
7691
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7684
+ //#region ../insights/dist/server/schema.mjs
7685
+ const HOUR_MS$1 = 36e5;
7686
+ const DAY_MS$1 = 864e5;
7687
+ const DEFAULT_INSIGHTS_RETENTION = {
7688
+ rawDays: 90,
7689
+ dailyDays: 400
7690
+ };
7691
+ const bucketRetention = (days) => ({
7692
+ field: "bucket_start_ms",
7693
+ days
7694
+ });
7695
+ /** Every kind of row the table keeps belongs in installation history. */
7696
+ const MOVEMENTS = /* @__PURE__ */ new Set([
7697
+ "UPDATE_DOWNLOADED",
7698
+ "UPDATE_APPLIED",
7699
+ "RECOVERED",
7700
+ "UPDATE_FAILED",
7701
+ "UNCHANGED"
7702
+ ]);
7703
+ /** A failed update check: it names no target bundle, so no bundle list shows it. */
7704
+ const isFailedCheck = (row) => row.type === "UPDATE_FAILED" && row.metadata?.failure?.stage === "check";
7705
+ const metadataOf = (row) => row.metadata ?? {};
7706
+ /** A download or apply that arrived after its target ran: it counts nothing. */
7707
+ const isLateEvent = (row) => metadataOf(row).late === true;
7692
7708
  /**
7693
- * The built-in bundle a report says its installation runs: the running
7694
- * bundle, which a download leaves at its source, when it is the native
7695
- * build's built-in bundle, whatever Release selected it. Null for any other
7696
- * bundle, and for a report without the build's built-in bundle ID.
7709
+ * Whether a stored row launched its installation into a bundle release: an
7710
+ * apply, or a kept UNCHANGED row that moved the installation to another
7711
+ * bundle of a release, as when the apply report never arrived or a rollback
7712
+ * to the built-in bundle took it there. A first report, a release change of
7713
+ * the bundle already running, and a late row launch nothing.
7697
7714
  */
7698
- const runningBuiltinBundleId = (row) => {
7699
- const running = row.type === "UPDATE_DOWNLOADED" ? row.from_bundle_id : row.to_bundle_id;
7700
- const builtin = isRecord$2(row.metadata) ? row.metadata.min_bundle_id : null;
7701
- return typeof builtin === "string" && running === builtin ? builtin : null;
7715
+ const isLaunch = (row) => {
7716
+ if (isLateEvent(row)) return false;
7717
+ if (row.type === "UPDATE_APPLIED") return true;
7718
+ if (row.type !== "UNCHANGED") return false;
7719
+ const kinds = metadataOf(row).change?.kinds ?? [];
7720
+ return kinds.includes("bundle") && !kinds.includes("first_seen") && row.to_release_id !== null;
7702
7721
  };
7703
- const isIdentity$1 = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
7704
- const isTextOrNull = (value) => value === null || typeof value === "string";
7705
- /** Each bundle event field and what it holds. */
7706
- const BUNDLE_EVENT_FIELDS = {
7707
- id: (value) => typeof value === "string",
7708
- type: (value) => value === "UPDATE_DOWNLOADED" || value === "UPDATE_APPLIED" || value === "RECOVERED" || value === "UPDATE_FAILED" || value === "UNCHANGED",
7709
- install_id: isIdentity$1,
7710
- user_id: (value) => value === null || isIdentity$1(value),
7711
- from_bundle_id: isTextOrNull,
7712
- from_release_id: isTextOrNull,
7713
- to_release_id: isTextOrNull,
7714
- to_bundle_id: (value) => typeof value === "string",
7715
- platform: (value) => value === "ios" || value === "android",
7716
- app_version: (value) => typeof value === "string",
7717
- channel: (value) => typeof value === "string",
7718
- metadata: isDatabaseBundleEventMetadata,
7719
- received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
7720
- };
7721
- const updateStrategyOf = (row) => isRecord$2(row.metadata) ? row.metadata.update_strategy : void 0;
7722
7722
  /**
7723
- * A movement, or a failed update, names the bundle it left and how the
7724
- * device updates, and a failed update says what failed; an unchanged report
7725
- * names neither.
7723
+ * The bundle a stored row's bundle filter matches: `from:<bundle>` for a
7724
+ * recovery; `to:<bundle>` for a download, an apply, and an update failure;
7725
+ * `on:<bundle>` for an UNCHANGED row that launched a bundle (`isLaunch`), a
7726
+ * key no other row uses. None for a failed check, which targets no bundle, a
7727
+ * late row, or any other UNCHANGED row.
7726
7728
  */
7727
- 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$2(row.metadata) && isRecord$2(row.metadata.failure)) || row.type === "UNCHANGED" && row.from_bundle_id === null && updateStrategyOf(row) === null;
7729
+ const bundleRefsOf = (row) => {
7730
+ if (isFailedCheck(row) || isLateEvent(row)) return [];
7731
+ if (row.type === "RECOVERED") return [`from:${row.from_bundle_id}`];
7732
+ if (row.type === "UNCHANGED") return isLaunch(row) ? [`on:${row.to_bundle_id}`] : [];
7733
+ return [`to:${row.to_bundle_id}`];
7734
+ };
7735
+ /** The key a bundle filter reads, as `bundleRefsOf` writes it. */
7736
+ const bundleRefOfFilter = (filter) => filter.type === "RECOVERED" ? `from:${filter.fromBundleId}` : filter.type === "UNCHANGED" ? `on:${filter.toBundleId}` : `to:${filter.toBundleId}`;
7728
7737
  /**
7729
- * Throws unless `row` has exactly the bundle event fields, each of its type,
7730
- * and the invariants of its event type: `invalid-field` for an unknown field,
7731
- * `invalid-data` for anything else.
7738
+ * Downloads, applies, recoveries, update failures, and the UNCHANGED
7739
+ * reports that changed what their installation runs, whole. An UNCHANGED
7740
+ * report that changes nothing counts as a launch and moves its
7741
+ * installation's head, but no row keeps it.
7732
7742
  */
7733
- const validateBundleEventFields = (row) => {
7734
- if (!isRecord$2(row)) throw new DatabaseAdapterInputError("invalid-data");
7735
- for (const field of Object.keys(row)) if (!Object.hasOwn(BUNDLE_EVENT_FIELDS, field)) throw new DatabaseAdapterInputError("invalid-field");
7736
- for (const [field, valid] of Object.entries(BUNDLE_EVENT_FIELDS)) if (!Object.hasOwn(row, field) || !valid(row[field])) throw new DatabaseAdapterInputError("invalid-data");
7737
- if (!hasEventInvariants(row)) throw new DatabaseAdapterInputError("invalid-data");
7738
- };
7739
- //#endregion
7740
- //#region ../insights/dist/server/contract.mjs
7741
- const encoder = new TextEncoder();
7742
- /** Exact UTF-8 byte ordering, without case folding or Unicode normalization. */
7743
- const compareInsightsText = (left, right) => {
7744
- const a = encoder.encode(left);
7745
- const b = encoder.encode(right);
7746
- for (let index = 0; index < Math.min(a.length, b.length); index += 1) if (a[index] !== b[index]) return a[index] - b[index];
7747
- return a.length - b.length;
7748
- };
7749
- const isWellFormedText = (value) => {
7750
- for (const character of value) {
7751
- const point = character.codePointAt(0);
7752
- if (point >= 55296 && point <= 57343) return false;
7753
- }
7754
- return true;
7755
- };
7756
- const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255 && isWellFormedText(value);
7757
- const isText$1 = (value) => typeof value === "string" && value.length > 0 && isWellFormedText(value);
7758
- const isTimestamp = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
7759
- const isLimit = (value) => isTimestamp(value) && value >= 1 && value <= 101;
7760
- const hasOnlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
7761
- const hasScope = (value) => (value.platform === "ios" || value.platform === "android") && isText$1(value.channel);
7762
- const isBundleFilter = (value, withKind = false) => {
7763
- if (!isRecord$2(value) || !hasScope(value)) return false;
7764
- const keys = [
7765
- "platform",
7766
- "channel",
7767
- "type",
7768
- ...withKind ? ["kind"] : []
7769
- ];
7770
- return value.type === "RECOVERED" ? isText$1(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED") && isText$1(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
7771
- };
7772
- const isEventFilter = (value) => {
7773
- if (!isRecord$2(value)) return false;
7774
- if (value.kind === "all") return hasOnlyKeys(value, ["kind"]);
7775
- if (value.kind === "installationMovement") return isIdentity(value.installId) && hasOnlyKeys(value, ["kind", "installId"]);
7776
- return value.kind === "bundle" && isBundleFilter(value, true);
7777
- };
7778
- const validateRow = (model, row, result = false) => {
7779
- try {
7780
- validateBundleEventFields(row);
7781
- if (!isRecord$2(row) || typeof row.id !== "string" || !isUUIDv7(row.id) || [...Object.values(row), ...isRecord$2(row.metadata) ? Object.values(row.metadata) : []].some((value) => typeof value === "string" && !isWellFormedText(value))) throw new DatabaseAdapterInputError("invalid-data");
7782
- } catch (error) {
7783
- if (result) throw new DatabaseAdapterInputError("invalid-result");
7784
- throw error;
7785
- }
7786
- };
7787
- /** Throws `invalid-data` unless `row` is a complete, well-formed bundle event row. */
7788
- const assertBundleEventRow = (row) => validateRow("bundle_events", row);
7789
- /** Downloads, applies, recoveries, and failed updates belong in installation history. */
7790
- const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED";
7791
- const matchesInsightsEventFilter = (event, filter) => {
7792
- if (filter.kind === "all") return true;
7793
- if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
7794
- 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");
7795
- };
7796
- const invalidQuery = () => {
7797
- throw new DatabaseAdapterInputError("invalid-query");
7798
- };
7799
- const invalidResult = () => {
7800
- throw new DatabaseAdapterInputError("invalid-result");
7801
- };
7802
- const validateCount = (count) => isTimestamp(count) ? count : invalidResult();
7803
- const isTimeRange = (value) => isRecord$2(value) && hasOnlyKeys(value, ["start", "end"]) && isTimestamp(value.start) && isTimestamp(value.end) && value.start < value.end;
7804
- const isReleaseReference = (value) => isRecord$2(value) && hasOnlyKeys(value, [
7805
- "releaseId",
7806
- "platform",
7807
- "channel"
7808
- ]) && isIdentity(value.releaseId) && hasScope(value);
7809
- const validateAggregateResult = (value) => {
7810
- const result = isRecord$2(value) ? value : invalidResult();
7811
- if (!isTimestamp(result.measuredAtMs)) invalidResult();
7812
- const coverage = result.coverage;
7813
- if (!isRecord$2(coverage) || coverage.kind !== "complete" && coverage.kind !== "partial" || coverage.sinceMs !== null && !isTimestamp(coverage.sinceMs)) invalidResult();
7814
- };
7815
- /** Validate custom and bundled providers at the same public boundary. */
7816
- const createValidatedInsightsModel = (model) => ({
7817
- async recordEvent(input) {
7818
- if (!isRecord$2(input) || !hasOnlyKeys(input, ["event"])) throw new DatabaseAdapterInputError("invalid-data");
7819
- validateRow("bundle_events", input.event);
7820
- await model.recordEvent(input);
7743
+ const bundleEvents = (days) => defineTable({
7744
+ id: {
7745
+ type: "string",
7746
+ maxLength: 36
7821
7747
  },
7822
- async listEvents(input) {
7823
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
7824
- "filter",
7825
- "sinceMs",
7826
- "beforeReceivedAtMs",
7827
- "after",
7828
- "limit"
7829
- ]) || !isEventFilter(input.filter) || !isLimit(input.limit) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs !== void 0 && !isTimestamp(input.sinceMs)) invalidQuery();
7830
- const sinceMs = input.sinceMs ?? 0;
7831
- if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!isRecord$2(input.after) || !hasOnlyKeys(input.after, ["receivedAtMs", "id"]) || !isTimestamp(input.after.receivedAtMs) || typeof input.after.id !== "string" || !isUUIDv7(input.after.id) || input.after.receivedAtMs < sinceMs || input.after.receivedAtMs >= input.beforeReceivedAtMs)) invalidQuery();
7832
- const rows = await model.listEvents(input);
7833
- if (!Array.isArray(rows) || rows.length > input.limit) invalidResult();
7834
- let previous = input.after;
7835
- for (const row of rows) {
7836
- validateRow("bundle_events", row, true);
7837
- if (row.received_at_ms < sinceMs || row.received_at_ms >= input.beforeReceivedAtMs || !matchesInsightsEventFilter(row, input.filter) || previous !== void 0 && (row.received_at_ms > previous.receivedAtMs || row.received_at_ms === previous.receivedAtMs && row.id >= previous.id)) invalidResult();
7838
- previous = {
7839
- receivedAtMs: row.received_at_ms,
7840
- id: row.id
7841
- };
7842
- }
7843
- return rows;
7748
+ type: {
7749
+ type: "string",
7750
+ maxLength: 32
7844
7751
  },
7845
- async findLatestEvents(input) {
7846
- if (!isRecord$2(input)) invalidQuery();
7847
- if ("installId" in input) {
7848
- if (!hasOnlyKeys(input, ["installId"]) || !isIdentity(input.installId)) invalidQuery();
7849
- } else if (!hasOnlyKeys(input, [
7850
- "userId",
7851
- "afterInstallId",
7852
- "limit"
7853
- ]) || !isIdentity(input.userId) || !isLimit(input.limit) || input.afterInstallId !== void 0 && !isIdentity(input.afterInstallId)) invalidQuery();
7854
- const rows = await model.findLatestEvents(input);
7855
- if (!Array.isArray(rows) || rows.length > ("installId" in input ? 1 : input.limit)) invalidResult();
7856
- let previous = "installId" in input ? void 0 : input.afterInstallId;
7857
- for (const row of rows) {
7858
- validateRow("bundle_events", row, true);
7859
- if ("installId" in input ? row.install_id !== input.installId : row.user_id !== input.userId || previous !== void 0 && compareInsightsText(row.install_id, previous) <= 0) invalidResult();
7860
- previous = row.install_id;
7861
- }
7862
- return rows;
7752
+ install_id: {
7753
+ type: "string",
7754
+ maxLength: 255
7863
7755
  },
7864
- async countLatestEvents(input) {
7865
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
7866
- "platform",
7867
- "channel",
7868
- "sinceMs",
7869
- "bundle"
7870
- ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundle !== void 0 && (!Array.isArray(input.bundle) || input.bundle.length < 1 || input.bundle.length > 2 || input.bundle.some((bundle) => !isRecord$2(bundle) || !hasOnlyKeys(bundle, [
7871
- "field",
7872
- "value",
7873
- "types"
7874
- ]) || bundle.field !== "from_bundle_id" && bundle.field !== "to_bundle_id" || !isText$1(bundle.value) || !Array.isArray(bundle.types) || bundle.types.length === 0 || bundle.types.some((type) => ![
7875
- "UNCHANGED",
7876
- "UPDATE_DOWNLOADED",
7877
- "UPDATE_APPLIED",
7878
- "RECOVERED"
7879
- ].includes(type))))) invalidQuery();
7880
- return validateCount(await model.countLatestEvents(input));
7756
+ user_id: {
7757
+ type: "string",
7758
+ maxLength: 255,
7759
+ required: false
7881
7760
  },
7882
- async countEvents(input) {
7883
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
7884
- "filter",
7885
- "sinceMs",
7886
- "beforeReceivedAtMs"
7887
- ]) || !isBundleFilter(input.filter) || !isTimestamp(input.sinceMs) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs > input.beforeReceivedAtMs) invalidQuery();
7888
- return validateCount(await model.countEvents(input));
7761
+ from_release_id: {
7762
+ type: "string",
7763
+ maxLength: 36,
7764
+ required: false
7889
7765
  },
7890
- async countEventSeries(input) {
7891
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
7892
- "filter",
7893
- "timeRange",
7894
- "intervalMs"
7895
- ]) || !isBundleFilter(input.filter) || !isTimeRange(input.timeRange) || !isTimestamp(input.intervalMs)) invalidQuery();
7896
- const { start, end } = input.timeRange;
7897
- const { intervalMs } = input;
7898
- if (intervalMs === 0 || intervalMs % 36e5 !== 0 || start % 36e5 !== 0 || (end - start) % intervalMs !== 0 || end - start > 7776e6) invalidQuery();
7899
- const points = await model.countEventSeries(input);
7900
- if (!Array.isArray(points) || points.length !== (end - start) / intervalMs || points.some((point, index) => !isRecord$2(point) || !hasOnlyKeys(point, ["startMs", "events"]) || point.startMs !== start + index * intervalMs || !isTimestamp(point.events))) invalidResult();
7901
- return points;
7766
+ from_bundle_id: {
7767
+ type: "string",
7768
+ maxLength: 36,
7769
+ required: false
7902
7770
  },
7903
- async getReleaseActivity(input) {
7904
- if (!isRecord$2(input) || !hasOnlyKeys(input, ["releases"]) || !Array.isArray(input.releases) || input.releases.length < 1 || input.releases.length > 100 || !input.releases.every(isReleaseReference)) invalidQuery();
7905
- const result = await model.getReleaseActivity(input);
7906
- if (!isRecord$2(result) || !isTimestamp(result.measuredAtMs) || !Array.isArray(result.data) || result.data.some((item) => !isRecord$2(item) || !isReleaseReference(item.release) || !isRecord$2(item.metrics) || !isTimestamp(item.metrics.downloads) || !isTimestamp(item.metrics.applies) || !isTimestamp(item.metrics.failedLaunches))) invalidResult();
7907
- return result;
7771
+ to_release_id: {
7772
+ type: "string",
7773
+ maxLength: 36,
7774
+ required: false
7908
7775
  },
7909
- async getAppUsage(input) {
7910
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
7911
- "channel",
7912
- "platform",
7913
- "appVersion",
7914
- "timeRange",
7915
- "intervalMs"
7916
- ]) || !isText$1(input.channel) || ![
7917
- "all",
7918
- "ios",
7919
- "android"
7920
- ].includes(input.platform) || input.appVersion !== void 0 && !isText$1(input.appVersion) || !isTimeRange(input.timeRange) || !isTimestamp(input.intervalMs) || input.intervalMs < 36e5) invalidQuery();
7921
- const result = await model.getAppUsage(input);
7922
- validateAggregateResult(result);
7923
- if (!isTimestamp(result.activeInstallations) || !Array.isArray(result.points) || !Array.isArray(result.appVersions) || !Array.isArray(result.versions) || !Array.isArray(result.platforms) || !Array.isArray(result.bundleDistribution)) invalidResult();
7924
- return result;
7776
+ to_bundle_id: {
7777
+ type: "string",
7778
+ maxLength: 36
7779
+ },
7780
+ platform: {
7781
+ type: "string",
7782
+ maxLength: 16
7783
+ },
7784
+ app_version: { type: "string" },
7785
+ channel: { type: "string" },
7786
+ metadata: { type: "json" },
7787
+ received_at_ms: { type: "integer" }
7788
+ }, {
7789
+ key: ["id"],
7790
+ derived: {
7791
+ /** The UTC day the event was received in. */
7792
+ day: {
7793
+ type: "integer",
7794
+ compute: (row) => row.received_at_ms - row.received_at_ms % DAY_MS$1
7795
+ },
7796
+ /** Every stored event belongs in installation history. */
7797
+ movement_install_id: {
7798
+ type: "string",
7799
+ compute: (row) => MOVEMENTS.has(row.type) ? row.install_id : null
7800
+ },
7801
+ /** The bundle a bundle filter matches (`bundleRefsOf`). */
7802
+ bundle_ref: {
7803
+ type: "string",
7804
+ multi: true,
7805
+ compute: (row) => bundleRefsOf(row)
7806
+ }
7807
+ },
7808
+ indexes: {
7809
+ movementsByInstall: {
7810
+ eq: ["movement_install_id"],
7811
+ sort: ["received_at_ms"]
7812
+ },
7813
+ byBundle: {
7814
+ eq: [
7815
+ "platform",
7816
+ "channel",
7817
+ "type",
7818
+ "bundle_ref",
7819
+ "day"
7820
+ ],
7821
+ sort: ["received_at_ms"]
7822
+ },
7823
+ /** Events of every scope by day, for the console's unfiltered event list. */
7824
+ byDay: {
7825
+ eq: ["day"],
7826
+ sort: ["received_at_ms"]
7827
+ }
7828
+ },
7829
+ retention: {
7830
+ field: "day",
7831
+ days
7925
7832
  }
7926
7833
  });
7927
- //#endregion
7928
- //#region ../insights/dist/server/model.mjs
7929
7834
  /**
7930
- * The plugin's API as `InsightsModel`, validated at its boundary: what the
7931
- * Insights routes, the console, and the e2e harness read through.
7835
+ * Each installation's latest event, whole, so reading it is one point read.
7836
+ * Its gauges count it in the UTC day of that event. An update failure moves
7837
+ * nothing, so no head holds one.
7932
7838
  */
7933
- const createInsightsModel = (api) => createValidatedInsightsModel({
7934
- recordEvent: ({ event }) => api.recordEvent(event),
7935
- listEvents: api.listEvents,
7936
- findLatestEvents: api.findLatestEvents,
7937
- countLatestEvents: api.countLatestEvents,
7938
- countEvents: api.countEvents,
7939
- countEventSeries: api.countEventSeries,
7940
- getReleaseActivity: api.getReleaseActivity,
7941
- getAppUsage: api.getAppUsage
7839
+ const bundleEventHeads = (days) => defineTable({
7840
+ install_id: {
7841
+ type: "string",
7842
+ maxLength: 255
7843
+ },
7844
+ id: {
7845
+ type: "string",
7846
+ maxLength: 36
7847
+ },
7848
+ type: {
7849
+ type: "string",
7850
+ maxLength: 32
7851
+ },
7852
+ user_id: {
7853
+ type: "string",
7854
+ maxLength: 255,
7855
+ required: false
7856
+ },
7857
+ from_release_id: {
7858
+ type: "string",
7859
+ maxLength: 36,
7860
+ required: false
7861
+ },
7862
+ from_bundle_id: {
7863
+ type: "string",
7864
+ maxLength: 36,
7865
+ required: false
7866
+ },
7867
+ to_release_id: {
7868
+ type: "string",
7869
+ maxLength: 36,
7870
+ required: false
7871
+ },
7872
+ to_bundle_id: {
7873
+ type: "string",
7874
+ maxLength: 36
7875
+ },
7876
+ platform: {
7877
+ type: "string",
7878
+ maxLength: 16
7879
+ },
7880
+ app_version: { type: "string" },
7881
+ channel: { type: "string" },
7882
+ metadata: { type: "json" },
7883
+ received_at_ms: { type: "integer" }
7884
+ }, {
7885
+ key: ["install_id"],
7886
+ indexes: { byUser: {
7887
+ eq: ["user_id"],
7888
+ sort: ["install_id"]
7889
+ } },
7890
+ retention: {
7891
+ field: "received_at_ms",
7892
+ days
7893
+ }
7942
7894
  });
7943
- //#endregion
7944
- //#region ../insights/dist/server/official.mjs
7945
- /**
7946
- * The brand of Hot Updater's own plugins, a registered symbol that
7947
- * `@hot-updater/server` reads to reserve the id "insights" for this plugin.
7948
- * Each official plugin package keeps this copy, so none imports another's
7949
- * internals for it.
7950
- */
7951
- const OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
7952
- /** Marks a plugin one of Hot Updater's own factories made. */
7953
- const markOfficial = (plugin) => Object.defineProperty(plugin, OFFICIAL, { value: true });
7954
- //#endregion
7955
- //#region ../insights/dist/server/errors.mjs
7956
7895
  /**
7957
- * What Insights' API throws for an input it refuses, such as an unknown
7958
- * event field or a malformed cursor; its routes answer 400 for it.
7896
+ * Shards are keyed by install id. Counters are blind increments and never
7897
+ * conflict, so they keep 8. Gauges and sketches are read, merged, and written
7898
+ * back. B3's rollout gate on PostgreSQL retried 2.4% of transactions at 8
7899
+ * shards and 1.3% at 16. DynamoDB's transactions take longer, so D8's gate
7900
+ * on DynamoDB Local retried 2–24% at 16, almost all on gauge rows, and 1–5%
7901
+ * with gauges at 32. Sketches stay at 16: each shard row carries 2 KB of
7902
+ * registers that every read merges.
7903
+ *
7904
+ * Every Insights aggregate is `batched`: on DynamoDB and Firestore, which
7905
+ * bill each write, an event's changes apply after it commits, merged with
7906
+ * other events' into one write per row, on one shard per writer.
7959
7907
  */
7960
- var InsightsBadRequestError = class extends Error {
7961
- name = "InsightsBadRequestError";
7908
+ const COUNTER_SHARDS = 8;
7909
+ const GAUGE_SHARDS = 32;
7910
+ const SKETCH_SHARDS = 16;
7911
+ /** Rows keyed by a hashed identity (scope and period) and a bucket. */
7912
+ const identityFields = {
7913
+ identity: {
7914
+ type: "string",
7915
+ maxLength: 32
7916
+ },
7917
+ bucket_start_ms: { type: "integer" }
7962
7918
  };
7963
- var InsightsPayloadTooLargeError = class extends Error {
7964
- maximumBytes;
7965
- name = "InsightsPayloadTooLargeError";
7966
- constructor(maximumBytes) {
7967
- super(`Event payload exceeds ${maximumBytes} bytes`);
7968
- this.maximumBytes = maximumBytes;
7969
- }
7919
+ const window = {
7920
+ eq: ["identity"],
7921
+ sort: ["bucket_start_ms"]
7970
7922
  };
7971
- //#endregion
7972
- //#region ../insights/dist/server/eventInput.mjs
7973
- const MAX_EVENT_STRING_LENGTH = 1024;
7974
- const MAX_IDENTITY_LENGTH$2 = 255;
7975
- const EVENT_BODY_MAX_BYTES = 16384;
7976
- function isRecord$1(value) {
7977
- return typeof value === "object" && value !== null && !Array.isArray(value);
7978
- }
7979
- function requireStringField(payload, key) {
7980
- const value = payload[key];
7981
- if (typeof value !== "string" || value.length === 0 || value.length > MAX_EVENT_STRING_LENGTH || new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(value)) !== value) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
7982
- return value;
7983
- }
7984
- function requireNullableStringField(payload, key) {
7985
- if (payload[key] === null) return null;
7986
- return requireStringField(payload, key);
7987
- }
7988
- function requireIdentityField(payload, key) {
7989
- const value = requireStringField(payload, key);
7990
- if (value.length > MAX_IDENTITY_LENGTH$2) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
7991
- return value;
7992
- }
7923
+ /** What a release or channel row counts in every period kind. */
7924
+ const WINDOW_COUNTERS = [
7925
+ "downloads",
7926
+ "failed_launches",
7927
+ "patch_downloads",
7928
+ "patch_fallbacks"
7929
+ ];
7993
7930
  /**
7994
- * The report's idempotency key, when the client sends one: it creates the
7995
- * UUIDv7 once and repeats it on every retry of that report.
7931
+ * Hourly or daily counters: a release or channel row counts downloads,
7932
+ * launches that crashed and recovered (`failed_launches`), and downloads a
7933
+ * patch delivered and those that fell back from one.
7996
7934
  */
7997
- function readEventId(payload) {
7998
- const value = payload.eventId;
7999
- if (value === void 0) return void 0;
8000
- if (!isUUIDv7(value)) throw new InsightsBadRequestError("Invalid event field: eventId");
8001
- return value;
8002
- }
7935
+ const counters = (retention) => defineAggregate(identityFields, {
7936
+ key: ["identity", "bucket_start_ms"],
7937
+ counters: WINDOW_COUNTERS,
7938
+ shards: COUNTER_SHARDS,
7939
+ batched: true,
7940
+ indexes: { window },
7941
+ retention
7942
+ });
8003
7943
  /**
8004
- * The native build's built-in bundle ID: a bundle ID's length at most, and
8005
- * left out when it is missing or malformed, so the report still records.
7944
+ * A release's counters since its first report, at bucket 0: the windowed
7945
+ * counters, its applies, which the bundle list reads, and its failed updates
7946
+ * (`failed_updates`), since no breakdown row outlives the raw period; windowed
7947
+ * reads sum `insights_failures` instead.
8006
7948
  */
8007
- const readMinBundleId = (value) => typeof value === "string" && value.length > 0 && value.length <= 36 ? value : null;
8008
- /** One value of an open set: any other value reads as `unknown`. */
8009
- const oneOf = (values, value) => values.includes(value) ? value : "unknown";
8010
- const STAGES = [
8011
- "check",
8012
- "download",
8013
- "install"
8014
- ];
8015
- const REASONS = [
8016
- "network",
8017
- "http",
8018
- "invalid_response",
8019
- "hash_mismatch",
8020
- "signature",
8021
- "patch",
8022
- "extract",
8023
- "storage"
8024
- ];
8025
- const RESOURCES = [
8026
- "catalog",
8027
- "artifact",
8028
- "manifest",
8029
- "file",
8030
- "patch",
8031
- "archive"
8032
- ];
8033
- const TRANSPORTS = [
8034
- "timeout",
8035
- "dns",
8036
- "tls",
8037
- "connection",
8038
- "offline",
8039
- "cancelled"
8040
- ];
8041
- const DELIVERIES = [
8042
- "patch",
8043
- "manifest",
8044
- "archive"
8045
- ];
8046
- /** A storage error code: letters, digits, and `._-`. */
8047
- const CODE = /^[A-Za-z0-9._-]{1,64}$/;
8048
- const readCode = (value) => typeof value === "string" && CODE.test(value) ? value : void 0;
8049
- const readMetadata = (payload) => isRecord$1(payload.metadata) ? payload.metadata : {};
7949
+ const lifetimeCounters = () => defineAggregate(identityFields, {
7950
+ key: ["identity", "bucket_start_ms"],
7951
+ counters: [
7952
+ ...WINDOW_COUNTERS,
7953
+ "applies",
7954
+ "failed_updates"
7955
+ ],
7956
+ shards: COUNTER_SHARDS,
7957
+ batched: true,
7958
+ indexes: { window }
7959
+ });
8050
7960
  /**
8051
- * What an UPDATE_FAILED report says failed. It never refuses the report: an
8052
- * unknown value of a set reads as `unknown`, a missing or malformed failure
8053
- * as an unknown stage and reason, and a malformed optional field is left out,
8054
- * as is a null one.
7961
+ * Unique-installation sketches of one period kind: a usage row's active
7962
+ * installations (`activity_users`), and a `failure` or `check` row's
7963
+ * installations whose update or update check failed (`failed_users`).
8055
7964
  */
8056
- function readFailure(value) {
8057
- const failure = isRecord$1(value) ? value : {};
8058
- const httpStatus = failure.httpStatus;
8059
- const originCode = readCode(failure.originCode);
8060
- const errorMessage = typeof failure.errorMessage === "string" && failure.errorMessage.length <= 2048 ? failure.errorMessage : void 0;
8061
- const errorStack = typeof failure.errorStack === "string" && failure.errorStack.length <= 4096 ? failure.errorStack : void 0;
8062
- return {
8063
- stage: oneOf(STAGES, failure.stage),
8064
- reason: oneOf(REASONS, failure.reason),
8065
- ...failure.resource == null ? {} : { resource: oneOf(RESOURCES, failure.resource) },
8066
- ...Number.isSafeInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {},
8067
- ...failure.transport == null ? {} : { transport: oneOf(TRANSPORTS, failure.transport) },
8068
- ...originCode === void 0 ? {} : { originCode },
8069
- ...errorMessage === void 0 ? {} : { errorMessage },
8070
- ...errorStack === void 0 ? {} : { errorStack }
8071
- };
8072
- }
8073
- function readHttpResponse(value) {
8074
- if (!isRecord$1(value) || value.resource !== "catalog" && value.resource !== "artifact" || typeof value.path !== "string" || value.path.length > 1024 || !Number.isSafeInteger(value.status) || value.status < 100 || value.status > 599 || value.body !== null && (typeof value.body !== "string" || new TextEncoder().encode(JSON.stringify(value.body)).byteLength > 4096) || typeof value.bodyTruncated !== "boolean" || !Number.isSafeInteger(value.receivedAtMs) || value.receivedAtMs < 0) throw new InsightsBadRequestError("Invalid HTTP response details");
8075
- return {
8076
- resource: value.resource,
8077
- path: value.path,
8078
- status: value.status,
8079
- body: value.body,
8080
- bodyTruncated: value.bodyTruncated,
8081
- receivedAtMs: value.receivedAtMs
8082
- };
8083
- }
8084
- async function readBoundedText(request) {
8085
- const contentLength = request.headers.get("content-length");
8086
- const declaredByteLength = Number(contentLength);
8087
- if (contentLength !== null && Number.isSafeInteger(declaredByteLength) && declaredByteLength > 16384) throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
8088
- if (request.body === null) return "";
8089
- const reader = request.body.getReader();
8090
- const decoder = new TextDecoder();
8091
- let byteLength = 0;
8092
- let text = "";
8093
- while (true) {
8094
- const result = await reader.read();
8095
- if (result.done) break;
8096
- byteLength += result.value.byteLength;
8097
- if (byteLength > 16384) {
8098
- await reader.cancel();
8099
- throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
8100
- }
8101
- text += decoder.decode(result.value, { stream: true });
8102
- }
8103
- return text + decoder.decode();
8104
- }
8105
- async function parseJson(request) {
8106
- const text = await readBoundedText(request);
8107
- try {
8108
- return JSON.parse(text);
8109
- } catch (error) {
8110
- if (error instanceof SyntaxError) throw new InsightsBadRequestError("Invalid event payload");
8111
- throw error;
7965
+ const sketches = (retention) => defineAggregate(identityFields, {
7966
+ key: ["identity", "bucket_start_ms"],
7967
+ distinct: ["activity_users", "failed_users"],
7968
+ shards: SKETCH_SHARDS,
7969
+ batched: true,
7970
+ indexes: { window },
7971
+ retention
7972
+ });
7973
+ /** Installations whose update to a release failed, since its first failure: kept, at bucket 0. */
7974
+ const lifetimeSketches = () => defineAggregate(identityFields, {
7975
+ key: ["identity", "bucket_start_ms"],
7976
+ distinct: ["failed_users"],
7977
+ shards: SKETCH_SHARDS,
7978
+ batched: true,
7979
+ indexes: { window }
7980
+ });
7981
+ /**
7982
+ * Update failures by what failed, a row an hour. `release_id` is the failed
7983
+ * update's target release, or empty for a failed check or an unknown target.
7984
+ * `stage` and `reason` say where and why it failed, and `detail` what else
7985
+ * the client knew, as the JSON array `[resource, httpStatus, originCode,
7986
+ * transport]`. Every index is in key order, so its counters stay blind
7987
+ * increments.
7988
+ */
7989
+ const insightsFailures = (retention) => defineAggregate({
7990
+ platform: {
7991
+ type: "string",
7992
+ maxLength: 16
7993
+ },
7994
+ channel: { type: "string" },
7995
+ bucket_start_ms: { type: "integer" },
7996
+ release_id: {
7997
+ type: "string",
7998
+ maxLength: 36
7999
+ },
8000
+ stage: {
8001
+ type: "string",
8002
+ maxLength: 16
8003
+ },
8004
+ reason: {
8005
+ type: "string",
8006
+ maxLength: 64
8007
+ },
8008
+ detail: {
8009
+ type: "string",
8010
+ maxLength: 160
8112
8011
  }
8113
- }
8114
- function requireEvent(payload) {
8115
- if (!isRecord$1(payload)) throw new InsightsBadRequestError("Invalid event payload");
8116
- const platform = requireStringField(payload, "platform");
8117
- if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid event field: platform");
8118
- const eventId = readEventId(payload);
8119
- const metadata = readMetadata(payload);
8120
- const httpMetadata = metadata.httpResponse === void 0 ? {} : { httpResponse: readHttpResponse(metadata.httpResponse) };
8121
- const base = {
8122
- ...metadata.httpResponse === void 0 ? {} : { metadata: httpMetadata },
8123
- ...eventId === void 0 ? {} : { eventId },
8124
- installId: requireIdentityField(payload, "installId"),
8125
- toBundleId: requireStringField(payload, "toBundleId"),
8126
- ...payload.userId === void 0 ? {} : { userId: requireIdentityField(payload, "userId") },
8127
- platform,
8128
- appVersion: requireStringField(payload, "appVersion"),
8129
- channel: requireStringField(payload, "channel"),
8130
- cohort: requireStringField(payload, "cohort"),
8131
- fingerprintHash: requireNullableStringField(payload, "fingerprintHash"),
8132
- sdkVersion: payload.sdkVersion === void 0 ? null : requireNullableStringField(payload, "sdkVersion"),
8133
- minBundleId: readMinBundleId(payload.minBundleId),
8134
- fromReleaseId: requireNullableStringField(payload, "fromReleaseId"),
8135
- toReleaseId: requireNullableStringField(payload, "toReleaseId")
8136
- };
8137
- const type = requireStringField(payload, "type");
8138
- const movement = () => {
8139
- const updateStrategy = requireStringField(payload, "updateStrategy");
8140
- if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") throw new InsightsBadRequestError("Invalid event field: updateStrategy");
8141
- return {
8142
- ...base,
8143
- fromBundleId: requireStringField(payload, "fromBundleId"),
8144
- updateStrategy
8145
- };
8146
- };
8147
- switch (type) {
8148
- case "UPDATE_DOWNLOADED": {
8149
- const delivery = metadata.delivery;
8150
- const read = {
8151
- ...httpMetadata,
8152
- ...delivery == null ? {} : { delivery: oneOf(DELIVERIES, delivery) },
8153
- ...metadata.patchFallback === true ? { patchFallback: true } : {}
8154
- };
8155
- return {
8156
- ...movement(),
8157
- type,
8158
- ...Object.keys(read).length === 0 ? {} : { metadata: read }
8159
- };
8012
+ }, {
8013
+ key: [
8014
+ "platform",
8015
+ "channel",
8016
+ "bucket_start_ms",
8017
+ "release_id",
8018
+ "stage",
8019
+ "reason",
8020
+ "detail"
8021
+ ],
8022
+ counters: ["events"],
8023
+ shards: COUNTER_SHARDS,
8024
+ batched: true,
8025
+ indexes: { byScope: {
8026
+ eq: ["platform", "channel"],
8027
+ sort: ["bucket_start_ms"]
8028
+ } },
8029
+ retention
8030
+ });
8031
+ /** Installations counted in the UTC day of their latest event, so reads sum whole UTC days. */
8032
+ const insightsDistribution = (retention) => defineAggregate({
8033
+ channel: { type: "string" },
8034
+ platform: {
8035
+ type: "string",
8036
+ maxLength: 16
8037
+ },
8038
+ app_version: { type: "string" },
8039
+ release_id: {
8040
+ type: "string",
8041
+ maxLength: 36
8042
+ },
8043
+ bucket_start_ms: { type: "integer" }
8044
+ }, {
8045
+ key: [
8046
+ "channel",
8047
+ "platform",
8048
+ "app_version",
8049
+ "release_id",
8050
+ "bucket_start_ms"
8051
+ ],
8052
+ gauges: ["latest_installations"],
8053
+ shards: GAUGE_SHARDS,
8054
+ batched: true,
8055
+ indexes: {
8056
+ byScope: {
8057
+ eq: ["channel", "platform"],
8058
+ sort: ["bucket_start_ms"]
8059
+ },
8060
+ byVersion: {
8061
+ eq: [
8062
+ "channel",
8063
+ "platform",
8064
+ "app_version"
8065
+ ],
8066
+ sort: ["bucket_start_ms"]
8160
8067
  }
8161
- case "UPDATE_APPLIED":
8162
- case "RECOVERED": return {
8163
- ...movement(),
8164
- type
8165
- };
8166
- case "UPDATE_FAILED": return {
8167
- ...movement(),
8168
- type,
8169
- metadata: {
8170
- ...httpMetadata,
8171
- failure: readFailure(metadata.failure)
8172
- }
8173
- };
8174
- case "UNCHANGED":
8175
- if (payload.fromBundleId !== null || payload.updateStrategy !== null) throw new InsightsBadRequestError("Invalid unchanged event shape");
8176
- return {
8177
- ...base,
8178
- type,
8179
- fromBundleId: null,
8180
- updateStrategy: null
8181
- };
8182
- default: throw new InsightsBadRequestError("Invalid event field: type");
8183
- }
8184
- }
8185
- async function parseBundleEventRequest(request) {
8186
- return requireEvent(await parseJson(request));
8187
- }
8188
- function createBundleEventRow(input) {
8189
- input = requireEvent(input);
8190
- const base = {
8191
- app_version: input.appVersion,
8192
- channel: input.channel,
8193
- from_release_id: input.fromReleaseId,
8194
- id: input.eventId ?? createUUIDv7(),
8195
- install_id: input.installId,
8196
- platform: input.platform,
8197
- received_at_ms: Date.now(),
8198
- to_bundle_id: input.toBundleId,
8199
- to_release_id: input.toReleaseId,
8200
- user_id: input.userId ?? null
8201
- };
8202
- const response = input.metadata?.httpResponse;
8203
- const metadata = {
8204
- ...response ? { http_response: {
8205
- resource: response.resource,
8206
- path: response.path,
8207
- status: response.status,
8208
- body: response.body,
8209
- body_truncated: response.bodyTruncated,
8210
- received_at_ms: response.receivedAtMs
8211
- } } : {},
8212
- cohort: input.cohort,
8213
- fingerprint_hash: input.fingerprintHash,
8214
- sdk_version: input.sdkVersion ?? null,
8215
- ...input.minBundleId ? { min_bundle_id: input.minBundleId } : {},
8216
- update_strategy: input.updateStrategy
8217
- };
8218
- switch (input.type) {
8219
- case "UPDATE_DOWNLOADED": {
8220
- const { delivery, patchFallback } = input.metadata ?? {};
8221
- return {
8222
- ...base,
8223
- from_bundle_id: input.fromBundleId,
8224
- type: input.type,
8225
- metadata: {
8226
- ...metadata,
8227
- ...delivery === void 0 ? {} : { delivery },
8228
- ...patchFallback ? { patch_fallback: true } : {}
8229
- }
8230
- };
8231
- }
8232
- case "UPDATE_APPLIED":
8233
- case "RECOVERED": return {
8234
- ...base,
8235
- from_bundle_id: input.fromBundleId,
8236
- type: input.type,
8237
- metadata
8238
- };
8239
- case "UPDATE_FAILED": {
8240
- const { failure } = input.metadata;
8241
- return {
8242
- ...base,
8243
- from_bundle_id: input.fromBundleId,
8244
- type: input.type,
8245
- metadata: {
8246
- ...metadata,
8247
- failure: {
8248
- stage: failure.stage,
8249
- reason: failure.reason,
8250
- ...failure.errorMessage === void 0 ? {} : { error_message: failure.errorMessage },
8251
- ...failure.errorStack === void 0 ? {} : { error_stack: failure.errorStack },
8252
- ...failure.resource === void 0 ? {} : { resource: failure.resource },
8253
- ...failure.httpStatus === void 0 ? {} : { http_status: failure.httpStatus },
8254
- ...failure.transport === void 0 ? {} : { transport: failure.transport },
8255
- ...failure.originCode === void 0 ? {} : { origin_code: failure.originCode }
8256
- }
8257
- }
8258
- };
8259
- }
8260
- case "UNCHANGED": return {
8261
- ...base,
8262
- from_bundle_id: null,
8263
- type: input.type,
8264
- metadata
8265
- };
8266
- }
8267
- }
8268
- //#endregion
8269
- //#region ../insights/dist/server/provider.mjs
8270
- const DEFAULT_PAGE_LIMIT = 50;
8271
- const MAX_PAGE_LIMIT$1 = 100;
8272
- const MAX_EVENT_ID_LENGTH = 1024;
8273
- const MAX_IDENTITY_LENGTH$1 = 255;
8274
- const MAX_CURSOR_LENGTH$1 = 8192;
8275
- const HOUR_MS$1 = 36e5;
8276
- const DAY_MS$1 = 24 * HOUR_MS$1;
8277
- const WINDOW_MS = {
8278
- "24h": 864e5,
8279
- "7d": 6048e5,
8280
- "30d": 2592e6
8281
- };
8282
- /**
8283
- * The longest receipt range, `[sinceMs, beforeReceivedAtMs)`, of a global or
8284
- * bundle event list, and the range one covers without `sinceMs`: raw events
8285
- * are listed inside a time range, as analytics products list them. It is a
8286
- * duration, so a range that ends mid-day touches 91 UTC days. Installation
8287
- * history is one index range and reads back to `sinceMs`.
8288
- */
8289
- const EVENT_LIST_RANGE_MS = 2160 * HOUR_MS$1;
8290
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8291
- const requireString = (value, label, maximumLength) => {
8292
- if (typeof value !== "string" || value.length === 0 || value.length > maximumLength || new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(value)) !== value) throw new InsightsBadRequestError(`Invalid ${label}.`);
8293
- return value;
8294
- };
8295
- const requireTimestamp = (value, label) => {
8296
- if (!Number.isSafeInteger(value) || Number(value) < 0) throw new InsightsBadRequestError(`Invalid ${label}.`);
8297
- return Number(value);
8298
- };
8299
- const readLimit = (value) => {
8300
- const limit = value ?? DEFAULT_PAGE_LIMIT;
8301
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT$1) throw new InsightsBadRequestError("Invalid page limit.");
8302
- return limit;
8303
- };
8304
- const encodeBase64Url = (value) => {
8305
- const bytes = new TextEncoder().encode(value);
8306
- let binary = "";
8307
- for (const byte of bytes) binary += String.fromCharCode(byte);
8308
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
8309
- };
8310
- const decodeBase64Url = (value) => {
8311
- if (value.length === 0 || value.length > MAX_CURSOR_LENGTH$1) throw new InsightsBadRequestError("Invalid Insights cursor.");
8312
- try {
8313
- const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
8314
- const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
8315
- const binary = atob(padded);
8316
- const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
8317
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
8318
- } catch {
8319
- throw new InsightsBadRequestError("Invalid Insights cursor.");
8320
- }
8321
- };
8322
- const encodeCursor = (value) => encodeBase64Url(JSON.stringify(value));
8323
- const decodeCursor = (value) => {
8324
- try {
8325
- return JSON.parse(decodeBase64Url(value));
8326
- } catch (error) {
8327
- if (error instanceof InsightsBadRequestError) throw error;
8328
- throw new InsightsBadRequestError("Invalid Insights cursor.");
8329
- }
8330
- };
8331
- const readScope$1 = (input) => {
8332
- if (input.platform !== "ios" && input.platform !== "android") throw new InsightsBadRequestError("Invalid Insights platform.");
8333
- return {
8334
- platform: input.platform,
8335
- channel: requireString(input.channel, "channel", MAX_EVENT_ID_LENGTH)
8336
- };
8337
- };
8338
- const bundleFilter = (input) => {
8339
- const scope = readScope$1(input);
8340
- const bundleId = requireString(input.bundleId, "bundle ID", MAX_EVENT_ID_LENGTH);
8341
- switch (input.outcome) {
8342
- case "downloaded": return {
8343
- ...scope,
8344
- type: "UPDATE_DOWNLOADED",
8345
- toBundleId: bundleId
8346
- };
8347
- case "applied": return {
8348
- ...scope,
8349
- type: "UPDATE_APPLIED",
8350
- toBundleId: bundleId
8351
- };
8352
- case "recovered": return {
8353
- ...scope,
8354
- type: "RECOVERED",
8355
- fromBundleId: bundleId
8356
- };
8357
- case "failed": return {
8358
- ...scope,
8359
- type: "UPDATE_FAILED",
8360
- toBundleId: bundleId
8361
- };
8362
- default: throw new InsightsBadRequestError("Invalid Insights outcome.");
8363
- }
8364
- };
8365
- const sameFilter = (left, right) => {
8366
- if (!isRecord(left) || left.kind !== right.kind) return false;
8367
- switch (right.kind) {
8368
- case "all": return true;
8369
- case "installationMovement": return left.installId === right.installId;
8370
- case "bundle": return left.platform === right.platform && left.channel === right.channel && left.type === right.type && (right.type === "RECOVERED" ? left.fromBundleId === right.fromBundleId : left.toBundleId === right.toBundleId);
8371
- }
8372
- };
8373
- const readEventCursor = (value, filter) => {
8374
- const cursor = decodeCursor(value);
8375
- if (!isRecord(cursor) || cursor.version !== 2 || cursor.kind !== "events" || !isRecord(cursor.filter) || !isRecord(cursor.after)) throw new InsightsBadRequestError("Invalid Insights cursor.");
8376
- if (!sameFilter(cursor.filter, filter)) throw new InsightsBadRequestError("Insights cursor does not match the requested events.");
8377
- if (typeof cursor.after.id !== "string" || !isUUIDv7(cursor.after.id)) throw new InsightsBadRequestError("Invalid Insights event cursor ID.");
8378
- return {
8379
- after: {
8380
- id: cursor.after.id,
8381
- receivedAtMs: requireTimestamp(cursor.after.receivedAtMs, "event cursor")
8382
- },
8383
- beforeReceivedAtMs: requireTimestamp(cursor.beforeReceivedAtMs, "event cutoff"),
8384
- kind: "events",
8385
- sinceMs: requireTimestamp(cursor.sinceMs, "event start"),
8386
- filter,
8387
- version: 2
8388
- };
8389
- };
8390
- const readUserInstallationCursor = (value, userId) => {
8391
- const cursor = decodeCursor(value);
8392
- if (!isRecord(cursor) || cursor.version !== 1 || cursor.kind !== "user-installations" || cursor.userId !== userId) throw new InsightsBadRequestError("Insights cursor does not match the requested user ID.");
8393
- return {
8394
- afterInstallId: requireString(cursor.afterInstallId, "installation cursor", MAX_IDENTITY_LENGTH$1),
8395
- kind: "user-installations",
8396
- userId,
8397
- version: 1
8398
- };
8399
- };
8400
- const compareEventNewest = (left, right) => right.received_at_ms - left.received_at_ms || compareInsightsText(right.id, left.id);
8401
- const isAfterEventCursor = (row, after) => row.received_at_ms < after.receivedAtMs || row.received_at_ms === after.receivedAtMs && compareInsightsText(row.id, after.id) < 0;
8402
- const assertEventRows = (rows, input) => {
8403
- if (rows.length > input.limit) throw new Error("Insights database returned too many event rows.");
8404
- for (let index = 0; index < rows.length; index += 1) {
8405
- const row = rows[index];
8406
- const previous = rows[index - 1];
8407
- if (!row || typeof row.id !== "string" || !isUUIDv7(row.id) || !Number.isSafeInteger(row.received_at_ms) || row.received_at_ms >= input.beforeReceivedAtMs || row.received_at_ms < input.sinceMs || previous !== void 0 && compareEventNewest(previous, row) >= 0 || input.after !== void 0 && !isAfterEventCursor(row, input.after) || input.filter.kind === "installationMovement" && (row.install_id !== input.filter.installId || !isInsightsMovementEvent(row)) || input.filter.kind === "bundle" && (row.platform !== input.filter.platform || row.channel !== input.filter.channel || row.type !== input.filter.type || (input.filter.type === "RECOVERED" ? row.from_bundle_id !== input.filter.fromBundleId : row.to_bundle_id !== input.filter.toBundleId))) throw new Error("Insights database returned invalid event rows.");
8408
- }
8409
- };
8410
- /** An `UPDATE_FAILED` row's failure, as the event lists show it. */
8411
- const toFailure = (failure) => ({
8412
- stage: failure.stage,
8413
- reason: failure.reason,
8414
- ...failure.error_message === void 0 ? {} : { errorMessage: failure.error_message },
8415
- ...failure.error_stack === void 0 ? {} : { errorStack: failure.error_stack },
8416
- ...failure.resource === void 0 ? {} : { resource: failure.resource },
8417
- ...failure.http_status === void 0 ? {} : { httpStatus: failure.http_status },
8418
- ...failure.transport === void 0 ? {} : { transport: failure.transport },
8419
- ...failure.origin_code === void 0 ? {} : { originCode: failure.origin_code }
8420
- });
8421
- /** What a row's metadata adds for its type: a failure or a delivery. */
8422
- const eventDetails = ({ type, metadata }) => ({
8423
- ...metadata.http_response !== void 0 ? { httpResponse: {
8424
- resource: metadata.http_response.resource,
8425
- path: metadata.http_response.path,
8426
- status: metadata.http_response.status,
8427
- body: metadata.http_response.body,
8428
- bodyTruncated: metadata.http_response.body_truncated,
8429
- receivedAtMs: metadata.http_response.received_at_ms
8430
- } } : {},
8431
- ...type === "UPDATE_FAILED" && metadata.failure !== void 0 ? { failure: toFailure(metadata.failure) } : {},
8432
- ...type === "UPDATE_DOWNLOADED" && metadata.delivery !== void 0 ? { delivery: metadata.delivery } : {},
8433
- ...type === "UPDATE_DOWNLOADED" && metadata.patch_fallback === true ? { patchFallback: true } : {}
8434
- });
8435
- const toEventHistoryRow = (row) => ({
8436
- ...row.to_release_id == null ? {} : { toReleaseId: row.to_release_id },
8437
- ...row.metadata.sdk_version == null ? {} : { sdkVersion: row.metadata.sdk_version },
8438
- ...row.metadata.min_bundle_id === void 0 ? {} : { minBundleId: row.metadata.min_bundle_id },
8439
- appVersion: row.app_version,
8440
- channel: row.channel,
8441
- cohort: row.metadata.cohort,
8442
- fromBundleId: row.from_bundle_id,
8443
- id: row.id,
8444
- installId: row.install_id,
8445
- platform: row.platform,
8446
- receivedAtMs: row.received_at_ms,
8447
- toBundleId: row.to_bundle_id,
8448
- type: row.type,
8449
- userId: row.user_id,
8450
- ...eventDetails(row)
8451
- });
8452
- const toInstallationRow = (row) => ({
8453
- ...eventDetails(row).httpResponse ? { httpResponse: eventDetails(row).httpResponse } : {},
8454
- ...row.metadata.min_bundle_id === void 0 ? {} : { minBundleId: row.metadata.min_bundle_id },
8455
- appVersion: row.app_version,
8456
- channel: row.channel,
8457
- cohort: row.metadata.cohort,
8458
- installId: row.install_id,
8459
- lastKnownBundleId: row.type === "UPDATE_DOWNLOADED" ? row.from_bundle_id : row.to_bundle_id,
8460
- pendingBundleId: row.type === "UPDATE_DOWNLOADED" ? row.to_bundle_id : null,
8461
- pendingReleaseId: row.type === "UPDATE_DOWNLOADED" ? row.to_release_id : null,
8462
- latestStatus: row.type,
8463
- platform: row.platform,
8464
- receivedAtMs: row.received_at_ms,
8465
- userId: row.user_id
8466
- });
8467
- /**
8468
- * Where a list without `sinceMs` starts: 90 days before its cutoff for the
8469
- * global and bundle lists, at the first event for installation history.
8470
- */
8471
- const defaultEventStart = (filter, beforeReceivedAtMs) => filter.kind === "installationMovement" ? 0 : Math.max(0, beforeReceivedAtMs - EVENT_LIST_RANGE_MS);
8472
- /** Whether a cursor's row lies inside the list's range. */
8473
- const isInsideBounds = ({ after }, bounds) => after.receivedAtMs >= bounds.sinceMs && after.receivedAtMs < bounds.beforeReceivedAtMs;
8474
- /** A page's range: its cursor's, which the request may repeat but not change. */
8475
- const readEventBounds = (input, cursor, filter) => {
8476
- const beforeReceivedAtMs = cursor?.beforeReceivedAtMs ?? (input.beforeReceivedAtMs === void 0 ? Date.now() : requireTimestamp(input.beforeReceivedAtMs, "event cutoff"));
8477
- if (cursor !== void 0 && input.beforeReceivedAtMs !== void 0 && input.beforeReceivedAtMs !== beforeReceivedAtMs) throw new InsightsBadRequestError("Insights cursor does not match the requested event cutoff.");
8478
- const sinceMs = cursor?.sinceMs ?? (input.sinceMs === void 0 ? defaultEventStart(filter, beforeReceivedAtMs) : requireTimestamp(input.sinceMs, "event start"));
8479
- const bounds = {
8480
- beforeReceivedAtMs,
8481
- sinceMs
8482
- };
8483
- if (sinceMs > beforeReceivedAtMs || input.sinceMs !== void 0 && input.sinceMs !== sinceMs || cursor !== void 0 && !isInsideBounds(cursor, bounds)) throw new InsightsBadRequestError("Insights cursor or range does not match the requested event start.");
8484
- if (filter.kind !== "installationMovement" && beforeReceivedAtMs - sinceMs > 7776e6) throw new InsightsBadRequestError("Insights event lists cover at most 90 days: send a sinceMs no more than 90 days before beforeReceivedAtMs.");
8485
- return bounds;
8486
- };
8487
- const pageEventRows = async (model, input, filter, map) => {
8488
- const limit = readLimit(input.limit);
8489
- const cursor = input.cursor === void 0 ? void 0 : readEventCursor(input.cursor, filter);
8490
- const bounds = readEventBounds(input, cursor, filter);
8491
- const query = {
8492
- filter,
8493
- ...bounds,
8494
- ...cursor === void 0 ? {} : { after: cursor.after },
8495
- limit
8496
- };
8497
- const rows = await model.listEvents(query);
8498
- assertEventRows(rows, query);
8499
- const last = rows.at(-1);
8500
- return {
8501
- beforeReceivedAtMs: bounds.beforeReceivedAtMs,
8502
- data: rows.map(map),
8503
- nextCursor: rows.length === limit && last ? encodeCursor({
8504
- after: {
8505
- id: last.id,
8506
- receivedAtMs: last.received_at_ms
8507
- },
8508
- ...bounds,
8509
- kind: "events",
8510
- filter,
8511
- version: 2
8512
- }) : null
8513
- };
8514
- };
8515
- const assertInstallationRows = (rows, input) => {
8516
- if (rows.length > input.limit) throw new Error("Insights database returned too many installation rows.");
8517
- for (let index = 0; index < rows.length; index += 1) {
8518
- const row = rows[index];
8519
- const previous = rows[index - 1];
8520
- if (!row || row.user_id !== input.userId || input.afterInstallId !== void 0 && compareInsightsText(row.install_id, input.afterInstallId) <= 0 || previous !== void 0 && compareInsightsText(previous.install_id, row.install_id) >= 0) throw new Error("Insights database returned invalid installation rows.");
8521
- }
8522
- };
8523
- const createInsightsProvider = (model) => Object.freeze({
8524
- async appendBundleEvent(input) {
8525
- const event = createBundleEventRow(input);
8526
- await model.recordEvent({ event });
8527
- },
8528
- listEvents(input) {
8529
- const filter = input.bundle === void 0 ? { kind: "all" } : {
8530
- kind: "bundle",
8531
- ...bundleFilter(input.bundle)
8532
- };
8533
- return pageEventRows(model, input, filter, toEventHistoryRow);
8534
- },
8535
- async listInstallationEvents(input) {
8536
- if ("bundle" in input && input.bundle !== void 0) throw new InsightsBadRequestError("Installation movement queries cannot include a bundle filter.");
8537
- const installId = requireString(input.installId, "install ID", MAX_IDENTITY_LENGTH$1);
8538
- return pageEventRows(model, input, {
8539
- kind: "installationMovement",
8540
- installId
8541
- }, (row) => toEventHistoryRow(row));
8542
- },
8543
- async getInstallation({ installId }) {
8544
- const normalizedInstallId = requireString(installId, "install ID", MAX_IDENTITY_LENGTH$1);
8545
- const rows = await model.findLatestEvents({ installId: normalizedInstallId });
8546
- const row = rows[0] ?? null;
8547
- if (rows.length > 1 || row !== null && row.install_id !== normalizedInstallId) throw new Error("Insights database returned an invalid installation.");
8548
- return row === null ? null : toInstallationRow(row);
8549
- },
8550
- async pageInstallationsByCurrentUserId(input) {
8551
- const userId = requireString(input.userId, "user ID", MAX_IDENTITY_LENGTH$1);
8552
- const limit = readLimit(input.limit);
8553
- const cursor = input.cursor === void 0 ? void 0 : readUserInstallationCursor(input.cursor, userId);
8554
- const databaseInput = {
8555
- userId,
8556
- ...cursor === void 0 ? {} : { afterInstallId: cursor.afterInstallId },
8557
- limit
8558
- };
8559
- const rows = await model.findLatestEvents(databaseInput);
8560
- assertInstallationRows(rows, databaseInput);
8561
- const last = rows.at(-1);
8562
- return {
8563
- data: rows.map(toInstallationRow),
8564
- nextCursor: rows.length === limit && last ? encodeCursor({
8565
- afterInstallId: last.install_id,
8566
- kind: "user-installations",
8567
- userId,
8568
- version: 1
8569
- }) : null
8570
- };
8571
8068
  },
8572
- async getReportingOverview(input) {
8573
- const scope = readScope$1(input);
8574
- const { window } = input;
8575
- if (!Object.hasOwn(WINDOW_MS, window)) throw new InsightsBadRequestError("Invalid reporting installation window.");
8576
- const now = Date.now();
8577
- const beforeReceivedAtMs = now - now % HOUR_MS$1 + (now % HOUR_MS$1 === 0 ? 0 : HOUR_MS$1);
8578
- const reach = Math.max(0, beforeReceivedAtMs - WINDOW_MS[window]);
8579
- const sinceMs = reach - reach % DAY_MS$1;
8580
- const measure = async (count) => {
8581
- const value = await count;
8582
- if (!Number.isSafeInteger(value) || value < 0) throw new Error("Insights database returned an invalid count.");
8583
- return {
8584
- count: value,
8585
- measuredAtMs: Date.now()
8586
- };
8587
- };
8588
- const bundleId = input.bundleId === void 0 ? void 0 : requireString(input.bundleId, "bundle ID", MAX_EVENT_ID_LENGTH);
8589
- const reporting = measure(model.countLatestEvents({
8590
- ...scope,
8591
- sinceMs
8592
- }));
8593
- if (bundleId === void 0) return {
8594
- ...scope,
8595
- window,
8596
- sinceMs,
8597
- beforeReceivedAtMs,
8598
- reportingInstallations: await reporting
8599
- };
8600
- const countOutcome = (outcome) => measure(model.countEvents({
8601
- filter: bundleFilter({
8602
- ...scope,
8603
- bundleId,
8604
- outcome
8605
- }),
8606
- sinceMs,
8607
- beforeReceivedAtMs
8608
- }));
8609
- const [reportingInstallations, bundleInstallations, downloadedReports, appliedReports, recoveredReports, failedReports] = await Promise.all([
8610
- reporting,
8611
- measure(model.countLatestEvents({
8612
- ...scope,
8613
- sinceMs,
8614
- bundle: [{
8615
- field: "from_bundle_id",
8616
- value: bundleId,
8617
- types: ["UPDATE_DOWNLOADED"]
8618
- }, {
8619
- field: "to_bundle_id",
8620
- value: bundleId,
8621
- types: [
8622
- "UNCHANGED",
8623
- "UPDATE_APPLIED",
8624
- "RECOVERED"
8625
- ]
8626
- }]
8627
- })),
8628
- countOutcome("downloaded"),
8629
- countOutcome("applied"),
8630
- countOutcome("recovered"),
8631
- countOutcome("failed")
8632
- ]);
8633
- return {
8634
- ...scope,
8635
- window,
8636
- sinceMs,
8637
- beforeReceivedAtMs,
8638
- reportingInstallations,
8639
- bundle: {
8640
- bundleId,
8641
- reportingInstallations: bundleInstallations,
8642
- downloadedReports,
8643
- appliedReports,
8644
- recoveredReports,
8645
- failedReports
8646
- }
8647
- };
8648
- }
8649
- });
8650
- //#endregion
8651
- //#region ../insights/dist/server/schema.mjs
8652
- const HOUR_MS = 36e5;
8653
- const DAY_MS = 864e5;
8654
- const DEFAULT_INSIGHTS_RETENTION = {
8655
- rawDays: 90,
8656
- dailyDays: 400
8657
- };
8658
- const bucketRetention = (days) => ({
8659
- field: "bucket_start_ms",
8660
- days
8069
+ retention
8661
8070
  });
8662
- const MOVEMENTS = /* @__PURE__ */ new Set([
8663
- "UPDATE_DOWNLOADED",
8664
- "UPDATE_APPLIED",
8665
- "RECOVERED",
8666
- "UPDATE_FAILED"
8667
- ]);
8668
- /** A failed update check: it names no target bundle, so no bundle list shows it. */
8669
- const isFailedCheck = (row) => row.type === "UPDATE_FAILED" && row.metadata?.failure?.stage === "check";
8670
8071
  /**
8671
- * Downloads, applies, recoveries, and update failures, whole. An UNCHANGED
8672
- * report counts as a launch and moves its installation's head, but no list
8673
- * shows it, so no row keeps it.
8072
+ * The installations of `insights_distribution` that run their native build's
8073
+ * built-in bundle, by its ID (`builtin_bundle_id`), within the same rows:
8074
+ * reads move them out of the row of their release, or of no release.
8674
8075
  */
8675
- const bundleEvents = (days) => defineTable({
8676
- id: {
8677
- type: "string",
8678
- maxLength: 36
8679
- },
8680
- type: {
8681
- type: "string",
8682
- maxLength: 32
8683
- },
8684
- install_id: {
8076
+ const insightsBuiltinDistribution = (retention) => defineAggregate({
8077
+ channel: { type: "string" },
8078
+ platform: {
8685
8079
  type: "string",
8686
- maxLength: 255
8080
+ maxLength: 16
8687
8081
  },
8688
- user_id: {
8082
+ app_version: { type: "string" },
8083
+ release_id: {
8689
8084
  type: "string",
8690
- maxLength: 255,
8691
- required: false
8085
+ maxLength: 36
8692
8086
  },
8693
- from_release_id: {
8087
+ builtin_bundle_id: {
8694
8088
  type: "string",
8695
- maxLength: 36,
8696
- required: false
8089
+ maxLength: 36
8697
8090
  },
8698
- from_bundle_id: {
8091
+ bucket_start_ms: { type: "integer" }
8092
+ }, {
8093
+ key: [
8094
+ "channel",
8095
+ "platform",
8096
+ "app_version",
8097
+ "release_id",
8098
+ "builtin_bundle_id",
8099
+ "bucket_start_ms"
8100
+ ],
8101
+ gauges: ["latest_installations"],
8102
+ shards: GAUGE_SHARDS,
8103
+ batched: true,
8104
+ indexes: {
8105
+ byScope: {
8106
+ eq: ["channel", "platform"],
8107
+ sort: ["bucket_start_ms"]
8108
+ },
8109
+ byVersion: {
8110
+ eq: [
8111
+ "channel",
8112
+ "platform",
8113
+ "app_version"
8114
+ ],
8115
+ sort: ["bucket_start_ms"]
8116
+ }
8117
+ },
8118
+ retention
8119
+ });
8120
+ /** Latest events by the bundle they came from or went to, bucketed like the distribution. */
8121
+ const insightsLatestByBundle = (retention) => defineAggregate({
8122
+ platform: {
8699
8123
  type: "string",
8700
- maxLength: 36,
8701
- required: false
8124
+ maxLength: 16
8702
8125
  },
8703
- to_release_id: {
8126
+ channel: { type: "string" },
8127
+ bundle_field: {
8704
8128
  type: "string",
8705
- maxLength: 36,
8706
- required: false
8129
+ maxLength: 16
8707
8130
  },
8708
- to_bundle_id: {
8131
+ bundle_id: {
8709
8132
  type: "string",
8710
8133
  maxLength: 36
8711
8134
  },
8135
+ type: {
8136
+ type: "string",
8137
+ maxLength: 32
8138
+ },
8139
+ bucket_start_ms: { type: "integer" }
8140
+ }, {
8141
+ key: [
8142
+ "platform",
8143
+ "channel",
8144
+ "bundle_field",
8145
+ "bundle_id",
8146
+ "type",
8147
+ "bucket_start_ms"
8148
+ ],
8149
+ gauges: ["installations"],
8150
+ shards: GAUGE_SHARDS,
8151
+ batched: true,
8152
+ indexes: { byBundle: {
8153
+ eq: [
8154
+ "platform",
8155
+ "channel",
8156
+ "bundle_field",
8157
+ "bundle_id",
8158
+ "type"
8159
+ ],
8160
+ sort: ["bucket_start_ms"]
8161
+ } },
8162
+ retention
8163
+ });
8164
+ /**
8165
+ * Stored events by outcome and the bundle a bundle filter matches, a row an
8166
+ * hour, which a failed check has none of; and every stored event, a row a UTC
8167
+ * day (`DAILY_EVENTS`). Bundle counts sum the hourly rows, and event lists
8168
+ * read either to skip days without events.
8169
+ */
8170
+ const insightsOutcomes = (retention) => defineAggregate({
8712
8171
  platform: {
8713
8172
  type: "string",
8714
8173
  maxLength: 16
8715
8174
  },
8716
- app_version: { type: "string" },
8717
8175
  channel: { type: "string" },
8718
- metadata: { type: "json" },
8719
- received_at_ms: { type: "integer" }
8720
- }, {
8721
- key: ["id"],
8722
- derived: {
8723
- /** The UTC day the event was received in. */
8724
- day: {
8725
- type: "integer",
8726
- compute: (row) => row.received_at_ms - row.received_at_ms % DAY_MS
8727
- },
8728
- /** Set only for events that belong in installation history. */
8729
- movement_install_id: {
8730
- type: "string",
8731
- compute: (row) => MOVEMENTS.has(row.type) ? row.install_id : null
8732
- },
8733
- /**
8734
- * The bundle a bundle filter matches: `from:<bundle>` for RECOVERED,
8735
- * none for a failed check, else `to:<bundle>`.
8736
- */
8737
- bundle_ref: {
8738
- type: "string",
8739
- multi: true,
8740
- compute: (row) => isFailedCheck(row) ? [] : [row.type === "RECOVERED" && row.from_bundle_id !== null ? `from:${row.from_bundle_id}` : `to:${row.to_bundle_id}`]
8741
- }
8176
+ type: {
8177
+ type: "string",
8178
+ maxLength: 32
8742
8179
  },
8743
- indexes: {
8744
- movementsByInstall: {
8745
- eq: ["movement_install_id"],
8746
- sort: ["received_at_ms"]
8747
- },
8748
- byBundle: {
8749
- eq: [
8750
- "platform",
8751
- "channel",
8752
- "type",
8753
- "bundle_ref",
8754
- "day"
8755
- ],
8756
- sort: ["received_at_ms"]
8757
- },
8758
- /** Events of every scope by day, for the console's unfiltered event list. */
8759
- byDay: {
8760
- eq: ["day"],
8761
- sort: ["received_at_ms"]
8762
- }
8180
+ bundle_ref: {
8181
+ type: "string",
8182
+ maxLength: 41
8763
8183
  },
8764
- retention: {
8765
- field: "day",
8766
- days
8184
+ bucket_start_ms: { type: "integer" }
8185
+ }, {
8186
+ key: [
8187
+ "platform",
8188
+ "channel",
8189
+ "type",
8190
+ "bundle_ref",
8191
+ "bucket_start_ms"
8192
+ ],
8193
+ counters: ["events"],
8194
+ shards: COUNTER_SHARDS,
8195
+ batched: true,
8196
+ indexes: { byRef: {
8197
+ eq: [
8198
+ "platform",
8199
+ "channel",
8200
+ "type",
8201
+ "bundle_ref"
8202
+ ],
8203
+ sort: ["bucket_start_ms"]
8204
+ } },
8205
+ retention
8206
+ });
8207
+ /**
8208
+ * The `insights_outcomes` identity that counts every stored event, for the
8209
+ * global event list. Its bucket is a UTC day, not an hour. Every event's
8210
+ * platform is ios or android, so no outcome row of a bundle filter shares it.
8211
+ */
8212
+ const DAILY_EVENTS = {
8213
+ platform: "*",
8214
+ channel: "",
8215
+ type: "",
8216
+ bundle_ref: ""
8217
+ };
8218
+ /**
8219
+ * Insights' tables, each expiring after its period. The periods are values
8220
+ * the engine applies at runtime; the tables and indexes are the same for
8221
+ * any periods.
8222
+ */
8223
+ const createInsightsSchema = ({ rawDays, dailyDays } = DEFAULT_INSIGHTS_RETENTION) => {
8224
+ const hourly = bucketRetention(rawDays);
8225
+ const daily = bucketRetention(dailyDays);
8226
+ return {
8227
+ bundle_events: bundleEvents(rawDays),
8228
+ bundle_event_heads: bundleEventHeads(dailyDays),
8229
+ /** Hourly counters and sketches: the raw period. */
8230
+ insights_overview: counters(hourly),
8231
+ insights_sketches: sketches(hourly),
8232
+ /** Daily counters and sketches: the daily period. */
8233
+ insights_overview_daily: counters(daily),
8234
+ insights_sketches_daily: sketches(daily),
8235
+ /** A release's counters since its first event, and its failed installations: kept. */
8236
+ insights_overview_lifetime: lifetimeCounters(),
8237
+ insights_sketches_lifetime: lifetimeSketches(),
8238
+ insights_distribution: insightsDistribution(daily),
8239
+ insights_builtin_distribution: insightsBuiltinDistribution(daily),
8240
+ insights_latest_by_bundle: insightsLatestByBundle(daily),
8241
+ insights_outcomes: insightsOutcomes(hourly),
8242
+ insights_failures: insightsFailures(hourly)
8243
+ };
8244
+ };
8245
+ createInsightsSchema();
8246
+ //#endregion
8247
+ //#region ../insights/dist/server/eventRow.mjs
8248
+ const CHANGE_KINDS = /* @__PURE__ */ new Set([
8249
+ "first_seen",
8250
+ "bundle",
8251
+ "release",
8252
+ "app_version",
8253
+ "channel",
8254
+ "native_build"
8255
+ ]);
8256
+ 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));
8257
+ const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
8258
+ const isString = (value) => typeof value === "string";
8259
+ 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);
8260
+ /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
8261
+ 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);
8262
+ 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);
8263
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8264
+ /**
8265
+ * The built-in bundle a report says its installation runs: the running
8266
+ * bundle, which a download leaves at its source, when it is the native
8267
+ * build's built-in bundle, whatever Release selected it. Null for any other
8268
+ * bundle, and for a report without the build's built-in bundle ID.
8269
+ */
8270
+ const runningBuiltinBundleId = (row) => {
8271
+ const running = row.type === "UPDATE_DOWNLOADED" ? row.from_bundle_id : row.to_bundle_id;
8272
+ const builtin = isRecord$2(row.metadata) ? row.metadata.min_bundle_id : null;
8273
+ return typeof builtin === "string" && running === builtin ? builtin : null;
8274
+ };
8275
+ const isIdentity$1 = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
8276
+ const isTextOrNull = (value) => value === null || typeof value === "string";
8277
+ /** Each bundle event field and what it holds. */
8278
+ const BUNDLE_EVENT_FIELDS = {
8279
+ id: (value) => typeof value === "string",
8280
+ type: (value) => value === "UPDATE_DOWNLOADED" || value === "UPDATE_APPLIED" || value === "RECOVERED" || value === "UPDATE_FAILED" || value === "UNCHANGED",
8281
+ install_id: isIdentity$1,
8282
+ user_id: (value) => value === null || isIdentity$1(value),
8283
+ from_bundle_id: isTextOrNull,
8284
+ from_release_id: isTextOrNull,
8285
+ to_release_id: isTextOrNull,
8286
+ to_bundle_id: (value) => typeof value === "string",
8287
+ platform: (value) => value === "ios" || value === "android",
8288
+ app_version: (value) => typeof value === "string",
8289
+ channel: (value) => typeof value === "string",
8290
+ metadata: isDatabaseBundleEventMetadata,
8291
+ received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
8292
+ };
8293
+ const hasMetadata = (row, key) => isRecord$2(row.metadata) && Object.hasOwn(row.metadata, key);
8294
+ const updateStrategyOf = (row) => isRecord$2(row.metadata) ? row.metadata.update_strategy : void 0;
8295
+ /**
8296
+ * A movement, or a failed update, names the bundle it left and how the
8297
+ * device updates, and a failed update says what failed; an unchanged report
8298
+ * names neither.
8299
+ */
8300
+ 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$2(row.metadata) && isRecord$2(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;
8301
+ /**
8302
+ * Throws unless `row` has exactly the bundle event fields, each of its type,
8303
+ * and the invariants of its event type: `invalid-field` for an unknown field,
8304
+ * `invalid-data` for anything else.
8305
+ */
8306
+ const validateBundleEventFields = (row) => {
8307
+ if (!isRecord$2(row)) throw new DatabaseAdapterInputError("invalid-data");
8308
+ for (const field of Object.keys(row)) if (!Object.hasOwn(BUNDLE_EVENT_FIELDS, field)) throw new DatabaseAdapterInputError("invalid-field");
8309
+ for (const [field, valid] of Object.entries(BUNDLE_EVENT_FIELDS)) if (!Object.hasOwn(row, field) || !valid(row[field])) throw new DatabaseAdapterInputError("invalid-data");
8310
+ if (!hasEventInvariants(row)) throw new DatabaseAdapterInputError("invalid-data");
8311
+ };
8312
+ //#endregion
8313
+ //#region ../insights/dist/server/contract.mjs
8314
+ const encoder = new TextEncoder();
8315
+ /** Exact UTF-8 byte ordering, without case folding or Unicode normalization. */
8316
+ const compareInsightsText = (left, right) => {
8317
+ const a = encoder.encode(left);
8318
+ const b = encoder.encode(right);
8319
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) if (a[index] !== b[index]) return a[index] - b[index];
8320
+ return a.length - b.length;
8321
+ };
8322
+ const isWellFormedText = (value) => {
8323
+ for (const character of value) {
8324
+ const point = character.codePointAt(0);
8325
+ if (point >= 55296 && point <= 57343) return false;
8767
8326
  }
8768
- });
8327
+ return true;
8328
+ };
8329
+ const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255 && isWellFormedText(value);
8330
+ const isText$1 = (value) => typeof value === "string" && value.length > 0 && isWellFormedText(value);
8331
+ const isTimestamp = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
8332
+ const isLimit = (value) => isTimestamp(value) && value >= 1 && value <= 101;
8333
+ const hasOnlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
8334
+ const hasScope = (value) => (value.platform === "ios" || value.platform === "android") && isText$1(value.channel);
8335
+ const isBundleFilter = (value, withKind = false) => {
8336
+ if (!isRecord$2(value) || !hasScope(value)) return false;
8337
+ const keys = [
8338
+ "platform",
8339
+ "channel",
8340
+ "type",
8341
+ ...withKind ? ["kind"] : []
8342
+ ];
8343
+ return value.type === "RECOVERED" ? isText$1(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED" || value.type === "UNCHANGED") && isText$1(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
8344
+ };
8345
+ const isEventFilter = (value) => {
8346
+ if (!isRecord$2(value)) return false;
8347
+ if (value.kind === "all") return hasOnlyKeys(value, ["kind"]);
8348
+ if (value.kind === "installationMovement") return isIdentity(value.installId) && hasOnlyKeys(value, ["kind", "installId"]);
8349
+ return value.kind === "bundle" && isBundleFilter(value, true);
8350
+ };
8351
+ const validateRow = (model, row, result = false) => {
8352
+ try {
8353
+ validateBundleEventFields(row);
8354
+ if (!isRecord$2(row) || typeof row.id !== "string" || !isUUIDv7(row.id) || [...Object.values(row), ...isRecord$2(row.metadata) ? Object.values(row.metadata) : []].some((value) => typeof value === "string" && !isWellFormedText(value))) throw new DatabaseAdapterInputError("invalid-data");
8355
+ } catch (error) {
8356
+ if (result) throw new DatabaseAdapterInputError("invalid-result");
8357
+ throw error;
8358
+ }
8359
+ };
8360
+ /** Throws `invalid-data` unless `row` is a complete, well-formed bundle event row. */
8361
+ const assertBundleEventRow = (row) => validateRow("bundle_events", row);
8769
8362
  /**
8770
- * Each installation's latest event, whole, so reading it is one point read.
8771
- * Its gauges count it in the UTC day of that event. An update failure moves
8772
- * nothing, so no head holds one.
8363
+ * Downloads, applies, recoveries, failed updates, and the UNCHANGED rows
8364
+ * kept for a change belong in installation history.
8773
8365
  */
8774
- const bundleEventHeads = (days) => defineTable({
8775
- install_id: {
8776
- type: "string",
8777
- maxLength: 255
8778
- },
8779
- id: {
8780
- type: "string",
8781
- maxLength: 36
8782
- },
8783
- type: {
8784
- type: "string",
8785
- maxLength: 32
8786
- },
8787
- user_id: {
8788
- type: "string",
8789
- maxLength: 255,
8790
- required: false
8366
+ const isInsightsMovementEvent = (event) => event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED" || event.type === "RECOVERED" || event.type === "UPDATE_FAILED" || event.type === "UNCHANGED";
8367
+ const matchesInsightsEventFilter = (event, filter) => {
8368
+ if (filter.kind === "all") return true;
8369
+ if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
8370
+ return event.type === filter.type && event.platform === filter.platform && event.channel === filter.channel && bundleRefsOf(event).includes(bundleRefOfFilter(filter));
8371
+ };
8372
+ const invalidQuery = () => {
8373
+ throw new DatabaseAdapterInputError("invalid-query");
8374
+ };
8375
+ const invalidResult = () => {
8376
+ throw new DatabaseAdapterInputError("invalid-result");
8377
+ };
8378
+ const validateCount = (count) => isTimestamp(count) ? count : invalidResult();
8379
+ const isTimeRange = (value) => isRecord$2(value) && hasOnlyKeys(value, ["start", "end"]) && isTimestamp(value.start) && isTimestamp(value.end) && value.start < value.end;
8380
+ const isReleaseReference = (value) => isRecord$2(value) && hasOnlyKeys(value, [
8381
+ "releaseId",
8382
+ "platform",
8383
+ "channel"
8384
+ ]) && isIdentity(value.releaseId) && hasScope(value);
8385
+ const validateAggregateResult = (value) => {
8386
+ const result = isRecord$2(value) ? value : invalidResult();
8387
+ if (!isTimestamp(result.measuredAtMs)) invalidResult();
8388
+ const coverage = result.coverage;
8389
+ if (!isRecord$2(coverage) || coverage.kind !== "complete" && coverage.kind !== "partial" || coverage.sinceMs !== null && !isTimestamp(coverage.sinceMs)) invalidResult();
8390
+ };
8391
+ /** Validate custom and bundled providers at the same public boundary. */
8392
+ const createValidatedInsightsModel = (model) => ({
8393
+ async recordEvent(input) {
8394
+ if (!isRecord$2(input) || !hasOnlyKeys(input, ["event"])) throw new DatabaseAdapterInputError("invalid-data");
8395
+ validateRow("bundle_events", input.event);
8396
+ await model.recordEvent(input);
8791
8397
  },
8792
- from_release_id: {
8793
- type: "string",
8794
- maxLength: 36,
8795
- required: false
8398
+ async listEvents(input) {
8399
+ if (!isRecord$2(input) || !hasOnlyKeys(input, [
8400
+ "filter",
8401
+ "sinceMs",
8402
+ "beforeReceivedAtMs",
8403
+ "after",
8404
+ "limit"
8405
+ ]) || !isEventFilter(input.filter) || !isLimit(input.limit) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs !== void 0 && !isTimestamp(input.sinceMs)) invalidQuery();
8406
+ const sinceMs = input.sinceMs ?? 0;
8407
+ if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!isRecord$2(input.after) || !hasOnlyKeys(input.after, ["receivedAtMs", "id"]) || !isTimestamp(input.after.receivedAtMs) || typeof input.after.id !== "string" || !isUUIDv7(input.after.id) || input.after.receivedAtMs < sinceMs || input.after.receivedAtMs >= input.beforeReceivedAtMs)) invalidQuery();
8408
+ const rows = await model.listEvents(input);
8409
+ if (!Array.isArray(rows) || rows.length > input.limit) invalidResult();
8410
+ let previous = input.after;
8411
+ for (const row of rows) {
8412
+ validateRow("bundle_events", row, true);
8413
+ if (row.received_at_ms < sinceMs || row.received_at_ms >= input.beforeReceivedAtMs || !matchesInsightsEventFilter(row, input.filter) || previous !== void 0 && (row.received_at_ms > previous.receivedAtMs || row.received_at_ms === previous.receivedAtMs && row.id >= previous.id)) invalidResult();
8414
+ previous = {
8415
+ receivedAtMs: row.received_at_ms,
8416
+ id: row.id
8417
+ };
8418
+ }
8419
+ return rows;
8796
8420
  },
8797
- from_bundle_id: {
8798
- type: "string",
8799
- maxLength: 36,
8800
- required: false
8421
+ async findLatestEvents(input) {
8422
+ if (!isRecord$2(input)) invalidQuery();
8423
+ if ("installId" in input) {
8424
+ if (!hasOnlyKeys(input, ["installId"]) || !isIdentity(input.installId)) invalidQuery();
8425
+ } else if (!hasOnlyKeys(input, [
8426
+ "userId",
8427
+ "afterInstallId",
8428
+ "limit"
8429
+ ]) || !isIdentity(input.userId) || !isLimit(input.limit) || input.afterInstallId !== void 0 && !isIdentity(input.afterInstallId)) invalidQuery();
8430
+ const rows = await model.findLatestEvents(input);
8431
+ if (!Array.isArray(rows) || rows.length > ("installId" in input ? 1 : input.limit)) invalidResult();
8432
+ let previous = "installId" in input ? void 0 : input.afterInstallId;
8433
+ for (const row of rows) {
8434
+ validateRow("bundle_events", row, true);
8435
+ if ("installId" in input ? row.install_id !== input.installId : row.user_id !== input.userId || previous !== void 0 && compareInsightsText(row.install_id, previous) <= 0) invalidResult();
8436
+ previous = row.install_id;
8437
+ }
8438
+ return rows;
8801
8439
  },
8802
- to_release_id: {
8803
- type: "string",
8804
- maxLength: 36,
8805
- required: false
8440
+ async countLatestEvents(input) {
8441
+ if (!isRecord$2(input) || !hasOnlyKeys(input, [
8442
+ "platform",
8443
+ "channel",
8444
+ "sinceMs",
8445
+ "bundle"
8446
+ ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundle !== void 0 && (!Array.isArray(input.bundle) || input.bundle.length < 1 || input.bundle.length > 2 || input.bundle.some((bundle) => !isRecord$2(bundle) || !hasOnlyKeys(bundle, [
8447
+ "field",
8448
+ "value",
8449
+ "types"
8450
+ ]) || bundle.field !== "from_bundle_id" && bundle.field !== "to_bundle_id" || !isText$1(bundle.value) || !Array.isArray(bundle.types) || bundle.types.length === 0 || bundle.types.some((type) => ![
8451
+ "UNCHANGED",
8452
+ "UPDATE_DOWNLOADED",
8453
+ "UPDATE_APPLIED",
8454
+ "RECOVERED"
8455
+ ].includes(type))))) invalidQuery();
8456
+ return validateCount(await model.countLatestEvents(input));
8806
8457
  },
8807
- to_bundle_id: {
8808
- type: "string",
8809
- maxLength: 36
8458
+ async countEvents(input) {
8459
+ if (!isRecord$2(input) || !hasOnlyKeys(input, [
8460
+ "filter",
8461
+ "sinceMs",
8462
+ "beforeReceivedAtMs"
8463
+ ]) || !isBundleFilter(input.filter) || !isTimestamp(input.sinceMs) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs > input.beforeReceivedAtMs) invalidQuery();
8464
+ return validateCount(await model.countEvents(input));
8810
8465
  },
8811
- platform: {
8812
- type: "string",
8813
- maxLength: 16
8466
+ async countEventSeries(input) {
8467
+ if (!isRecord$2(input) || !hasOnlyKeys(input, [
8468
+ "filter",
8469
+ "timeRange",
8470
+ "intervalMs"
8471
+ ]) || !isBundleFilter(input.filter) || !isTimeRange(input.timeRange) || !isTimestamp(input.intervalMs)) invalidQuery();
8472
+ const { start, end } = input.timeRange;
8473
+ const { intervalMs } = input;
8474
+ if (intervalMs === 0 || intervalMs % 36e5 !== 0 || start % 36e5 !== 0 || (end - start) % intervalMs !== 0 || end - start > 7776e6) invalidQuery();
8475
+ const points = await model.countEventSeries(input);
8476
+ if (!Array.isArray(points) || points.length !== (end - start) / intervalMs || points.some((point, index) => !isRecord$2(point) || !hasOnlyKeys(point, ["startMs", "events"]) || point.startMs !== start + index * intervalMs || !isTimestamp(point.events))) invalidResult();
8477
+ return points;
8814
8478
  },
8815
- app_version: { type: "string" },
8816
- channel: { type: "string" },
8817
- metadata: { type: "json" },
8818
- received_at_ms: { type: "integer" }
8819
- }, {
8820
- key: ["install_id"],
8821
- indexes: { byUser: {
8822
- eq: ["user_id"],
8823
- sort: ["install_id"]
8824
- } },
8825
- retention: {
8826
- field: "received_at_ms",
8827
- days
8828
- }
8829
- });
8830
- /**
8831
- * Shards are keyed by install id. Counters are blind increments and never
8832
- * conflict, so they keep 8. Gauges and sketches are read, merged, and written
8833
- * back. B3's rollout gate on PostgreSQL retried 2.4% of transactions at 8
8834
- * shards and 1.3% at 16. DynamoDB's transactions take longer, so D8's gate
8835
- * on DynamoDB Local retried 2–24% at 16, almost all on gauge rows, and 1–5%
8836
- * with gauges at 32. Sketches stay at 16: each shard row carries 2 KB of
8837
- * registers that every read merges.
8838
- *
8839
- * Every Insights aggregate is `batched`: on DynamoDB and Firestore, which
8840
- * bill each write, an event's changes apply after it commits, merged with
8841
- * other events' into one write per row, on one shard per writer.
8842
- */
8843
- const COUNTER_SHARDS = 8;
8844
- const GAUGE_SHARDS = 32;
8845
- const SKETCH_SHARDS = 16;
8846
- /** Rows keyed by a hashed identity (scope and period) and a bucket. */
8847
- const identityFields = {
8848
- identity: {
8849
- type: "string",
8850
- maxLength: 32
8479
+ async getReleaseActivity(input) {
8480
+ if (!isRecord$2(input) || !hasOnlyKeys(input, ["releases"]) || !Array.isArray(input.releases) || input.releases.length < 1 || input.releases.length > 100 || !input.releases.every(isReleaseReference)) invalidQuery();
8481
+ const result = await model.getReleaseActivity(input);
8482
+ if (!isRecord$2(result) || !isTimestamp(result.measuredAtMs) || !Array.isArray(result.data) || result.data.some((item) => !isRecord$2(item) || !isReleaseReference(item.release) || !isRecord$2(item.metrics) || !isTimestamp(item.metrics.downloads) || !isTimestamp(item.metrics.applies) || !isTimestamp(item.metrics.failedLaunches))) invalidResult();
8483
+ return result;
8851
8484
  },
8852
- bucket_start_ms: { type: "integer" }
8853
- };
8854
- const window = {
8855
- eq: ["identity"],
8856
- sort: ["bucket_start_ms"]
8857
- };
8858
- /** What a release or channel row counts in every period kind. */
8859
- const WINDOW_COUNTERS = [
8860
- "downloads",
8861
- "failed_launches",
8862
- "patch_downloads",
8863
- "patch_fallbacks"
8864
- ];
8485
+ async getAppUsage(input) {
8486
+ if (!isRecord$2(input) || !hasOnlyKeys(input, [
8487
+ "channel",
8488
+ "platform",
8489
+ "appVersion",
8490
+ "timeRange",
8491
+ "intervalMs"
8492
+ ]) || !isText$1(input.channel) || ![
8493
+ "all",
8494
+ "ios",
8495
+ "android"
8496
+ ].includes(input.platform) || input.appVersion !== void 0 && !isText$1(input.appVersion) || !isTimeRange(input.timeRange) || !isTimestamp(input.intervalMs) || input.intervalMs < 36e5) invalidQuery();
8497
+ const result = await model.getAppUsage(input);
8498
+ validateAggregateResult(result);
8499
+ if (!isTimestamp(result.activeInstallations) || !Array.isArray(result.points) || !Array.isArray(result.appVersions) || !Array.isArray(result.versions) || !Array.isArray(result.platforms) || !Array.isArray(result.bundleDistribution)) invalidResult();
8500
+ return result;
8501
+ }
8502
+ });
8503
+ //#endregion
8504
+ //#region ../insights/dist/server/model.mjs
8865
8505
  /**
8866
- * Hourly or daily counters: a release or channel row counts downloads,
8867
- * launches that crashed and recovered (`failed_launches`), and downloads a
8868
- * patch delivered and those that fell back from one.
8506
+ * The plugin's API as `InsightsModel`, validated at its boundary: what the
8507
+ * Insights routes, the console, and the e2e harness read through.
8869
8508
  */
8870
- const counters = (retention) => defineAggregate(identityFields, {
8871
- key: ["identity", "bucket_start_ms"],
8872
- counters: WINDOW_COUNTERS,
8873
- shards: COUNTER_SHARDS,
8874
- batched: true,
8875
- indexes: { window },
8876
- retention
8509
+ const createInsightsModel = (api) => createValidatedInsightsModel({
8510
+ recordEvent: ({ event }) => api.recordEvent(event),
8511
+ listEvents: api.listEvents,
8512
+ findLatestEvents: api.findLatestEvents,
8513
+ countLatestEvents: api.countLatestEvents,
8514
+ countEvents: api.countEvents,
8515
+ countEventSeries: api.countEventSeries,
8516
+ getReleaseActivity: api.getReleaseActivity,
8517
+ getAppUsage: api.getAppUsage
8877
8518
  });
8519
+ //#endregion
8520
+ //#region ../insights/dist/server/official.mjs
8878
8521
  /**
8879
- * A release's counters since its first report, at bucket 0: the windowed
8880
- * counters, its applies, which the bundle list reads, and its failed updates
8881
- * (`failed_updates`), since no breakdown row outlives the raw period; windowed
8882
- * reads sum `insights_failures` instead.
8522
+ * The brand of Hot Updater's own plugins, a registered symbol that
8523
+ * `@hot-updater/server` reads to reserve the id "insights" for this plugin.
8524
+ * Each official plugin package keeps this copy, so none imports another's
8525
+ * internals for it.
8883
8526
  */
8884
- const lifetimeCounters = () => defineAggregate(identityFields, {
8885
- key: ["identity", "bucket_start_ms"],
8886
- counters: [
8887
- ...WINDOW_COUNTERS,
8888
- "applies",
8889
- "failed_updates"
8890
- ],
8891
- shards: COUNTER_SHARDS,
8892
- batched: true,
8893
- indexes: { window }
8894
- });
8527
+ const OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
8528
+ /** Marks a plugin one of Hot Updater's own factories made. */
8529
+ const markOfficial = (plugin) => Object.defineProperty(plugin, OFFICIAL, { value: true });
8530
+ //#endregion
8531
+ //#region ../insights/dist/server/errors.mjs
8895
8532
  /**
8896
- * Unique-installation sketches of one period kind: a usage row's active
8897
- * installations (`activity_users`), and a `failure` or `check` row's
8898
- * installations whose update or update check failed (`failed_users`).
8533
+ * What Insights' API throws for an input it refuses, such as an unknown
8534
+ * event field or a malformed cursor; its routes answer 400 for it.
8899
8535
  */
8900
- const sketches = (retention) => defineAggregate(identityFields, {
8901
- key: ["identity", "bucket_start_ms"],
8902
- distinct: ["activity_users", "failed_users"],
8903
- shards: SKETCH_SHARDS,
8904
- batched: true,
8905
- indexes: { window },
8906
- retention
8907
- });
8908
- /** Installations whose update to a release failed, since its first failure: kept, at bucket 0. */
8909
- const lifetimeSketches = () => defineAggregate(identityFields, {
8910
- key: ["identity", "bucket_start_ms"],
8911
- distinct: ["failed_users"],
8912
- shards: SKETCH_SHARDS,
8913
- batched: true,
8914
- indexes: { window }
8915
- });
8536
+ var InsightsBadRequestError = class extends Error {
8537
+ name = "InsightsBadRequestError";
8538
+ };
8539
+ var InsightsPayloadTooLargeError = class extends Error {
8540
+ maximumBytes;
8541
+ name = "InsightsPayloadTooLargeError";
8542
+ constructor(maximumBytes) {
8543
+ super(`Event payload exceeds ${maximumBytes} bytes`);
8544
+ this.maximumBytes = maximumBytes;
8545
+ }
8546
+ };
8547
+ //#endregion
8548
+ //#region ../insights/dist/server/eventInput.mjs
8549
+ const MAX_EVENT_STRING_LENGTH = 1024;
8550
+ const MAX_IDENTITY_LENGTH$2 = 255;
8551
+ const EVENT_BODY_MAX_BYTES = 16384;
8552
+ function isRecord$1(value) {
8553
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8554
+ }
8555
+ function requireStringField(payload, key) {
8556
+ const value = payload[key];
8557
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_EVENT_STRING_LENGTH || new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(value)) !== value) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
8558
+ return value;
8559
+ }
8560
+ function requireNullableStringField(payload, key) {
8561
+ if (payload[key] === null) return null;
8562
+ return requireStringField(payload, key);
8563
+ }
8564
+ function requireIdentityField(payload, key) {
8565
+ const value = requireStringField(payload, key);
8566
+ if (value.length > MAX_IDENTITY_LENGTH$2) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
8567
+ return value;
8568
+ }
8916
8569
  /**
8917
- * Update failures by what failed, a row an hour. `release_id` is the failed
8918
- * update's target release, or empty for a failed check or an unknown target.
8919
- * `stage` and `reason` say where and why it failed, and `detail` what else
8920
- * the client knew, as the JSON array `[resource, httpStatus, originCode,
8921
- * transport]`. Every index is in key order, so its counters stay blind
8922
- * increments.
8570
+ * The report's idempotency key, when the client sends one: it creates the
8571
+ * UUIDv7 once and repeats it on every retry of that report.
8923
8572
  */
8924
- const insightsFailures = (retention) => defineAggregate({
8925
- platform: {
8926
- type: "string",
8927
- maxLength: 16
8928
- },
8929
- channel: { type: "string" },
8930
- bucket_start_ms: { type: "integer" },
8931
- release_id: {
8932
- type: "string",
8933
- maxLength: 36
8934
- },
8935
- stage: {
8936
- type: "string",
8937
- maxLength: 16
8938
- },
8939
- reason: {
8940
- type: "string",
8941
- maxLength: 64
8942
- },
8943
- detail: {
8944
- type: "string",
8945
- maxLength: 160
8573
+ function readEventId(payload) {
8574
+ const value = payload.eventId;
8575
+ if (value === void 0) return void 0;
8576
+ if (!isUUIDv7(value)) throw new InsightsBadRequestError("Invalid event field: eventId");
8577
+ return value;
8578
+ }
8579
+ /**
8580
+ * The native build's built-in bundle ID: a bundle ID's length at most, and
8581
+ * left out when it is missing or malformed, so the report still records.
8582
+ */
8583
+ const readMinBundleId = (value) => typeof value === "string" && value.length > 0 && value.length <= 36 ? value : null;
8584
+ /** One value of an open set: any other value reads as `unknown`. */
8585
+ const oneOf = (values, value) => values.includes(value) ? value : "unknown";
8586
+ const STAGES = [
8587
+ "check",
8588
+ "download",
8589
+ "install"
8590
+ ];
8591
+ const REASONS = [
8592
+ "network",
8593
+ "http",
8594
+ "invalid_response",
8595
+ "hash_mismatch",
8596
+ "signature",
8597
+ "patch",
8598
+ "extract",
8599
+ "storage"
8600
+ ];
8601
+ const RESOURCES = [
8602
+ "catalog",
8603
+ "artifact",
8604
+ "manifest",
8605
+ "file",
8606
+ "patch",
8607
+ "archive"
8608
+ ];
8609
+ const TRANSPORTS = [
8610
+ "timeout",
8611
+ "dns",
8612
+ "tls",
8613
+ "connection",
8614
+ "offline",
8615
+ "cancelled"
8616
+ ];
8617
+ const DELIVERIES = [
8618
+ "patch",
8619
+ "manifest",
8620
+ "archive"
8621
+ ];
8622
+ /** A storage error code: letters, digits, and `._-`. */
8623
+ const CODE = /^[A-Za-z0-9._-]{1,64}$/;
8624
+ const readCode = (value) => typeof value === "string" && CODE.test(value) ? value : void 0;
8625
+ const readMetadata = (payload) => isRecord$1(payload.metadata) ? payload.metadata : {};
8626
+ /**
8627
+ * What an UPDATE_FAILED report says failed. It never refuses the report: an
8628
+ * unknown value of a set reads as `unknown`, a missing or malformed failure
8629
+ * as an unknown stage and reason, and a malformed optional field is left out,
8630
+ * as is a null one.
8631
+ */
8632
+ function readFailure(value) {
8633
+ const failure = isRecord$1(value) ? value : {};
8634
+ const httpStatus = failure.httpStatus;
8635
+ const originCode = readCode(failure.originCode);
8636
+ const errorMessage = typeof failure.errorMessage === "string" && failure.errorMessage.length <= 2048 ? failure.errorMessage : void 0;
8637
+ const errorStack = typeof failure.errorStack === "string" && failure.errorStack.length <= 4096 ? failure.errorStack : void 0;
8638
+ return {
8639
+ stage: oneOf(STAGES, failure.stage),
8640
+ reason: oneOf(REASONS, failure.reason),
8641
+ ...failure.resource == null ? {} : { resource: oneOf(RESOURCES, failure.resource) },
8642
+ ...Number.isSafeInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {},
8643
+ ...failure.transport == null ? {} : { transport: oneOf(TRANSPORTS, failure.transport) },
8644
+ ...originCode === void 0 ? {} : { originCode },
8645
+ ...errorMessage === void 0 ? {} : { errorMessage },
8646
+ ...errorStack === void 0 ? {} : { errorStack }
8647
+ };
8648
+ }
8649
+ function readHttpResponse(value) {
8650
+ if (!isRecord$1(value) || value.resource !== "catalog" && value.resource !== "artifact" || typeof value.path !== "string" || value.path.length > 1024 || !Number.isSafeInteger(value.status) || value.status < 100 || value.status > 599 || value.body !== null && (typeof value.body !== "string" || new TextEncoder().encode(JSON.stringify(value.body)).byteLength > 4096) || typeof value.bodyTruncated !== "boolean" || !Number.isSafeInteger(value.receivedAtMs) || value.receivedAtMs < 0) throw new InsightsBadRequestError("Invalid HTTP response details");
8651
+ return {
8652
+ resource: value.resource,
8653
+ path: value.path,
8654
+ status: value.status,
8655
+ body: value.body,
8656
+ bodyTruncated: value.bodyTruncated,
8657
+ receivedAtMs: value.receivedAtMs
8658
+ };
8659
+ }
8660
+ async function readBoundedText(request) {
8661
+ const contentLength = request.headers.get("content-length");
8662
+ const declaredByteLength = Number(contentLength);
8663
+ if (contentLength !== null && Number.isSafeInteger(declaredByteLength) && declaredByteLength > 16384) throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
8664
+ if (request.body === null) return "";
8665
+ const reader = request.body.getReader();
8666
+ const decoder = new TextDecoder();
8667
+ let byteLength = 0;
8668
+ let text = "";
8669
+ while (true) {
8670
+ const result = await reader.read();
8671
+ if (result.done) break;
8672
+ byteLength += result.value.byteLength;
8673
+ if (byteLength > 16384) {
8674
+ await reader.cancel();
8675
+ throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
8676
+ }
8677
+ text += decoder.decode(result.value, { stream: true });
8678
+ }
8679
+ return text + decoder.decode();
8680
+ }
8681
+ async function parseJson(request) {
8682
+ const text = await readBoundedText(request);
8683
+ try {
8684
+ return JSON.parse(text);
8685
+ } catch (error) {
8686
+ if (error instanceof SyntaxError) throw new InsightsBadRequestError("Invalid event payload");
8687
+ throw error;
8688
+ }
8689
+ }
8690
+ function requireEvent(payload) {
8691
+ if (!isRecord$1(payload)) throw new InsightsBadRequestError("Invalid event payload");
8692
+ const platform = requireStringField(payload, "platform");
8693
+ if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid event field: platform");
8694
+ const eventId = readEventId(payload);
8695
+ const metadata = readMetadata(payload);
8696
+ const httpMetadata = metadata.httpResponse === void 0 ? {} : { httpResponse: readHttpResponse(metadata.httpResponse) };
8697
+ const base = {
8698
+ ...metadata.httpResponse === void 0 ? {} : { metadata: httpMetadata },
8699
+ ...eventId === void 0 ? {} : { eventId },
8700
+ installId: requireIdentityField(payload, "installId"),
8701
+ toBundleId: requireStringField(payload, "toBundleId"),
8702
+ ...payload.userId === void 0 ? {} : { userId: requireIdentityField(payload, "userId") },
8703
+ platform,
8704
+ appVersion: requireStringField(payload, "appVersion"),
8705
+ channel: requireStringField(payload, "channel"),
8706
+ cohort: requireStringField(payload, "cohort"),
8707
+ fingerprintHash: requireNullableStringField(payload, "fingerprintHash"),
8708
+ sdkVersion: payload.sdkVersion === void 0 ? null : requireNullableStringField(payload, "sdkVersion"),
8709
+ minBundleId: readMinBundleId(payload.minBundleId),
8710
+ fromReleaseId: requireNullableStringField(payload, "fromReleaseId"),
8711
+ toReleaseId: requireNullableStringField(payload, "toReleaseId")
8712
+ };
8713
+ const type = requireStringField(payload, "type");
8714
+ const movement = () => {
8715
+ const updateStrategy = requireStringField(payload, "updateStrategy");
8716
+ if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") throw new InsightsBadRequestError("Invalid event field: updateStrategy");
8717
+ return {
8718
+ ...base,
8719
+ fromBundleId: requireStringField(payload, "fromBundleId"),
8720
+ updateStrategy
8721
+ };
8722
+ };
8723
+ switch (type) {
8724
+ case "UPDATE_DOWNLOADED": {
8725
+ const delivery = metadata.delivery;
8726
+ const read = {
8727
+ ...httpMetadata,
8728
+ ...delivery == null ? {} : { delivery: oneOf(DELIVERIES, delivery) },
8729
+ ...metadata.patchFallback === true ? { patchFallback: true } : {}
8730
+ };
8731
+ return {
8732
+ ...movement(),
8733
+ type,
8734
+ ...Object.keys(read).length === 0 ? {} : { metadata: read }
8735
+ };
8736
+ }
8737
+ case "UPDATE_APPLIED":
8738
+ case "RECOVERED": return {
8739
+ ...movement(),
8740
+ type
8741
+ };
8742
+ case "UPDATE_FAILED": return {
8743
+ ...movement(),
8744
+ type,
8745
+ metadata: {
8746
+ ...httpMetadata,
8747
+ failure: readFailure(metadata.failure)
8748
+ }
8749
+ };
8750
+ case "UNCHANGED":
8751
+ if (payload.fromBundleId !== null || payload.updateStrategy !== null) throw new InsightsBadRequestError("Invalid unchanged event shape");
8752
+ return {
8753
+ ...base,
8754
+ type,
8755
+ fromBundleId: null,
8756
+ updateStrategy: null
8757
+ };
8758
+ default: throw new InsightsBadRequestError("Invalid event field: type");
8946
8759
  }
8947
- }, {
8948
- key: [
8949
- "platform",
8950
- "channel",
8951
- "bucket_start_ms",
8952
- "release_id",
8953
- "stage",
8954
- "reason",
8955
- "detail"
8956
- ],
8957
- counters: ["events"],
8958
- shards: COUNTER_SHARDS,
8959
- batched: true,
8960
- indexes: { byScope: {
8961
- eq: ["platform", "channel"],
8962
- sort: ["bucket_start_ms"]
8963
- } },
8964
- retention
8965
- });
8966
- /** Installations counted in the UTC day of their latest event, so reads sum whole UTC days. */
8967
- const insightsDistribution = (retention) => defineAggregate({
8968
- channel: { type: "string" },
8969
- platform: {
8970
- type: "string",
8971
- maxLength: 16
8972
- },
8973
- app_version: { type: "string" },
8974
- release_id: {
8975
- type: "string",
8976
- maxLength: 36
8977
- },
8978
- bucket_start_ms: { type: "integer" }
8979
- }, {
8980
- key: [
8981
- "channel",
8982
- "platform",
8983
- "app_version",
8984
- "release_id",
8985
- "bucket_start_ms"
8986
- ],
8987
- gauges: ["latest_installations"],
8988
- shards: GAUGE_SHARDS,
8989
- batched: true,
8990
- indexes: {
8991
- byScope: {
8992
- eq: ["channel", "platform"],
8993
- sort: ["bucket_start_ms"]
8994
- },
8995
- byVersion: {
8996
- eq: [
8997
- "channel",
8998
- "platform",
8999
- "app_version"
9000
- ],
9001
- sort: ["bucket_start_ms"]
8760
+ }
8761
+ async function parseBundleEventRequest(request) {
8762
+ return requireEvent(await parseJson(request));
8763
+ }
8764
+ function createBundleEventRow(input) {
8765
+ input = requireEvent(input);
8766
+ const base = {
8767
+ app_version: input.appVersion,
8768
+ channel: input.channel,
8769
+ from_release_id: input.fromReleaseId,
8770
+ id: input.eventId ?? createUUIDv7(),
8771
+ install_id: input.installId,
8772
+ platform: input.platform,
8773
+ received_at_ms: Date.now(),
8774
+ to_bundle_id: input.toBundleId,
8775
+ to_release_id: input.toReleaseId,
8776
+ user_id: input.userId ?? null
8777
+ };
8778
+ const response = input.metadata?.httpResponse;
8779
+ const metadata = {
8780
+ ...response ? { http_response: {
8781
+ resource: response.resource,
8782
+ path: response.path,
8783
+ status: response.status,
8784
+ body: response.body,
8785
+ body_truncated: response.bodyTruncated,
8786
+ received_at_ms: response.receivedAtMs
8787
+ } } : {},
8788
+ cohort: input.cohort,
8789
+ fingerprint_hash: input.fingerprintHash,
8790
+ sdk_version: input.sdkVersion ?? null,
8791
+ ...input.minBundleId ? { min_bundle_id: input.minBundleId } : {},
8792
+ update_strategy: input.updateStrategy
8793
+ };
8794
+ switch (input.type) {
8795
+ case "UPDATE_DOWNLOADED": {
8796
+ const { delivery, patchFallback } = input.metadata ?? {};
8797
+ return {
8798
+ ...base,
8799
+ from_bundle_id: input.fromBundleId,
8800
+ type: input.type,
8801
+ metadata: {
8802
+ ...metadata,
8803
+ ...delivery === void 0 ? {} : { delivery },
8804
+ ...patchFallback ? { patch_fallback: true } : {}
8805
+ }
8806
+ };
9002
8807
  }
9003
- },
9004
- retention
9005
- });
8808
+ case "UPDATE_APPLIED":
8809
+ case "RECOVERED": return {
8810
+ ...base,
8811
+ from_bundle_id: input.fromBundleId,
8812
+ type: input.type,
8813
+ metadata
8814
+ };
8815
+ case "UPDATE_FAILED": {
8816
+ const { failure } = input.metadata;
8817
+ return {
8818
+ ...base,
8819
+ from_bundle_id: input.fromBundleId,
8820
+ type: input.type,
8821
+ metadata: {
8822
+ ...metadata,
8823
+ failure: {
8824
+ stage: failure.stage,
8825
+ reason: failure.reason,
8826
+ ...failure.errorMessage === void 0 ? {} : { error_message: failure.errorMessage },
8827
+ ...failure.errorStack === void 0 ? {} : { error_stack: failure.errorStack },
8828
+ ...failure.resource === void 0 ? {} : { resource: failure.resource },
8829
+ ...failure.httpStatus === void 0 ? {} : { http_status: failure.httpStatus },
8830
+ ...failure.transport === void 0 ? {} : { transport: failure.transport },
8831
+ ...failure.originCode === void 0 ? {} : { origin_code: failure.originCode }
8832
+ }
8833
+ }
8834
+ };
8835
+ }
8836
+ case "UNCHANGED": return {
8837
+ ...base,
8838
+ from_bundle_id: null,
8839
+ type: input.type,
8840
+ metadata
8841
+ };
8842
+ }
8843
+ }
8844
+ //#endregion
8845
+ //#region ../insights/dist/server/provider.mjs
8846
+ const DEFAULT_PAGE_LIMIT = 50;
8847
+ const MAX_PAGE_LIMIT$1 = 100;
8848
+ const MAX_EVENT_ID_LENGTH = 1024;
8849
+ const MAX_IDENTITY_LENGTH$1 = 255;
8850
+ const MAX_CURSOR_LENGTH$1 = 8192;
8851
+ const HOUR_MS = 36e5;
8852
+ const DAY_MS = 24 * HOUR_MS;
8853
+ const WINDOW_MS = {
8854
+ "24h": 864e5,
8855
+ "7d": 6048e5,
8856
+ "30d": 2592e6
8857
+ };
9006
8858
  /**
9007
- * The installations of `insights_distribution` that run their native build's
9008
- * built-in bundle, by its ID (`builtin_bundle_id`), within the same rows:
9009
- * reads move them out of the row of their release, or of no release.
9010
- */
9011
- const insightsBuiltinDistribution = (retention) => defineAggregate({
9012
- channel: { type: "string" },
9013
- platform: {
9014
- type: "string",
9015
- maxLength: 16
9016
- },
9017
- app_version: { type: "string" },
9018
- release_id: {
9019
- type: "string",
9020
- maxLength: 36
9021
- },
9022
- builtin_bundle_id: {
9023
- type: "string",
9024
- maxLength: 36
9025
- },
9026
- bucket_start_ms: { type: "integer" }
9027
- }, {
9028
- key: [
9029
- "channel",
9030
- "platform",
9031
- "app_version",
9032
- "release_id",
9033
- "builtin_bundle_id",
9034
- "bucket_start_ms"
9035
- ],
9036
- gauges: ["latest_installations"],
9037
- shards: GAUGE_SHARDS,
9038
- batched: true,
9039
- indexes: {
9040
- byScope: {
9041
- eq: ["channel", "platform"],
9042
- sort: ["bucket_start_ms"]
8859
+ * The longest receipt range, `[sinceMs, beforeReceivedAtMs)`, of a global or
8860
+ * bundle event list, and the range one covers without `sinceMs`: raw events
8861
+ * are listed inside a time range, as analytics products list them. It is a
8862
+ * duration, so a range that ends mid-day touches 91 UTC days. Installation
8863
+ * history is one index range and reads back to `sinceMs`.
8864
+ */
8865
+ const EVENT_LIST_RANGE_MS = 2160 * HOUR_MS;
8866
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8867
+ const requireString = (value, label, maximumLength) => {
8868
+ if (typeof value !== "string" || value.length === 0 || value.length > maximumLength || new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(value)) !== value) throw new InsightsBadRequestError(`Invalid ${label}.`);
8869
+ return value;
8870
+ };
8871
+ const requireTimestamp = (value, label) => {
8872
+ if (!Number.isSafeInteger(value) || Number(value) < 0) throw new InsightsBadRequestError(`Invalid ${label}.`);
8873
+ return Number(value);
8874
+ };
8875
+ const readLimit = (value) => {
8876
+ const limit = value ?? DEFAULT_PAGE_LIMIT;
8877
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT$1) throw new InsightsBadRequestError("Invalid page limit.");
8878
+ return limit;
8879
+ };
8880
+ const encodeBase64Url = (value) => {
8881
+ const bytes = new TextEncoder().encode(value);
8882
+ let binary = "";
8883
+ for (const byte of bytes) binary += String.fromCharCode(byte);
8884
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
8885
+ };
8886
+ const decodeBase64Url = (value) => {
8887
+ if (value.length === 0 || value.length > MAX_CURSOR_LENGTH$1) throw new InsightsBadRequestError("Invalid Insights cursor.");
8888
+ try {
8889
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
8890
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
8891
+ const binary = atob(padded);
8892
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
8893
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
8894
+ } catch {
8895
+ throw new InsightsBadRequestError("Invalid Insights cursor.");
8896
+ }
8897
+ };
8898
+ const encodeCursor = (value) => encodeBase64Url(JSON.stringify(value));
8899
+ const decodeCursor = (value) => {
8900
+ try {
8901
+ return JSON.parse(decodeBase64Url(value));
8902
+ } catch (error) {
8903
+ if (error instanceof InsightsBadRequestError) throw error;
8904
+ throw new InsightsBadRequestError("Invalid Insights cursor.");
8905
+ }
8906
+ };
8907
+ const readScope$1 = (input) => {
8908
+ if (input.platform !== "ios" && input.platform !== "android") throw new InsightsBadRequestError("Invalid Insights platform.");
8909
+ return {
8910
+ platform: input.platform,
8911
+ channel: requireString(input.channel, "channel", MAX_EVENT_ID_LENGTH)
8912
+ };
8913
+ };
8914
+ const bundleFilter = (input) => {
8915
+ const scope = readScope$1(input);
8916
+ const bundleId = requireString(input.bundleId, "bundle ID", MAX_EVENT_ID_LENGTH);
8917
+ switch (input.outcome) {
8918
+ case "downloaded": return {
8919
+ ...scope,
8920
+ type: "UPDATE_DOWNLOADED",
8921
+ toBundleId: bundleId
8922
+ };
8923
+ case "applied": return {
8924
+ ...scope,
8925
+ type: "UPDATE_APPLIED",
8926
+ toBundleId: bundleId
8927
+ };
8928
+ case "recovered": return {
8929
+ ...scope,
8930
+ type: "RECOVERED",
8931
+ fromBundleId: bundleId
8932
+ };
8933
+ case "failed": return {
8934
+ ...scope,
8935
+ type: "UPDATE_FAILED",
8936
+ toBundleId: bundleId
8937
+ };
8938
+ default: throw new InsightsBadRequestError("Invalid Insights outcome.");
8939
+ }
8940
+ };
8941
+ const sameFilter = (left, right) => {
8942
+ if (!isRecord(left) || left.kind !== right.kind) return false;
8943
+ switch (right.kind) {
8944
+ case "all": return true;
8945
+ case "installationMovement": return left.installId === right.installId;
8946
+ case "bundle": return left.platform === right.platform && left.channel === right.channel && left.type === right.type && (right.type === "RECOVERED" ? left.fromBundleId === right.fromBundleId : left.toBundleId === right.toBundleId);
8947
+ }
8948
+ };
8949
+ const readEventCursor = (value, filter) => {
8950
+ const cursor = decodeCursor(value);
8951
+ if (!isRecord(cursor) || cursor.version !== 2 || cursor.kind !== "events" || !isRecord(cursor.filter) || !isRecord(cursor.after)) throw new InsightsBadRequestError("Invalid Insights cursor.");
8952
+ if (!sameFilter(cursor.filter, filter)) throw new InsightsBadRequestError("Insights cursor does not match the requested events.");
8953
+ if (typeof cursor.after.id !== "string" || !isUUIDv7(cursor.after.id)) throw new InsightsBadRequestError("Invalid Insights event cursor ID.");
8954
+ return {
8955
+ after: {
8956
+ id: cursor.after.id,
8957
+ receivedAtMs: requireTimestamp(cursor.after.receivedAtMs, "event cursor")
9043
8958
  },
9044
- byVersion: {
9045
- eq: [
9046
- "channel",
9047
- "platform",
9048
- "app_version"
9049
- ],
9050
- sort: ["bucket_start_ms"]
9051
- }
9052
- },
9053
- retention
8959
+ beforeReceivedAtMs: requireTimestamp(cursor.beforeReceivedAtMs, "event cutoff"),
8960
+ kind: "events",
8961
+ sinceMs: requireTimestamp(cursor.sinceMs, "event start"),
8962
+ filter,
8963
+ version: 2
8964
+ };
8965
+ };
8966
+ const readUserInstallationCursor = (value, userId) => {
8967
+ const cursor = decodeCursor(value);
8968
+ if (!isRecord(cursor) || cursor.version !== 1 || cursor.kind !== "user-installations" || cursor.userId !== userId) throw new InsightsBadRequestError("Insights cursor does not match the requested user ID.");
8969
+ return {
8970
+ afterInstallId: requireString(cursor.afterInstallId, "installation cursor", MAX_IDENTITY_LENGTH$1),
8971
+ kind: "user-installations",
8972
+ userId,
8973
+ version: 1
8974
+ };
8975
+ };
8976
+ const compareEventNewest = (left, right) => right.received_at_ms - left.received_at_ms || compareInsightsText(right.id, left.id);
8977
+ const isAfterEventCursor = (row, after) => row.received_at_ms < after.receivedAtMs || row.received_at_ms === after.receivedAtMs && compareInsightsText(row.id, after.id) < 0;
8978
+ const assertEventRows = (rows, input) => {
8979
+ if (rows.length > input.limit) throw new Error("Insights database returned too many event rows.");
8980
+ for (let index = 0; index < rows.length; index += 1) {
8981
+ const row = rows[index];
8982
+ const previous = rows[index - 1];
8983
+ if (!row || typeof row.id !== "string" || !isUUIDv7(row.id) || !Number.isSafeInteger(row.received_at_ms) || row.received_at_ms >= input.beforeReceivedAtMs || row.received_at_ms < input.sinceMs || previous !== void 0 && compareEventNewest(previous, row) >= 0 || input.after !== void 0 && !isAfterEventCursor(row, input.after) || input.filter.kind === "installationMovement" && (row.install_id !== input.filter.installId || !isInsightsMovementEvent(row)) || input.filter.kind === "bundle" && (row.platform !== input.filter.platform || row.channel !== input.filter.channel || row.type !== input.filter.type || (input.filter.type === "RECOVERED" ? row.from_bundle_id !== input.filter.fromBundleId : row.to_bundle_id !== input.filter.toBundleId))) throw new Error("Insights database returned invalid event rows.");
8984
+ }
8985
+ };
8986
+ /** An `UPDATE_FAILED` row's failure, as the event lists show it. */
8987
+ const toFailure = (failure) => ({
8988
+ stage: failure.stage,
8989
+ reason: failure.reason,
8990
+ ...failure.error_message === void 0 ? {} : { errorMessage: failure.error_message },
8991
+ ...failure.error_stack === void 0 ? {} : { errorStack: failure.error_stack },
8992
+ ...failure.resource === void 0 ? {} : { resource: failure.resource },
8993
+ ...failure.http_status === void 0 ? {} : { httpStatus: failure.http_status },
8994
+ ...failure.transport === void 0 ? {} : { transport: failure.transport },
8995
+ ...failure.origin_code === void 0 ? {} : { originCode: failure.origin_code }
9054
8996
  });
9055
- /** Latest events by the bundle they came from or went to, bucketed like the distribution. */
9056
- const insightsLatestByBundle = (retention) => defineAggregate({
9057
- platform: {
9058
- type: "string",
9059
- maxLength: 16
9060
- },
9061
- channel: { type: "string" },
9062
- bundle_field: {
9063
- type: "string",
9064
- maxLength: 16
9065
- },
9066
- bundle_id: {
9067
- type: "string",
9068
- maxLength: 36
9069
- },
9070
- type: {
9071
- type: "string",
9072
- maxLength: 32
9073
- },
9074
- bucket_start_ms: { type: "integer" }
9075
- }, {
9076
- key: [
9077
- "platform",
9078
- "channel",
9079
- "bundle_field",
9080
- "bundle_id",
9081
- "type",
9082
- "bucket_start_ms"
9083
- ],
9084
- gauges: ["installations"],
9085
- shards: GAUGE_SHARDS,
9086
- batched: true,
9087
- indexes: { byBundle: {
9088
- eq: [
9089
- "platform",
9090
- "channel",
9091
- "bundle_field",
9092
- "bundle_id",
9093
- "type"
9094
- ],
9095
- sort: ["bucket_start_ms"]
9096
- } },
9097
- retention
8997
+ /** What a row's metadata adds for its type: a failure or a delivery. */
8998
+ const eventDetails = ({ type, metadata }) => ({
8999
+ ...metadata.http_response !== void 0 ? { httpResponse: {
9000
+ resource: metadata.http_response.resource,
9001
+ path: metadata.http_response.path,
9002
+ status: metadata.http_response.status,
9003
+ body: metadata.http_response.body,
9004
+ bodyTruncated: metadata.http_response.body_truncated,
9005
+ receivedAtMs: metadata.http_response.received_at_ms
9006
+ } } : {},
9007
+ ...type === "UPDATE_FAILED" && metadata.failure !== void 0 ? { failure: toFailure(metadata.failure) } : {},
9008
+ ...type === "UPDATE_DOWNLOADED" && metadata.delivery !== void 0 ? { delivery: metadata.delivery } : {},
9009
+ ...type === "UPDATE_DOWNLOADED" && metadata.patch_fallback === true ? { patchFallback: true } : {},
9010
+ ...type === "UNCHANGED" && metadata.change !== void 0 ? { change: {
9011
+ kinds: metadata.change.kinds,
9012
+ ...metadata.change.previous === null ? {} : { previous: {
9013
+ bundleId: metadata.change.previous.bundle_id,
9014
+ releaseId: metadata.change.previous.release_id,
9015
+ appVersion: metadata.change.previous.app_version,
9016
+ channel: metadata.change.previous.channel,
9017
+ ...metadata.change.previous.min_bundle_id === void 0 ? {} : { minBundleId: metadata.change.previous.min_bundle_id }
9018
+ } }
9019
+ } } : {},
9020
+ ...metadata.late === true ? { late: true } : {},
9021
+ ...metadata.implied_download === true ? { impliedDownload: true } : {}
9098
9022
  });
9099
- /**
9100
- * Stored events by outcome and the bundle a bundle filter matches, a row an
9101
- * hour, which a failed check has none of; and every stored event, a row a UTC
9102
- * day (`DAILY_EVENTS`). Bundle counts sum the hourly rows, and event lists
9103
- * read either to skip days without events.
9104
- */
9105
- const insightsOutcomes = (retention) => defineAggregate({
9106
- platform: {
9107
- type: "string",
9108
- maxLength: 16
9109
- },
9110
- channel: { type: "string" },
9111
- type: {
9112
- type: "string",
9113
- maxLength: 32
9114
- },
9115
- bundle_ref: {
9116
- type: "string",
9117
- maxLength: 41
9118
- },
9119
- bucket_start_ms: { type: "integer" }
9120
- }, {
9121
- key: [
9122
- "platform",
9123
- "channel",
9124
- "type",
9125
- "bundle_ref",
9126
- "bucket_start_ms"
9127
- ],
9128
- counters: ["events"],
9129
- shards: COUNTER_SHARDS,
9130
- batched: true,
9131
- indexes: { byRef: {
9132
- eq: [
9133
- "platform",
9134
- "channel",
9135
- "type",
9136
- "bundle_ref"
9137
- ],
9138
- sort: ["bucket_start_ms"]
9139
- } },
9140
- retention
9023
+ const toEventHistoryRow = (row) => ({
9024
+ ...row.to_release_id == null ? {} : { toReleaseId: row.to_release_id },
9025
+ ...row.metadata.sdk_version == null ? {} : { sdkVersion: row.metadata.sdk_version },
9026
+ ...row.metadata.min_bundle_id === void 0 ? {} : { minBundleId: row.metadata.min_bundle_id },
9027
+ appVersion: row.app_version,
9028
+ channel: row.channel,
9029
+ cohort: row.metadata.cohort,
9030
+ fromBundleId: row.from_bundle_id,
9031
+ id: row.id,
9032
+ installId: row.install_id,
9033
+ platform: row.platform,
9034
+ receivedAtMs: row.received_at_ms,
9035
+ toBundleId: row.to_bundle_id,
9036
+ type: row.type,
9037
+ userId: row.user_id,
9038
+ ...eventDetails(row)
9039
+ });
9040
+ const toInstallationRow = (row) => ({
9041
+ ...eventDetails(row).httpResponse ? { httpResponse: eventDetails(row).httpResponse } : {},
9042
+ ...row.metadata.min_bundle_id === void 0 ? {} : { minBundleId: row.metadata.min_bundle_id },
9043
+ appVersion: row.app_version,
9044
+ channel: row.channel,
9045
+ cohort: row.metadata.cohort,
9046
+ installId: row.install_id,
9047
+ lastKnownBundleId: row.type === "UPDATE_DOWNLOADED" ? row.from_bundle_id : row.to_bundle_id,
9048
+ pendingBundleId: row.type === "UPDATE_DOWNLOADED" ? row.to_bundle_id : null,
9049
+ pendingReleaseId: row.type === "UPDATE_DOWNLOADED" ? row.to_release_id : null,
9050
+ latestStatus: row.type,
9051
+ platform: row.platform,
9052
+ receivedAtMs: row.received_at_ms,
9053
+ userId: row.user_id
9141
9054
  });
9142
9055
  /**
9143
- * The `insights_outcomes` identity that counts every stored event, for the
9144
- * global event list. Its bucket is a UTC day, not an hour. Every event's
9145
- * platform is ios or android, so no outcome row of a bundle filter shares it.
9056
+ * Where a list without `sinceMs` starts: 90 days before its cutoff for the
9057
+ * global and bundle lists, at the first event for installation history.
9146
9058
  */
9147
- const DAILY_EVENTS = {
9148
- platform: "*",
9149
- channel: "",
9150
- type: "",
9151
- bundle_ref: ""
9059
+ const defaultEventStart = (filter, beforeReceivedAtMs) => filter.kind === "installationMovement" ? 0 : Math.max(0, beforeReceivedAtMs - EVENT_LIST_RANGE_MS);
9060
+ /** Whether a cursor's row lies inside the list's range. */
9061
+ const isInsideBounds = ({ after }, bounds) => after.receivedAtMs >= bounds.sinceMs && after.receivedAtMs < bounds.beforeReceivedAtMs;
9062
+ /** A page's range: its cursor's, which the request may repeat but not change. */
9063
+ const readEventBounds = (input, cursor, filter) => {
9064
+ const beforeReceivedAtMs = cursor?.beforeReceivedAtMs ?? (input.beforeReceivedAtMs === void 0 ? Date.now() : requireTimestamp(input.beforeReceivedAtMs, "event cutoff"));
9065
+ if (cursor !== void 0 && input.beforeReceivedAtMs !== void 0 && input.beforeReceivedAtMs !== beforeReceivedAtMs) throw new InsightsBadRequestError("Insights cursor does not match the requested event cutoff.");
9066
+ const sinceMs = cursor?.sinceMs ?? (input.sinceMs === void 0 ? defaultEventStart(filter, beforeReceivedAtMs) : requireTimestamp(input.sinceMs, "event start"));
9067
+ const bounds = {
9068
+ beforeReceivedAtMs,
9069
+ sinceMs
9070
+ };
9071
+ if (sinceMs > beforeReceivedAtMs || input.sinceMs !== void 0 && input.sinceMs !== sinceMs || cursor !== void 0 && !isInsideBounds(cursor, bounds)) throw new InsightsBadRequestError("Insights cursor or range does not match the requested event start.");
9072
+ if (filter.kind !== "installationMovement" && beforeReceivedAtMs - sinceMs > 7776e6) throw new InsightsBadRequestError("Insights event lists cover at most 90 days: send a sinceMs no more than 90 days before beforeReceivedAtMs.");
9073
+ return bounds;
9152
9074
  };
9153
- /**
9154
- * Insights' tables, each expiring after its period. The periods are values
9155
- * the engine applies at runtime; the tables and indexes are the same for
9156
- * any periods.
9157
- */
9158
- const createInsightsSchema = ({ rawDays, dailyDays } = DEFAULT_INSIGHTS_RETENTION) => {
9159
- const hourly = bucketRetention(rawDays);
9160
- const daily = bucketRetention(dailyDays);
9075
+ const pageEventRows = async (model, input, filter, map) => {
9076
+ const limit = readLimit(input.limit);
9077
+ const cursor = input.cursor === void 0 ? void 0 : readEventCursor(input.cursor, filter);
9078
+ const bounds = readEventBounds(input, cursor, filter);
9079
+ const query = {
9080
+ filter,
9081
+ ...bounds,
9082
+ ...cursor === void 0 ? {} : { after: cursor.after },
9083
+ limit
9084
+ };
9085
+ const rows = await model.listEvents(query);
9086
+ assertEventRows(rows, query);
9087
+ const last = rows.at(-1);
9161
9088
  return {
9162
- bundle_events: bundleEvents(rawDays),
9163
- bundle_event_heads: bundleEventHeads(dailyDays),
9164
- /** Hourly counters and sketches: the raw period. */
9165
- insights_overview: counters(hourly),
9166
- insights_sketches: sketches(hourly),
9167
- /** Daily counters and sketches: the daily period. */
9168
- insights_overview_daily: counters(daily),
9169
- insights_sketches_daily: sketches(daily),
9170
- /** A release's counters since its first event, and its failed installations: kept. */
9171
- insights_overview_lifetime: lifetimeCounters(),
9172
- insights_sketches_lifetime: lifetimeSketches(),
9173
- insights_distribution: insightsDistribution(daily),
9174
- insights_builtin_distribution: insightsBuiltinDistribution(daily),
9175
- insights_latest_by_bundle: insightsLatestByBundle(daily),
9176
- insights_outcomes: insightsOutcomes(hourly),
9177
- insights_failures: insightsFailures(hourly)
9089
+ beforeReceivedAtMs: bounds.beforeReceivedAtMs,
9090
+ data: rows.map(map),
9091
+ nextCursor: rows.length === limit && last ? encodeCursor({
9092
+ after: {
9093
+ id: last.id,
9094
+ receivedAtMs: last.received_at_ms
9095
+ },
9096
+ ...bounds,
9097
+ kind: "events",
9098
+ filter,
9099
+ version: 2
9100
+ }) : null
9178
9101
  };
9179
9102
  };
9180
- createInsightsSchema();
9103
+ const assertInstallationRows = (rows, input) => {
9104
+ if (rows.length > input.limit) throw new Error("Insights database returned too many installation rows.");
9105
+ for (let index = 0; index < rows.length; index += 1) {
9106
+ const row = rows[index];
9107
+ const previous = rows[index - 1];
9108
+ if (!row || row.user_id !== input.userId || input.afterInstallId !== void 0 && compareInsightsText(row.install_id, input.afterInstallId) <= 0 || previous !== void 0 && compareInsightsText(previous.install_id, row.install_id) >= 0) throw new Error("Insights database returned invalid installation rows.");
9109
+ }
9110
+ };
9111
+ const createInsightsProvider = (model) => Object.freeze({
9112
+ async appendBundleEvent(input) {
9113
+ const event = createBundleEventRow(input);
9114
+ await model.recordEvent({ event });
9115
+ },
9116
+ listEvents(input) {
9117
+ const filter = input.bundle === void 0 ? { kind: "all" } : {
9118
+ kind: "bundle",
9119
+ ...bundleFilter(input.bundle)
9120
+ };
9121
+ return pageEventRows(model, input, filter, toEventHistoryRow);
9122
+ },
9123
+ async listInstallationEvents(input) {
9124
+ if ("bundle" in input && input.bundle !== void 0) throw new InsightsBadRequestError("Installation movement queries cannot include a bundle filter.");
9125
+ const installId = requireString(input.installId, "install ID", MAX_IDENTITY_LENGTH$1);
9126
+ return pageEventRows(model, input, {
9127
+ kind: "installationMovement",
9128
+ installId
9129
+ }, (row) => toEventHistoryRow(row));
9130
+ },
9131
+ async getInstallation({ installId }) {
9132
+ const normalizedInstallId = requireString(installId, "install ID", MAX_IDENTITY_LENGTH$1);
9133
+ const rows = await model.findLatestEvents({ installId: normalizedInstallId });
9134
+ const row = rows[0] ?? null;
9135
+ if (rows.length > 1 || row !== null && row.install_id !== normalizedInstallId) throw new Error("Insights database returned an invalid installation.");
9136
+ return row === null ? null : toInstallationRow(row);
9137
+ },
9138
+ async pageInstallationsByCurrentUserId(input) {
9139
+ const userId = requireString(input.userId, "user ID", MAX_IDENTITY_LENGTH$1);
9140
+ const limit = readLimit(input.limit);
9141
+ const cursor = input.cursor === void 0 ? void 0 : readUserInstallationCursor(input.cursor, userId);
9142
+ const databaseInput = {
9143
+ userId,
9144
+ ...cursor === void 0 ? {} : { afterInstallId: cursor.afterInstallId },
9145
+ limit
9146
+ };
9147
+ const rows = await model.findLatestEvents(databaseInput);
9148
+ assertInstallationRows(rows, databaseInput);
9149
+ const last = rows.at(-1);
9150
+ return {
9151
+ data: rows.map(toInstallationRow),
9152
+ nextCursor: rows.length === limit && last ? encodeCursor({
9153
+ afterInstallId: last.install_id,
9154
+ kind: "user-installations",
9155
+ userId,
9156
+ version: 1
9157
+ }) : null
9158
+ };
9159
+ },
9160
+ async getReportingOverview(input) {
9161
+ const scope = readScope$1(input);
9162
+ const { window } = input;
9163
+ if (!Object.hasOwn(WINDOW_MS, window)) throw new InsightsBadRequestError("Invalid reporting installation window.");
9164
+ const now = Date.now();
9165
+ const beforeReceivedAtMs = now - now % HOUR_MS + (now % HOUR_MS === 0 ? 0 : HOUR_MS);
9166
+ const reach = Math.max(0, beforeReceivedAtMs - WINDOW_MS[window]);
9167
+ const sinceMs = reach - reach % DAY_MS;
9168
+ const measure = async (count) => {
9169
+ const value = await count;
9170
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Insights database returned an invalid count.");
9171
+ return {
9172
+ count: value,
9173
+ measuredAtMs: Date.now()
9174
+ };
9175
+ };
9176
+ const bundleId = input.bundleId === void 0 ? void 0 : requireString(input.bundleId, "bundle ID", MAX_EVENT_ID_LENGTH);
9177
+ const reporting = measure(model.countLatestEvents({
9178
+ ...scope,
9179
+ sinceMs
9180
+ }));
9181
+ if (bundleId === void 0) return {
9182
+ ...scope,
9183
+ window,
9184
+ sinceMs,
9185
+ beforeReceivedAtMs,
9186
+ reportingInstallations: await reporting
9187
+ };
9188
+ const countOutcome = (outcome) => measure(model.countEvents({
9189
+ filter: bundleFilter({
9190
+ ...scope,
9191
+ bundleId,
9192
+ outcome
9193
+ }),
9194
+ sinceMs,
9195
+ beforeReceivedAtMs
9196
+ }));
9197
+ const [reportingInstallations, bundleInstallations, downloadedReports, appliedReports, recoveredReports, failedReports] = await Promise.all([
9198
+ reporting,
9199
+ measure(model.countLatestEvents({
9200
+ ...scope,
9201
+ sinceMs,
9202
+ bundle: [{
9203
+ field: "from_bundle_id",
9204
+ value: bundleId,
9205
+ types: ["UPDATE_DOWNLOADED"]
9206
+ }, {
9207
+ field: "to_bundle_id",
9208
+ value: bundleId,
9209
+ types: [
9210
+ "UNCHANGED",
9211
+ "UPDATE_APPLIED",
9212
+ "RECOVERED"
9213
+ ]
9214
+ }]
9215
+ })),
9216
+ countOutcome("downloaded"),
9217
+ countOutcome("applied"),
9218
+ countOutcome("recovered"),
9219
+ countOutcome("failed")
9220
+ ]);
9221
+ return {
9222
+ ...scope,
9223
+ window,
9224
+ sinceMs,
9225
+ beforeReceivedAtMs,
9226
+ reportingInstallations,
9227
+ bundle: {
9228
+ bundleId,
9229
+ reportingInstallations: bundleInstallations,
9230
+ downloadedReports,
9231
+ appliedReports,
9232
+ recoveredReports,
9233
+ failedReports
9234
+ }
9235
+ };
9236
+ }
9237
+ });
9181
9238
  //#endregion
9182
9239
  //#region ../insights/dist/server/overview.mjs
9183
9240
  const encodeIdentity = (identity) => [
@@ -9245,18 +9302,19 @@ const insightsOverviewDeltas = (event) => {
9245
9302
  bucketStartMs
9246
9303
  }, windowed);
9247
9304
  };
9248
- if (event.type === "UPDATE_DOWNLOADED") metric(event.to_release_id, {
9305
+ const implied = event.metadata.implied_download === true ? 1 : 0;
9306
+ if (!isLateEvent(event) && event.type === "UPDATE_DOWNLOADED") metric(event.to_release_id, {
9249
9307
  downloads: 1,
9250
9308
  applies: 0,
9251
9309
  failedLaunches: 0
9252
9310
  });
9253
- else if (event.type === "UPDATE_APPLIED") metric(event.to_release_id, {
9254
- downloads: 0,
9311
+ else if (!isLateEvent(event) && isLaunch(event)) metric(event.to_release_id, {
9312
+ downloads: implied,
9255
9313
  applies: 1,
9256
9314
  failedLaunches: 0
9257
9315
  });
9258
- else if (event.type === "RECOVERED") metric(event.from_release_id, {
9259
- downloads: 0,
9316
+ else if (!isLateEvent(event) && event.type === "RECOVERED") metric(event.from_release_id, {
9317
+ downloads: implied,
9260
9318
  applies: 0,
9261
9319
  failedLaunches: 1
9262
9320
  });
@@ -9280,8 +9338,8 @@ const insightsOverviewDeltas = (event) => {
9280
9338
  };
9281
9339
  //#endregion
9282
9340
  //#region ../insights/dist/server/recordEvent.mjs
9283
- const hourOf = (ms) => ms - ms % HOUR_MS;
9284
- const dayOf = (ms) => ms - ms % DAY_MS;
9341
+ const hourOf = (ms) => ms - ms % HOUR_MS$1;
9342
+ const dayOf = (ms) => ms - ms % DAY_MS$1;
9285
9343
  /** The `identity` of an overview or sketch row: its scope and period, hashed. */
9286
9344
  const insightsIdentity = (identity) => insightsOverviewId({
9287
9345
  ...identity,
@@ -9412,16 +9470,16 @@ const countEvent = (tx, event) => {
9412
9470
  }
9413
9471
  };
9414
9472
  /**
9415
- * A stored event's outcome rows: its bundle filter's hour, which a failed
9416
- * check has none of, and every stored event's UTC day.
9473
+ * A stored event's outcome rows: its bundle filter's hour, when it has one
9474
+ * (`bundleRefsOf`), and every stored event's UTC day.
9417
9475
  */
9418
9476
  const countOutcome = (tx, event) => {
9419
9477
  const shardBy = event.install_id;
9420
- if (!isFailedCheck(event)) tx.aggregate("insights_outcomes", {
9478
+ for (const bundleRef of bundleRefsOf(event)) tx.aggregate("insights_outcomes", {
9421
9479
  platform: event.platform,
9422
9480
  channel: event.channel,
9423
9481
  type: event.type,
9424
- bundle_ref: event.type === "RECOVERED" ? `from:${event.from_bundle_id}` : `to:${event.to_bundle_id}`,
9482
+ bundle_ref: bundleRef,
9425
9483
  bucket_start_ms: hourOf(event.received_at_ms)
9426
9484
  }, { events: 1 }, { shardBy });
9427
9485
  tx.aggregate("insights_outcomes", {
@@ -9525,6 +9583,126 @@ const countFailure = (tx, event) => {
9525
9583
  }, { events: 1 }, { shardBy });
9526
9584
  };
9527
9585
  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;
9586
+ /** What a head says its installation runs: a download leaves it at the source. */
9587
+ const runningOf = (head) => head.type === "UPDATE_DOWNLOADED" ? {
9588
+ bundle_id: head.from_bundle_id,
9589
+ release_id: head.from_release_id
9590
+ } : {
9591
+ bundle_id: head.to_bundle_id,
9592
+ release_id: head.to_release_id
9593
+ };
9594
+ /** The native build a report or head came from, when its SDK says. */
9595
+ const minBundleIdOf = (row) => row.metadata?.min_bundle_id;
9596
+ /** When the client made a report, from the UUIDv7 it gave it. */
9597
+ const madeAt = (id) => isUUIDv7(id) ? extractTimestampFromUUIDv7(id) : null;
9598
+ /**
9599
+ * A report that arrived after its installation moved past it: a download or
9600
+ * an apply of the bundle the head already runs, or an UNCHANGED report of the
9601
+ * bundle an apply head left, made before that apply. A reload can cut one
9602
+ * runtime's report short and deliver it after the next runtime's. It stays
9603
+ * in history but moves and counts nothing.
9604
+ */
9605
+ const isLate = (event, head) => {
9606
+ if (head === null) return false;
9607
+ if (event.type === "UPDATE_DOWNLOADED" || event.type === "UPDATE_APPLIED") return event.to_bundle_id === runningOf(head).bundle_id;
9608
+ if (event.type === "UNCHANGED" && head.type === "UPDATE_APPLIED" && event.to_bundle_id === head.from_bundle_id) {
9609
+ const made = madeAt(event.id);
9610
+ const applied = madeAt(head.id);
9611
+ return made !== null && applied !== null && made < applied;
9612
+ }
9613
+ return false;
9614
+ };
9615
+ /**
9616
+ * What an UNCHANGED report changes against its installation's head: the
9617
+ * running bundle or release, the app version, the channel, or the native
9618
+ * build (when both name it), with the head's values before it; `first_seen`
9619
+ * when the installation has no head. A report the head already answers, one
9620
+ * older than the head, a user switch and any other event type change
9621
+ * nothing, so no row keeps them.
9622
+ */
9623
+ const changeOf = (event, head) => {
9624
+ if (event.type !== "UNCHANGED") return null;
9625
+ if (head === null) return {
9626
+ kinds: ["first_seen"],
9627
+ previous: null
9628
+ };
9629
+ if (!isNewer(event, head)) return null;
9630
+ const running = runningOf(head);
9631
+ const build = minBundleIdOf(event);
9632
+ const headBuild = minBundleIdOf(head);
9633
+ const kinds = [];
9634
+ if (event.to_bundle_id !== running.bundle_id) kinds.push("bundle");
9635
+ if (event.to_release_id !== running.release_id) kinds.push("release");
9636
+ if (event.app_version !== head.app_version) kinds.push("app_version");
9637
+ if (event.channel !== head.channel) kinds.push("channel");
9638
+ if (build !== void 0 && headBuild !== void 0 && build !== headBuild) kinds.push("native_build");
9639
+ return kinds.length === 0 ? null : {
9640
+ kinds,
9641
+ previous: {
9642
+ bundle_id: running.bundle_id,
9643
+ release_id: running.release_id,
9644
+ app_version: head.app_version,
9645
+ channel: head.channel,
9646
+ ...headBuild === void 0 ? {} : { min_bundle_id: headBuild }
9647
+ }
9648
+ };
9649
+ };
9650
+ /**
9651
+ * Whether a launch or a crash comes with no download report for its target:
9652
+ * its head shows neither that download nor the target already running. The
9653
+ * native build's built-in bundle is never downloaded, and without a head the
9654
+ * server knows nothing of the installation's past, so it implies nothing.
9655
+ */
9656
+ const impliesDownload = (event, head) => {
9657
+ const target = event.type === "RECOVERED" ? {
9658
+ bundle: event.from_bundle_id,
9659
+ release: event.from_release_id
9660
+ } : isLaunch(event) ? {
9661
+ bundle: event.to_bundle_id,
9662
+ release: event.to_release_id
9663
+ } : null;
9664
+ if (target === null || target.release === null || target.bundle === minBundleIdOf(event)) return false;
9665
+ if (head === null) return false;
9666
+ if (head.type === "UPDATE_DOWNLOADED" && head.to_bundle_id === target.bundle) return false;
9667
+ return runningOf(head).bundle_id !== target.bundle;
9668
+ };
9669
+ /**
9670
+ * The event as recorded, with what only the server sets: what a kept
9671
+ * UNCHANGED report changed, a late report's flag, and a download its launch
9672
+ * or crash implied.
9673
+ */
9674
+ const recordedOf = (event, head) => {
9675
+ const { change: _change, late: _late, implied_download: _implied, ...metadata } = event.metadata;
9676
+ const reported = {
9677
+ ...event,
9678
+ metadata
9679
+ };
9680
+ const late = isLate(reported, head);
9681
+ const change = late ? null : changeOf(reported, head);
9682
+ const withChange = change === null ? reported : {
9683
+ ...reported,
9684
+ metadata: {
9685
+ ...metadata,
9686
+ change
9687
+ }
9688
+ };
9689
+ return {
9690
+ recorded: late && reported.type !== "UNCHANGED" ? {
9691
+ ...reported,
9692
+ metadata: {
9693
+ ...metadata,
9694
+ late: true
9695
+ }
9696
+ } : !late && impliesDownload(withChange, head) ? {
9697
+ ...withChange,
9698
+ metadata: {
9699
+ ...withChange.metadata,
9700
+ implied_download: true
9701
+ }
9702
+ } : withChange,
9703
+ late
9704
+ };
9705
+ };
9528
9706
  /** What an UNCHANGED report must share with its installation's head to repeat it. */
9529
9707
  const REPEATED_FIELDS = [
9530
9708
  "channel",
@@ -9559,32 +9737,33 @@ const recordEvent = (db, event) => {
9559
9737
  const [existing, previous] = await Promise.all([tx.findOne("bundle_events", { id: event.id }), tx.findOne("bundle_event_heads", { install_id: event.install_id })]);
9560
9738
  if (existing !== null || previous?.id === event.id) return;
9561
9739
  if (previous !== null && repeatsHead(event, previous)) return;
9562
- if (event.type !== "UNCHANGED") {
9563
- tx.create("bundle_events", event);
9564
- countOutcome(tx, event);
9740
+ const { recorded, late } = recordedOf(event, previous);
9741
+ if (recorded.type !== "UNCHANGED" || recorded.metadata.change !== void 0 && !late) {
9742
+ tx.create("bundle_events", recorded);
9743
+ countOutcome(tx, recorded);
9565
9744
  }
9566
- if (event.type === "UPDATE_FAILED") {
9567
- countFailure(tx, event);
9745
+ if (recorded.type === "UPDATE_FAILED") {
9746
+ countFailure(tx, recorded);
9568
9747
  return;
9569
9748
  }
9570
- countEvent(tx, event);
9571
- if (previous !== null && !isNewer(event, previous)) return;
9572
- if (previous === null) tx.create("bundle_event_heads", event);
9749
+ countEvent(tx, recorded);
9750
+ if (previous !== null && (late || !isNewer(recorded, previous))) return;
9751
+ if (previous === null) tx.create("bundle_event_heads", recorded);
9573
9752
  else {
9574
- const { install_id: _, ...fields } = event;
9753
+ const { install_id: _, ...fields } = recorded;
9575
9754
  countHead(tx, previous, -1);
9576
9755
  tx.update("bundle_event_heads", previous, fields);
9577
9756
  }
9578
- countHead(tx, event, 1);
9757
+ countHead(tx, recorded, 1);
9579
9758
  });
9580
9759
  };
9581
9760
  //#endregion
9582
9761
  //#region ../insights/dist/server/reads.mjs
9583
9762
  const PAGE = 500;
9584
- const hourFloor = (ms) => ms - ms % HOUR_MS;
9585
- const hourCeil = (ms) => hourFloor(ms + HOUR_MS - 1);
9586
- const dayFloor = (ms) => ms - ms % DAY_MS;
9587
- const dayCeil = (ms) => dayFloor(ms + DAY_MS - 1);
9763
+ const hourFloor = (ms) => ms - ms % HOUR_MS$1;
9764
+ const hourCeil = (ms) => hourFloor(ms + HOUR_MS$1 - 1);
9765
+ const dayFloor = (ms) => ms - ms % DAY_MS$1;
9766
+ const dayCeil = (ms) => dayFloor(ms + DAY_MS$1 - 1);
9588
9767
  /** Every page of one read, in order. */
9589
9768
  const drain = async (read) => {
9590
9769
  const rows = [];
@@ -9613,12 +9792,11 @@ const EVENT_FIELDS = [
9613
9792
  ];
9614
9793
  /** A stored event or head, as the event row it holds. */
9615
9794
  const toEvent = (row) => Object.fromEntries(EVENT_FIELDS.map((field) => [field, row[field]]));
9616
- const bundleRef = (filter) => filter.type === "RECOVERED" ? `from:${filter.fromBundleId}` : `to:${filter.toBundleId}`;
9617
9795
  const scopeOf = (filter) => ({
9618
9796
  platform: filter.platform,
9619
9797
  channel: filter.channel,
9620
9798
  type: filter.type,
9621
- bundle_ref: bundleRef(filter)
9799
+ bundle_ref: bundleRefOfFilter(filter)
9622
9800
  });
9623
9801
  /** One UTC day of a global or bundle list, newest first. */
9624
9802
  const eventsOfDay = (db, filter, day, range, limit) => filter.kind === "all" ? db.findMany("bundle_events", {
@@ -9691,7 +9869,7 @@ const listEvents = async (db, input) => {
9691
9869
  while (day !== void 0 && day >= bottom && rows.length < limit) {
9692
9870
  const page = await eventsOfDay(db, filter, day, range, limit - rows.length);
9693
9871
  rows.push(...page.rows.map(toEvent));
9694
- day = page.rows.length > 0 ? day - DAY_MS : day > bottom ? await newestDayBelow(db, filter, day, since) : void 0;
9872
+ day = page.rows.length > 0 ? day - DAY_MS$1 : day > bottom ? await newestDayBelow(db, filter, day, since) : void 0;
9695
9873
  }
9696
9874
  return rows;
9697
9875
  };
@@ -9712,7 +9890,7 @@ const findLatestEvents = async (db, input) => {
9712
9890
  /** Raw events of one bundle filter over [from, to), which spans at most two partial hours. */
9713
9891
  const countRaw = async (db, filter, from, to) => {
9714
9892
  let total = 0;
9715
- for (let day = dayFloor(from); from < to && day < to; day += DAY_MS) {
9893
+ for (let day = dayFloor(from); from < to && day < to; day += DAY_MS$1) {
9716
9894
  const rows = await drain((page) => db.findMany("bundle_events", {
9717
9895
  index: "byBundle",
9718
9896
  where: {
@@ -9836,8 +10014,8 @@ const countLatestEvents = async (db, input) => {
9836
10014
  };
9837
10015
  /** The oldest whole hour and UTC day that hourly and daily rows still hold. */
9838
10016
  const retained = (now, { rawDays, dailyDays }) => ({
9839
- hour: hourCeil(now - rawDays * DAY_MS),
9840
- day: dayCeil(now - dailyDays * DAY_MS)
10017
+ hour: hourCeil(now - rawDays * DAY_MS$1),
10018
+ day: dayCeil(now - dailyDays * DAY_MS$1)
9841
10019
  });
9842
10020
  /**
9843
10021
  * Whether the rows a window reads still hold all of it; an older start is
@@ -10077,7 +10255,7 @@ const getAppUsage = async (db, input, now, retention) => {
10077
10255
  };
10078
10256
  };
10079
10257
  /** The longest period the failures read covers: the console's longest, 30 days. */
10080
- const UPDATE_FAILURES_RANGE_MS = 30 * DAY_MS;
10258
+ const UPDATE_FAILURES_RANGE_MS = 30 * DAY_MS$1;
10081
10259
  const isRange = (range) => range === void 0 || Number.isSafeInteger(range.start) && Number.isSafeInteger(range.end) && range.start >= 0 && range.start < range.end && range.end - range.start <= UPDATE_FAILURES_RANGE_MS;
10082
10260
  const isText = (value, maxLength) => typeof value === "string" && value.length > 0 && value.length <= maxLength;
10083
10261
  const failed = (...rows) => countDistinct(mergeDistinct(rows.flat().map((row) => row.failed_users)));