@hot-updater/firebase 1.0.0-rc.1 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ let firebase_admin_firestore = require("firebase-admin/firestore");
6
6
  let firebase_admin_storage = require("firebase-admin/storage");
7
7
  //#endregion
8
8
  //#region ../../packages/server/dist/version.mjs
9
- const HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.1";
9
+ const HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.2";
10
10
  //#endregion
11
11
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
12
12
  const createVersionRouteHandlers = () => ({ version: async () => Response.json({
@@ -339,6 +339,7 @@ const resolveManifestAssetStorageUri = ({ assetBaseStorageUri, assetPath, downlo
339
339
  //#endregion
340
340
  //#region ../plugin-core/dist/databasePluginCrudValidationErrors.mjs
341
341
  var DatabasePluginInputError = class extends Error {
342
+ code;
342
343
  name = "DatabasePluginInputError";
343
344
  constructor(code) {
344
345
  super(`Invalid database plugin input: ${code}`);
@@ -493,7 +494,7 @@ function getRolledOutNumericCohorts(bundleId, rolloutCohortCount) {
493
494
  return Array.from({ length: normalizedRolloutCount }, (_, position) => positiveMod(multiplier * position + offset, NUMERIC_COHORT_SIZE)).map((zeroBasedCohort) => zeroBasedCohort + 1).sort((left, right) => left - right);
494
495
  }
495
496
  const RELEASE_CATALOG_FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
496
- const MAX_COMPILED_CATALOG_BYTES = 256 * 1024;
497
+ const MAX_COMPILED_CATALOG_BYTES = 262144;
497
498
  const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
498
499
  const MAX_CATALOG_SEGMENT_LENGTH = 255;
499
500
  function assertCatalogSegment(value, name) {
@@ -600,7 +601,7 @@ const isUUIDv7 = (value) => typeof value === "string" && UUID_V7_PATTERN.test(va
600
601
  //#endregion
601
602
  //#region ../plugin-core/dist/uuidv7.mjs
602
603
  function createUUIDv7FromTimestampHex(timestampHex) {
603
- const randomBytes = new Uint8Array(10);
604
+ const randomBytes = /* @__PURE__ */ new Uint8Array(10);
604
605
  crypto.getRandomValues(randomBytes);
605
606
  const randomHex = Array.from(randomBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
606
607
  const randA = randomHex.slice(0, 3);
@@ -697,6 +698,19 @@ const databaseFields = {
697
698
  "sdk_version",
698
699
  "received_at_ms"
699
700
  ],
701
+ bundle_installations: [
702
+ "id",
703
+ "install_id",
704
+ "user_id",
705
+ "username",
706
+ "to_bundle_id",
707
+ "type",
708
+ "platform",
709
+ "app_version",
710
+ "channel",
711
+ "cohort",
712
+ "received_at_ms"
713
+ ],
700
714
  api_keys: [
701
715
  "id",
702
716
  "hash",
@@ -709,7 +723,7 @@ const databaseFields = {
709
723
  };
710
724
  //#endregion
711
725
  //#region ../plugin-core/dist/databasePluginCrudValidationFields.mjs
712
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
726
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
713
727
  const isChannelText = (value) => {
714
728
  if (typeof value !== "string" || value.length === 0) return false;
715
729
  let codePointCount = 0;
@@ -719,6 +733,8 @@ const isChannelText = (value) => {
719
733
  }
720
734
  return true;
721
735
  };
736
+ const isInsightsIdentityText = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
737
+ const isNullableInsightsIdentityText = (value) => value === null || isInsightsIdentityText(value);
722
738
  const modelValidators = {
723
739
  bundles: {
724
740
  id: (value) => typeof value === "string",
@@ -794,8 +810,8 @@ const modelValidators = {
794
810
  bundle_events: {
795
811
  id: (value) => typeof value === "string",
796
812
  type: (value) => value === "UPDATE_APPLIED" || value === "RECOVERED" || value === "RELEASE_ADOPTED" || value === "UNCHANGED",
797
- install_id: (value) => typeof value === "string",
798
- user_id: (value) => value === null || typeof value === "string",
813
+ install_id: isInsightsIdentityText,
814
+ user_id: isNullableInsightsIdentityText,
799
815
  username: (value) => value === null || typeof value === "string",
800
816
  from_bundle_id: (value) => value === null || typeof value === "string",
801
817
  from_release_id: (value) => value === null || typeof value === "string",
@@ -810,6 +826,19 @@ const modelValidators = {
810
826
  sdk_version: (value) => value === null || typeof value === "string",
811
827
  received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
812
828
  },
829
+ bundle_installations: {
830
+ id: (value) => typeof value === "string",
831
+ install_id: isInsightsIdentityText,
832
+ user_id: isNullableInsightsIdentityText,
833
+ username: (value) => value === null || typeof value === "string",
834
+ to_bundle_id: (value) => typeof value === "string",
835
+ type: (value) => value === "UPDATE_APPLIED" || value === "RECOVERED" || value === "RELEASE_ADOPTED" || value === "UNCHANGED",
836
+ platform: (value) => value === "ios" || value === "android",
837
+ app_version: (value) => typeof value === "string",
838
+ channel: (value) => typeof value === "string",
839
+ cohort: (value) => typeof value === "string",
840
+ received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
841
+ },
813
842
  api_keys: {
814
843
  id: (value) => typeof value === "string",
815
844
  hash: (value) => typeof value === "string",
@@ -820,7 +849,7 @@ const modelValidators = {
820
849
  revoked_at_ms: (value) => value === null || typeof value === "number" && Number.isSafeInteger(value) && value >= 0
821
850
  }
822
851
  };
823
- const stringFields = new Set([
852
+ const stringFields = /* @__PURE__ */ new Set([
824
853
  "id",
825
854
  "platform",
826
855
  "file_hash",
@@ -861,7 +890,7 @@ const stringFields = new Set([
861
890
  "prefix",
862
891
  "role"
863
892
  ]);
864
- const numberFields = new Set([
893
+ const numberFields = /* @__PURE__ */ new Set([
865
894
  "archive_byte_size",
866
895
  "byte_size",
867
896
  "rollout_cohort_count",
@@ -874,13 +903,13 @@ const numberFields = new Set([
874
903
  "created_at_ms",
875
904
  "revoked_at_ms"
876
905
  ]);
877
- const booleanFields = new Set([
906
+ const booleanFields = /* @__PURE__ */ new Set([
878
907
  "should_force_update",
879
908
  "enabled",
880
909
  "is_tombstone"
881
910
  ]);
882
911
  const sortableFields = {
883
- bundles: new Set([
912
+ bundles: /* @__PURE__ */ new Set([
884
913
  "id",
885
914
  "platform",
886
915
  "file_hash",
@@ -897,7 +926,7 @@ const sortableFields = {
897
926
  "manifest_file_hash",
898
927
  "asset_base_storage_uri"
899
928
  ]),
900
- bundle_patches: new Set([
929
+ bundle_patches: /* @__PURE__ */ new Set([
901
930
  "id",
902
931
  "bundle_id",
903
932
  "base_bundle_id",
@@ -907,7 +936,7 @@ const sortableFields = {
907
936
  "byte_size",
908
937
  "order_index"
909
938
  ]),
910
- releases: new Set([
939
+ releases: /* @__PURE__ */ new Set([
911
940
  "id",
912
941
  "revision",
913
942
  "scope_key",
@@ -927,7 +956,7 @@ const sortableFields = {
927
956
  "created_at_ms",
928
957
  "updated_at_ms"
929
958
  ]),
930
- release_catalogs: new Set([
959
+ release_catalogs: /* @__PURE__ */ new Set([
931
960
  "scope_key",
932
961
  "catalog_id",
933
962
  "strategy",
@@ -941,8 +970,8 @@ const sortableFields = {
941
970
  "is_tombstone",
942
971
  "updated_at_ms"
943
972
  ]),
944
- channels: new Set(["id", "name"]),
945
- bundle_events: new Set([
973
+ channels: /* @__PURE__ */ new Set(["id", "name"]),
974
+ bundle_events: /* @__PURE__ */ new Set([
946
975
  "id",
947
976
  "type",
948
977
  "install_id",
@@ -961,7 +990,20 @@ const sortableFields = {
961
990
  "sdk_version",
962
991
  "received_at_ms"
963
992
  ]),
964
- api_keys: new Set([
993
+ bundle_installations: /* @__PURE__ */ new Set([
994
+ "id",
995
+ "install_id",
996
+ "user_id",
997
+ "username",
998
+ "to_bundle_id",
999
+ "type",
1000
+ "platform",
1001
+ "app_version",
1002
+ "channel",
1003
+ "cohort",
1004
+ "received_at_ms"
1005
+ ]),
1006
+ api_keys: /* @__PURE__ */ new Set([
965
1007
  "id",
966
1008
  "hash",
967
1009
  "name",
@@ -995,7 +1037,7 @@ const hasValidReleaseInvariants = (data) => {
995
1037
  };
996
1038
  const hasValidBundleEventInvariants = (data) => (data.type === "UPDATE_APPLIED" || data.type === "RECOVERED" || data.type === "RELEASE_ADOPTED") && typeof data.from_bundle_id === "string" && (data.update_strategy === "fingerprint" || data.update_strategy === "appVersion") || data.type === "UNCHANGED" && data.from_bundle_id === null && data.update_strategy === null;
997
1039
  const validateCreateData = (model, data) => {
998
- if (!isRecord$2(data)) throw new DatabasePluginInputError("invalid-data");
1040
+ if (!isRecord$3(data)) throw new DatabasePluginInputError("invalid-data");
999
1041
  validateFields(model, Object.keys(data));
1000
1042
  for (const field of databaseFields[model]) {
1001
1043
  const validator = modelValidators[model][field];
@@ -1012,7 +1054,7 @@ const selectRow = (row, input) => {
1012
1054
  return Object.fromEntries(select.map((field) => [field, Reflect.get(row, field)]));
1013
1055
  };
1014
1056
  const validateResult = (model, row, select) => {
1015
- if (!isRecord$2(row)) throw new DatabasePluginInputError("invalid-result");
1057
+ if (!isRecord$3(row)) throw new DatabasePluginInputError("invalid-result");
1016
1058
  const fields = select ?? databaseFields[model];
1017
1059
  for (const field of fields) {
1018
1060
  const validator = modelValidators[model][field];
@@ -1033,17 +1075,182 @@ const validateResult = (model, row, select) => {
1033
1075
  ].every((field) => Object.hasOwn(row, field)) && !hasValidBundleEventInvariants(row)) throw new DatabasePluginInputError("invalid-result");
1034
1076
  };
1035
1077
  //#endregion
1078
+ //#region ../plugin-core/dist/insightsContract.mjs
1079
+ const encoder = new TextEncoder();
1080
+ /** Exact UTF-8 byte ordering, without case folding or Unicode normalization. */
1081
+ const compareInsightsText = (left, right) => {
1082
+ const a = encoder.encode(left);
1083
+ const b = encoder.encode(right);
1084
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) if (a[index] !== b[index]) return a[index] - b[index];
1085
+ return a.length - b.length;
1086
+ };
1087
+ const isWellFormedText = (value) => {
1088
+ for (const character of value) {
1089
+ const point = character.codePointAt(0);
1090
+ if (point >= 55296 && point <= 57343) return false;
1091
+ }
1092
+ return true;
1093
+ };
1094
+ const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255 && isWellFormedText(value);
1095
+ const isText = (value) => typeof value === "string" && value.length > 0 && isWellFormedText(value);
1096
+ const isTimestamp = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1097
+ const isLimit = (value) => isTimestamp(value) && value >= 1 && value <= 101;
1098
+ const hasOnlyKeys$1 = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
1099
+ const hasScope = (value) => (value.platform === "ios" || value.platform === "android") && isText(value.channel);
1100
+ const isBundleFilter = (value, withKind = false) => {
1101
+ if (!isRecord$3(value) || !hasScope(value)) return false;
1102
+ const keys = [
1103
+ "platform",
1104
+ "channel",
1105
+ "type",
1106
+ ...withKind ? ["kind"] : []
1107
+ ];
1108
+ return value.type === "RECOVERED" ? isText(value.fromBundleId) && hasOnlyKeys$1(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_APPLIED" || value.type === "RELEASE_ADOPTED") && isText(value.toBundleId) && hasOnlyKeys$1(value, [...keys, "toBundleId"]);
1109
+ };
1110
+ const isEventFilter = (value) => {
1111
+ if (!isRecord$3(value)) return false;
1112
+ if (value.kind === "all") return hasOnlyKeys$1(value, ["kind"]);
1113
+ if (value.kind === "installationMovement") return isIdentity(value.installId) && hasOnlyKeys$1(value, ["kind", "installId"]);
1114
+ return value.kind === "bundle" && isBundleFilter(value, true);
1115
+ };
1116
+ const validateRow = (model, row, result = false) => {
1117
+ try {
1118
+ validateCreateData(model, row);
1119
+ if (!isRecord$3(row) || typeof row.id !== "string" || !isUUIDv7(row.id) || Object.values(row).some((value) => typeof value === "string" && !isWellFormedText(value))) throw new DatabasePluginInputError("invalid-data");
1120
+ } catch (error) {
1121
+ if (result) throw new DatabasePluginInputError("invalid-result");
1122
+ throw error;
1123
+ }
1124
+ };
1125
+ /** Prepare the full latest-state candidate; the provider owns winning writes. */
1126
+ const toInsightsInstallationRow = (event) => {
1127
+ validateRow("bundle_events", event);
1128
+ return {
1129
+ id: event.id,
1130
+ install_id: event.install_id,
1131
+ user_id: event.user_id,
1132
+ username: event.username,
1133
+ to_bundle_id: event.to_bundle_id,
1134
+ type: event.type,
1135
+ platform: event.platform,
1136
+ app_version: event.app_version,
1137
+ channel: event.channel,
1138
+ cohort: event.cohort,
1139
+ received_at_ms: event.received_at_ms
1140
+ };
1141
+ };
1142
+ /** Release adoption and unchanged lifecycle reports are not bundle movements. */
1143
+ const isInsightsMovementEvent = (event) => event.type === "UPDATE_APPLIED" || event.type === "RECOVERED";
1144
+ const matchesInsightsEventFilter = (event, filter) => {
1145
+ if (filter.kind === "all") return true;
1146
+ if (filter.kind === "installationMovement") return event.install_id === filter.installId && isInsightsMovementEvent(event);
1147
+ 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);
1148
+ };
1149
+ const invalidQuery = () => {
1150
+ throw new DatabasePluginInputError("invalid-query");
1151
+ };
1152
+ const invalidResult = () => {
1153
+ throw new DatabasePluginInputError("invalid-result");
1154
+ };
1155
+ const validateCount = (count) => isTimestamp(count) ? count : invalidResult();
1156
+ /** Validate custom and bundled providers at the same public boundary. */
1157
+ const createValidatedInsightsModel = (model) => ({
1158
+ async record(input) {
1159
+ if (!isRecord$3(input) || !hasOnlyKeys$1(input, ["event", "installation"])) throw new DatabasePluginInputError("invalid-data");
1160
+ const expected = toInsightsInstallationRow(input.event);
1161
+ validateRow("bundle_installations", input.installation);
1162
+ if (Object.entries(expected).some(([field, value]) => Reflect.get(input.installation, field) !== value)) throw new DatabasePluginInputError("invalid-data");
1163
+ await model.record(input);
1164
+ },
1165
+ async listEvents(input) {
1166
+ if (!isRecord$3(input) || !hasOnlyKeys$1(input, [
1167
+ "filter",
1168
+ "sinceMs",
1169
+ "beforeReceivedAtMs",
1170
+ "after",
1171
+ "limit"
1172
+ ]) || !isEventFilter(input.filter) || !isLimit(input.limit) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs !== void 0 && !isTimestamp(input.sinceMs)) invalidQuery();
1173
+ const sinceMs = input.sinceMs ?? 0;
1174
+ if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!isRecord$3(input.after) || !hasOnlyKeys$1(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();
1175
+ const rows = await model.listEvents(input);
1176
+ if (!Array.isArray(rows) || rows.length > input.limit) invalidResult();
1177
+ let previous = input.after;
1178
+ for (const row of rows) {
1179
+ validateRow("bundle_events", row, true);
1180
+ 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();
1181
+ previous = {
1182
+ receivedAtMs: row.received_at_ms,
1183
+ id: row.id
1184
+ };
1185
+ }
1186
+ return rows;
1187
+ },
1188
+ async findInstallations(input) {
1189
+ if (!isRecord$3(input)) invalidQuery();
1190
+ if ("installId" in input) {
1191
+ if (!hasOnlyKeys$1(input, ["installId"]) || !isIdentity(input.installId)) invalidQuery();
1192
+ } else if (!hasOnlyKeys$1(input, [
1193
+ "userId",
1194
+ "afterInstallId",
1195
+ "limit"
1196
+ ]) || !isIdentity(input.userId) || !isLimit(input.limit) || input.afterInstallId !== void 0 && !isIdentity(input.afterInstallId)) invalidQuery();
1197
+ const rows = await model.findInstallations(input);
1198
+ if (!Array.isArray(rows) || rows.length > ("installId" in input ? 1 : input.limit)) invalidResult();
1199
+ let previous = "installId" in input ? void 0 : input.afterInstallId;
1200
+ for (const row of rows) {
1201
+ validateRow("bundle_installations", row, true);
1202
+ if ("installId" in input ? row.install_id !== input.installId : row.user_id !== input.userId || previous !== void 0 && compareInsightsText(row.install_id, previous) <= 0) invalidResult();
1203
+ previous = row.install_id;
1204
+ }
1205
+ return rows;
1206
+ },
1207
+ async countInstallations(input) {
1208
+ if (!isRecord$3(input) || !hasOnlyKeys$1(input, [
1209
+ "platform",
1210
+ "channel",
1211
+ "sinceMs",
1212
+ "bundleId"
1213
+ ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundleId !== void 0 && !isText(input.bundleId)) invalidQuery();
1214
+ return validateCount(await model.countInstallations(input));
1215
+ },
1216
+ async countEvents(input) {
1217
+ if (!isRecord$3(input) || !hasOnlyKeys$1(input, [
1218
+ "filter",
1219
+ "sinceMs",
1220
+ "beforeReceivedAtMs"
1221
+ ]) || !isBundleFilter(input.filter) || !isTimestamp(input.sinceMs) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs > input.beforeReceivedAtMs) invalidQuery();
1222
+ return validateCount(await model.countEvents(input));
1223
+ }
1224
+ });
1225
+ //#endregion
1036
1226
  //#region ../plugin-core/dist/databasePluginCrudValidationMutations.mjs
1037
1227
  const validateMutationWhere = (where) => {
1038
1228
  if (where.length === 0) throw new DatabasePluginInputError("empty-mutation-where");
1039
1229
  };
1040
1230
  const validateUpdateWhere = (model, where) => {
1231
+ if (model === "bundle_installations") {
1232
+ const [install, receivedAt, id] = where;
1233
+ const exactInstall = isRecord$3(install) && install.field === "install_id" && (install.operator === void 0 || install.operator === "eq") && typeof install.value === "string" && install.connector === void 0 && install.mode === void 0;
1234
+ const receivedBefore = isRecord$3(receivedAt) && receivedAt.field === "received_at_ms" && (receivedAt.operator === "lt" || receivedAt.operator === "eq") && typeof receivedAt.value === "number" && Number.isSafeInteger(receivedAt.value) && receivedAt.value >= 0 && receivedAt.connector === void 0 && receivedAt.mode === void 0;
1235
+ const idBefore = isRecord$3(id) && id.field === "id" && id.operator === "lt" && typeof id.value === "string" && id.connector === void 0 && id.mode === void 0;
1236
+ if (!exactInstall || !receivedBefore || !(where.length === 2 && receivedAt.operator === "lt" || where.length === 3 && receivedAt.operator === "eq" && idBefore)) throw new DatabasePluginInputError("invalid-update-selector");
1237
+ return;
1238
+ }
1041
1239
  const selector = where[0];
1042
1240
  const primaryField = model === "release_catalogs" ? "scope_key" : "id";
1043
- if (where.length !== 1 || !isRecord$2(selector) || selector.field !== primaryField || selector.operator !== void 0 && selector.operator !== "eq" || typeof selector.value !== "string" || selector.connector !== void 0 || selector.mode !== void 0) throw new DatabasePluginInputError("invalid-update-selector");
1241
+ if (where.length !== 1 || !isRecord$3(selector) || selector.field !== primaryField || selector.operator !== void 0 && selector.operator !== "eq" || typeof selector.value !== "string" || selector.connector !== void 0 || selector.mode !== void 0) throw new DatabasePluginInputError("invalid-update-selector");
1242
+ };
1243
+ const validateInsightsInstallationUpdateData = (update) => {
1244
+ if (!isRecord$3(update)) throw new DatabasePluginInputError("invalid-data");
1245
+ const fields = databaseFields.bundle_installations.filter((field) => field !== "install_id");
1246
+ if (Reflect.ownKeys(update).length !== fields.length || fields.some((field) => !Object.hasOwn(update, field))) throw new DatabasePluginInputError("invalid-data");
1247
+ for (const field of fields) {
1248
+ const validator = modelValidators.bundle_installations[field];
1249
+ if (!validator?.(Reflect.get(update, field))) throw new DatabasePluginInputError("invalid-data");
1250
+ }
1044
1251
  };
1045
1252
  const validateBundleUpdateData = (update) => {
1046
- if (!isRecord$2(update)) throw new DatabasePluginInputError("invalid-data");
1253
+ if (!isRecord$3(update)) throw new DatabasePluginInputError("invalid-data");
1047
1254
  for (const [field, value] of Object.entries(update)) {
1048
1255
  if (field === "id") throw new DatabasePluginInputError("invalid-data");
1049
1256
  validateField("bundles", field);
@@ -1052,9 +1259,9 @@ const validateBundleUpdateData = (update) => {
1052
1259
  }
1053
1260
  };
1054
1261
  const validateApiKeyUpdateData = (update) => {
1055
- if (!isRecord$2(update) || Reflect.ownKeys(update).length !== 1 || !Object.hasOwn(update, "revoked_at_ms") || !modelValidators.api_keys.revoked_at_ms(Reflect.get(update, "revoked_at_ms"))) throw new DatabasePluginInputError("invalid-data");
1262
+ if (!isRecord$3(update) || Reflect.ownKeys(update).length !== 1 || !Object.hasOwn(update, "revoked_at_ms") || !modelValidators.api_keys.revoked_at_ms(Reflect.get(update, "revoked_at_ms"))) throw new DatabasePluginInputError("invalid-data");
1056
1263
  };
1057
- const RELEASE_MUTABLE_FIELDS = new Set([
1264
+ const RELEASE_MUTABLE_FIELDS = /* @__PURE__ */ new Set([
1058
1265
  "revision",
1059
1266
  "scope_key",
1060
1267
  "target_app_version",
@@ -1067,7 +1274,7 @@ const RELEASE_MUTABLE_FIELDS = new Set([
1067
1274
  "updated_at_ms"
1068
1275
  ]);
1069
1276
  const validateReleaseUpdateData = (update) => {
1070
- if (!isRecord$2(update) || Reflect.ownKeys(update).length === 0) throw new DatabasePluginInputError("invalid-data");
1277
+ if (!isRecord$3(update) || Reflect.ownKeys(update).length === 0) throw new DatabasePluginInputError("invalid-data");
1071
1278
  for (const [field, value] of Object.entries(update)) {
1072
1279
  if (!RELEASE_MUTABLE_FIELDS.has(field)) throw new DatabasePluginInputError("invalid-data");
1073
1280
  const validator = modelValidators.releases[field];
@@ -1075,7 +1282,7 @@ const validateReleaseUpdateData = (update) => {
1075
1282
  }
1076
1283
  };
1077
1284
  const validateReleaseCatalogUpdateData = (update) => {
1078
- if (!isRecord$2(update)) throw new DatabasePluginInputError("invalid-data");
1285
+ if (!isRecord$3(update)) throw new DatabasePluginInputError("invalid-data");
1079
1286
  const expectedFields = Object.keys(modelValidators.release_catalogs).filter((field) => field !== "scope_key");
1080
1287
  if (Reflect.ownKeys(update).length !== expectedFields.length || expectedFields.some((field) => !Object.hasOwn(update, field))) throw new DatabasePluginInputError("invalid-data");
1081
1288
  for (const [field, value] of Object.entries(update)) {
@@ -1155,7 +1362,7 @@ const validateWhere$1 = (model, where) => {
1155
1362
  if (where === void 0) return;
1156
1363
  if (!Array.isArray(where)) throw new DatabasePluginInputError("invalid-query");
1157
1364
  for (const item of where) {
1158
- if (!isRecord$2(item)) throw new DatabasePluginInputError("invalid-query");
1365
+ if (!isRecord$3(item)) throw new DatabasePluginInputError("invalid-query");
1159
1366
  if (item.connector !== void 0 && item.connector !== "AND" && item.connector !== "OR") throw new DatabasePluginInputError("invalid-query");
1160
1367
  validateWhereValue(model, item);
1161
1368
  }
@@ -1172,7 +1379,7 @@ const validateOrderBy = (model, orderBy) => {
1172
1379
  if (!Array.isArray(orderBy) || orderBy.length === 0) throw new DatabasePluginInputError("invalid-query");
1173
1380
  const fields = /* @__PURE__ */ new Set();
1174
1381
  return orderBy.map((clause) => {
1175
- if (!isRecord$2(clause) || typeof clause.field !== "string") throw new DatabasePluginInputError("invalid-query");
1382
+ if (!isRecord$3(clause) || typeof clause.field !== "string") throw new DatabasePluginInputError("invalid-query");
1176
1383
  validateField(model, clause.field);
1177
1384
  if (!sortableFields[model].has(clause.field)) throw new DatabasePluginInputError("invalid-query");
1178
1385
  if (clause.direction !== "asc" && clause.direction !== "desc") throw new DatabasePluginInputError("invalid-query");
@@ -1184,7 +1391,7 @@ const validateOrderBy = (model, orderBy) => {
1184
1391
  };
1185
1392
  const validateDistinctOn = (model, distinctOn, orderBy) => {
1186
1393
  if (distinctOn === void 0) return;
1187
- if (!isRecord$2(distinctOn)) throw new DatabasePluginInputError("invalid-distinct");
1394
+ if (!isRecord$3(distinctOn)) throw new DatabasePluginInputError("invalid-distinct");
1188
1395
  const fields = validateDistinctFields(model, distinctOn.fields);
1189
1396
  if (fields === void 0 || orderBy === void 0) throw new DatabasePluginInputError("invalid-distinct");
1190
1397
  for (const [index, field] of fields.entries()) if (orderBy[index]?.field !== field) throw new DatabasePluginInputError("invalid-distinct");
@@ -1198,7 +1405,7 @@ const validateBundlePagination = (options) => {
1198
1405
  if (!Number.isSafeInteger(options.limit) || options.limit <= 0 || options.page !== void 0 && (!Number.isSafeInteger(options.page) || options.page <= 0) || options.page !== void 0 && options.cursor !== void 0) throw new DatabasePluginInputError("invalid-pagination");
1199
1406
  const cursor = options.cursor;
1200
1407
  if (cursor === void 0) return;
1201
- if (!isRecord$2(cursor)) throw new DatabasePluginInputError("invalid-pagination");
1408
+ if (!isRecord$3(cursor)) throw new DatabasePluginInputError("invalid-pagination");
1202
1409
  const hasAfter = Object.hasOwn(cursor, "after");
1203
1410
  if (hasAfter === Object.hasOwn(cursor, "before")) throw new DatabasePluginInputError("invalid-pagination");
1204
1411
  const value = hasAfter ? cursor.after : cursor.before;
@@ -1210,7 +1417,7 @@ const createDatabasePluginCrud = (implementation) => {
1210
1417
  async function create(input) {
1211
1418
  validateModel(input.model);
1212
1419
  validateCreateData(input.model, input.data);
1213
- if (input.onConflict !== void 0 && !(input.onConflict === "ignore" && (input.model === "channels" || input.model === "api_keys"))) throw new DatabasePluginInputError("invalid-operation");
1420
+ if (input.onConflict !== void 0 && !(input.onConflict === "ignore" && (input.model === "channels" || input.model === "api_keys" || input.model === "bundle_installations"))) throw new DatabasePluginInputError("invalid-operation");
1214
1421
  validateSelect(input.model, input.select);
1215
1422
  const row = await implementation.create(input);
1216
1423
  validateResult(input.model, row, input.select);
@@ -1224,6 +1431,7 @@ const createDatabasePluginCrud = (implementation) => {
1224
1431
  if (input.model === "bundles") validateBundleUpdateData(input.update);
1225
1432
  else if (input.model === "releases") validateReleaseUpdateData(input.update);
1226
1433
  else if (input.model === "release_catalogs") validateReleaseCatalogUpdateData(input.update);
1434
+ else if (input.model === "bundle_installations") validateInsightsInstallationUpdateData(input.update);
1227
1435
  else if (input.model === "api_keys") validateApiKeyUpdateData(input.update);
1228
1436
  else throw new DatabasePluginInputError("invalid-operation");
1229
1437
  validateSelect(input.model, input.select);
@@ -1293,7 +1501,87 @@ const createTransactionDatabasePlugin = (implementation) => {
1293
1501
  //#region ../plugin-core/dist/createDatabasePlugin.mjs
1294
1502
  const PAGE_SIZE$1 = 100;
1295
1503
  const compareChannelRows = (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
1504
+ const toInsightsBundleWhere = (filter) => [
1505
+ {
1506
+ field: "platform",
1507
+ value: filter.platform
1508
+ },
1509
+ {
1510
+ field: "channel",
1511
+ value: filter.channel
1512
+ },
1513
+ {
1514
+ field: "type",
1515
+ value: filter.type
1516
+ },
1517
+ filter.type === "RECOVERED" ? {
1518
+ field: "from_bundle_id",
1519
+ value: filter.fromBundleId
1520
+ } : {
1521
+ field: "to_bundle_id",
1522
+ value: filter.toBundleId
1523
+ }
1524
+ ];
1525
+ const toInsightsEventRanges = (filter) => {
1526
+ if (filter.kind === "all") return [[]];
1527
+ if (filter.kind === "bundle") return [toInsightsBundleWhere(filter)];
1528
+ return ["UPDATE_APPLIED", "RECOVERED"].map((type) => [{
1529
+ field: "install_id",
1530
+ value: filter.installId
1531
+ }, {
1532
+ field: "type",
1533
+ value: type
1534
+ }]);
1535
+ };
1536
+ const listInsightsEventRange = async (crud, input, filterWhere) => {
1537
+ const where = [...filterWhere, {
1538
+ field: "received_at_ms",
1539
+ operator: "gte",
1540
+ value: input.sinceMs ?? 0
1541
+ }];
1542
+ const sameTimestamp = input.after === void 0 ? [] : await crud.findMany({
1543
+ model: "bundle_events",
1544
+ where: [
1545
+ ...filterWhere,
1546
+ {
1547
+ field: "received_at_ms",
1548
+ value: input.after.receivedAtMs
1549
+ },
1550
+ {
1551
+ field: "id",
1552
+ operator: "lt",
1553
+ value: input.after.id
1554
+ }
1555
+ ],
1556
+ orderBy: [{
1557
+ field: "id",
1558
+ direction: "desc"
1559
+ }],
1560
+ limit: input.limit,
1561
+ offset: 0
1562
+ });
1563
+ if (sameTimestamp.length === input.limit) return sameTimestamp;
1564
+ const older = await crud.findMany({
1565
+ model: "bundle_events",
1566
+ where: [...where, {
1567
+ field: "received_at_ms",
1568
+ operator: "lt",
1569
+ value: input.after?.receivedAtMs ?? input.beforeReceivedAtMs
1570
+ }],
1571
+ orderBy: [{
1572
+ field: "received_at_ms",
1573
+ direction: "desc"
1574
+ }, {
1575
+ field: "id",
1576
+ direction: "desc"
1577
+ }],
1578
+ limit: input.limit - sameTimestamp.length,
1579
+ offset: 0
1580
+ });
1581
+ return [...sameTimestamp, ...older];
1582
+ };
1296
1583
  var DatabaseAtomicCommitUnsupportedError = class extends Error {
1584
+ pluginName;
1297
1585
  name = "DatabaseAtomicCommitUnsupportedError";
1298
1586
  constructor(pluginName) {
1299
1587
  super(`Database plugin "${pluginName}" cannot atomically commit changes across models.`);
@@ -1314,6 +1602,7 @@ var DatabaseRowReferencedError = class extends Error {
1314
1602
  }
1315
1603
  };
1316
1604
  var DatabaseCommitConflictError = class extends Error {
1605
+ result;
1317
1606
  name = "DatabaseCommitConflictError";
1318
1607
  constructor(result) {
1319
1608
  super("Database commit precondition failed.");
@@ -1541,12 +1830,6 @@ const applyChange = async (database, change, changeIndex) => {
1541
1830
  }
1542
1831
  return;
1543
1832
  }
1544
- case "insights":
1545
- await database.create({
1546
- model: "bundle_events",
1547
- data: change.row
1548
- });
1549
- return;
1550
1833
  case "apiKeys": switch (change.operation) {
1551
1834
  case "insert":
1552
1835
  await database.create({
@@ -1618,10 +1901,10 @@ const hasOnlyKeys = (value, keys) => {
1618
1901
  return Reflect.ownKeys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
1619
1902
  };
1620
1903
  const validateWhere = (where, field, validateValue = (value) => typeof value === "string") => {
1621
- if (!isRecord$2(where) || !hasOnlyKeys(where, [field]) || !validateValue(Reflect.get(where, field))) throw new DatabasePluginInputError("invalid-data");
1904
+ if (!isRecord$3(where) || !hasOnlyKeys(where, [field]) || !validateValue(Reflect.get(where, field))) throw new DatabasePluginInputError("invalid-data");
1622
1905
  };
1623
1906
  const validateDatabaseChange = (change) => {
1624
- if (!isRecord$2(change)) throw new DatabasePluginInputError("invalid-data");
1907
+ if (!isRecord$3(change)) throw new DatabasePluginInputError("invalid-data");
1625
1908
  switch (change.model) {
1626
1909
  case "bundles": switch (change.operation) {
1627
1910
  case "insert":
@@ -1728,14 +2011,6 @@ const validateDatabaseChange = (change) => {
1728
2011
  return;
1729
2012
  default: throw new DatabasePluginInputError("invalid-operation");
1730
2013
  }
1731
- case "insights":
1732
- if (change.operation !== "insert" || !hasOnlyKeys(change, [
1733
- "model",
1734
- "operation",
1735
- "row"
1736
- ])) throw new DatabasePluginInputError("invalid-operation");
1737
- validateCreateData("bundle_events", change.row);
1738
- return;
1739
2014
  case "apiKeys": switch (change.operation) {
1740
2015
  case "insert":
1741
2016
  if (!hasOnlyKeys(change, [
@@ -1754,7 +2029,7 @@ const validateDatabaseChange = (change) => {
1754
2029
  "update"
1755
2030
  ])) throw new DatabasePluginInputError("invalid-data");
1756
2031
  validateWhere(change.where, "id");
1757
- if (!isRecord$2(change.update)) throw new DatabasePluginInputError("invalid-data");
2032
+ if (!isRecord$3(change.update)) throw new DatabasePluginInputError("invalid-data");
1758
2033
  if (!hasOnlyKeys(change.update, ["revokedAtMs"])) throw new DatabasePluginInputError("invalid-data");
1759
2034
  validateApiKeyUpdateData({ revoked_at_ms: change.update.revokedAtMs });
1760
2035
  return;
@@ -1764,7 +2039,7 @@ const validateDatabaseChange = (change) => {
1764
2039
  }
1765
2040
  };
1766
2041
  const validateDatabaseCommitExpectation = (expectation) => {
1767
- if (!isRecord$2(expectation)) throw new DatabasePluginInputError("invalid-data");
2042
+ if (!isRecord$3(expectation)) throw new DatabasePluginInputError("invalid-data");
1768
2043
  if (expectation.model === "releases") {
1769
2044
  if (!hasOnlyKeys(expectation, [
1770
2045
  "model",
@@ -1784,7 +2059,7 @@ const validateDatabaseCommitExpectation = (expectation) => {
1784
2059
  throw new DatabasePluginInputError("invalid-model");
1785
2060
  };
1786
2061
  function validateDatabaseCommit(input) {
1787
- if (!isRecord$2(input) || !Array.isArray(input.changes) || (Object.hasOwn(input, "expectations") ? !hasOnlyKeys(input, ["changes", "expectations"]) || !Array.isArray(input.expectations) : !hasOnlyKeys(input, ["changes"]))) throw new DatabasePluginInputError("invalid-data");
2062
+ if (!isRecord$3(input) || !Array.isArray(input.changes) || (Object.hasOwn(input, "expectations") ? !hasOnlyKeys(input, ["changes", "expectations"]) || !Array.isArray(input.expectations) : !hasOnlyKeys(input, ["changes"]))) throw new DatabasePluginInputError("invalid-data");
1788
2063
  input.changes.forEach(validateDatabaseChange);
1789
2064
  if (Array.isArray(input.expectations)) input.expectations.forEach(validateDatabaseCommitExpectation);
1790
2065
  }
@@ -2004,36 +2279,82 @@ const createDatabasePluginAdapter = (name, implementation) => {
2004
2279
  }
2005
2280
  },
2006
2281
  insights: {
2007
- async append(row) {
2008
- await crud.create({
2009
- model: "bundle_events",
2010
- data: row
2011
- });
2282
+ record: (input) => implementation.recordInsights(input),
2283
+ async listEvents(input) {
2284
+ const ranges = await Promise.all(toInsightsEventRanges(input.filter).map((where) => listInsightsEventRange(crud, input, where)));
2285
+ if (ranges.length === 1) return ranges[0];
2286
+ return ranges.flat().sort((left, right) => right.received_at_ms - left.received_at_ms || compareInsightsText(right.id, left.id)).slice(0, input.limit);
2012
2287
  },
2013
- async scan(input) {
2014
- const rows = [];
2015
- for (let offset = 0; rows.length < input.limit; offset += PAGE_SIZE$1) {
2016
- const page = await crud.findMany({
2017
- model: "bundle_events",
2288
+ async findInstallations(input) {
2289
+ if ("installId" in input) {
2290
+ const row = await crud.findOne({
2291
+ model: "bundle_installations",
2018
2292
  where: [{
2293
+ field: "install_id",
2294
+ value: input.installId
2295
+ }]
2296
+ });
2297
+ return row === null ? [] : [row];
2298
+ }
2299
+ return crud.findMany({
2300
+ model: "bundle_installations",
2301
+ where: [{
2302
+ field: "user_id",
2303
+ value: input.userId
2304
+ }, ...input.afterInstallId === void 0 ? [] : [{
2305
+ field: "install_id",
2306
+ operator: "gt",
2307
+ value: input.afterInstallId
2308
+ }]],
2309
+ orderBy: [{
2310
+ field: "install_id",
2311
+ direction: "asc"
2312
+ }],
2313
+ limit: input.limit,
2314
+ offset: 0
2315
+ });
2316
+ },
2317
+ countInstallations(input) {
2318
+ return crud.count({
2319
+ model: "bundle_installations",
2320
+ where: [
2321
+ {
2322
+ field: "platform",
2323
+ value: input.platform
2324
+ },
2325
+ {
2326
+ field: "channel",
2327
+ value: input.channel
2328
+ },
2329
+ {
2330
+ field: "received_at_ms",
2331
+ operator: "gte",
2332
+ value: input.sinceMs
2333
+ },
2334
+ ...input.bundleId === void 0 ? [] : [{
2335
+ field: "to_bundle_id",
2336
+ value: input.bundleId
2337
+ }]
2338
+ ]
2339
+ });
2340
+ },
2341
+ countEvents(input) {
2342
+ return crud.count({
2343
+ model: "bundle_events",
2344
+ where: [
2345
+ ...toInsightsBundleWhere(input.filter),
2346
+ {
2347
+ field: "received_at_ms",
2348
+ operator: "gte",
2349
+ value: input.sinceMs
2350
+ },
2351
+ {
2019
2352
  field: "received_at_ms",
2020
2353
  operator: "lt",
2021
2354
  value: input.beforeReceivedAtMs
2022
- }],
2023
- orderBy: [{
2024
- field: "received_at_ms",
2025
- direction: "asc"
2026
- }, {
2027
- field: "id",
2028
- direction: "asc"
2029
- }],
2030
- limit: PAGE_SIZE$1,
2031
- offset
2032
- });
2033
- rows.push(...page.filter((row) => input.after === void 0 || row.received_at_ms > input.after.receivedAtMs || row.received_at_ms === input.after.receivedAtMs && row.id > input.after.id));
2034
- if (page.length < PAGE_SIZE$1) break;
2035
- }
2036
- return rows.slice(0, input.limit);
2355
+ }
2356
+ ]
2357
+ });
2037
2358
  }
2038
2359
  },
2039
2360
  apiKeys: {
@@ -2077,7 +2398,13 @@ const createDatabasePluginAdapter = (name, implementation) => {
2077
2398
  ...implementation.dispose ? { dispose: implementation.dispose } : {}
2078
2399
  };
2079
2400
  };
2080
- const createDatabasePlugin = (options) => ({ ...options });
2401
+ const createDatabasePlugin = (options) => ({
2402
+ ...options,
2403
+ models: {
2404
+ ...options.models,
2405
+ insights: createValidatedInsightsModel(options.models.insights)
2406
+ }
2407
+ });
2081
2408
  //#endregion
2082
2409
  //#region ../plugin-core/dist/createStoragePlugin.mjs
2083
2410
  const createStoragePlugin = (options) => ({ ...options });
@@ -2212,6 +2539,8 @@ const rowsToBundles = (bundleRows, patchRows, referencedBundleRows) => {
2212
2539
  //#endregion
2213
2540
  //#region ../plugin-core/dist/databaseClientUpdates.mjs
2214
2541
  var DatabasePatchUpdateUnsupportedError = class extends Error {
2542
+ bundleId;
2543
+ pluginName;
2215
2544
  name = "DatabasePatchUpdateUnsupportedError";
2216
2545
  constructor(bundleId, pluginName) {
2217
2546
  super(`Database plugin "${pluginName}" cannot atomically replace patches for bundle "${bundleId}".`);
@@ -2300,7 +2629,8 @@ const hydrateRows = async (database, ownerRows) => {
2300
2629
  const patchRows = await database.models.bundlePatches.findByBundleIds(ownerRows.map(({ id }) => id));
2301
2630
  const ownerIds = new Set(ownerRows.map(({ id }) => id));
2302
2631
  const referencedIds = [...new Set(patchRows.map(({ base_bundle_id }) => base_bundle_id).filter((id) => !ownerIds.has(id)))];
2303
- return rowsToBundles(ownerRows, patchRows, referencedIds.length === 0 ? [] : await loadBundleRows(database, { id: { in: referencedIds } }));
2632
+ const referencedRows = referencedIds.length === 0 ? [] : await loadBundleRows(database, { id: { in: referencedIds } });
2633
+ return rowsToBundles(ownerRows, patchRows, referencedRows);
2304
2634
  };
2305
2635
  const cursorIdFilter = (cursor, direction) => {
2306
2636
  if (cursor?.after) return { [direction === "desc" ? "lt" : "gt"]: cursor.after };
@@ -2352,6 +2682,7 @@ const responsePage = async (database, options) => {
2352
2682
  //#endregion
2353
2683
  //#region ../plugin-core/dist/databaseClient.mjs
2354
2684
  var DatabaseBundleNotFoundError = class extends Error {
2685
+ bundleId;
2355
2686
  name = "DatabaseBundleNotFoundError";
2356
2687
  constructor(bundleId) {
2357
2688
  super(`Bundle "${bundleId}" was not found.`);
@@ -2359,6 +2690,8 @@ var DatabaseBundleNotFoundError = class extends Error {
2359
2690
  }
2360
2691
  };
2361
2692
  var DatabasePatchInsertUnsupportedError = class extends Error {
2693
+ bundleId;
2694
+ pluginName;
2362
2695
  name = "DatabasePatchInsertUnsupportedError";
2363
2696
  constructor(bundleId, pluginName) {
2364
2697
  super(`Database plugin "${pluginName}" cannot atomically insert patches for bundle "${bundleId}".`);
@@ -2377,12 +2710,13 @@ const insertChanges = (bundle) => [{
2377
2710
  }))];
2378
2711
  const updateChanges = (bundleId, update) => {
2379
2712
  const rowUpdate = bundleUpdateToRow(update);
2713
+ const patchesPresent = Object.hasOwn(update, "patches");
2380
2714
  return [{
2381
2715
  model: "bundles",
2382
2716
  operation: "update",
2383
2717
  where: { id: bundleId },
2384
2718
  update: rowUpdate
2385
- }, ...Object.hasOwn(update, "patches") ? [{
2719
+ }, ...patchesPresent ? [{
2386
2720
  model: "bundlePatches",
2387
2721
  operation: "delete",
2388
2722
  where: { bundleId }
@@ -2455,7 +2789,7 @@ const createDatabaseClient = (plugin) => {
2455
2789
  };
2456
2790
  };
2457
2791
  //#endregion
2458
- //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.3.2/node_modules/verkit/dist/index.mjs
2792
+ //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/comparison-DenM3wCn.mjs
2459
2793
  const LETTER_DASH_NUMBER = "[a-zA-Z0-9-]";
2460
2794
  const NUMERIC_IDENTIFIER = String.raw`0|[1-9]\d*`;
2461
2795
  const NUMERIC_IDENTIFIER_LOOSE = String.raw`\d+`;
@@ -2492,22 +2826,9 @@ function makeSafeRegexSource(source) {
2492
2826
  function safeRegex(source, flags) {
2493
2827
  return new RegExp(makeSafeRegexSource(source), flags);
2494
2828
  }
2495
- const NUMERIC$1 = /^\d+$/;
2496
- function compareIdentifiers(left, right) {
2497
- if (typeof left === "number" && typeof right === "number") return left === right ? 0 : left < right ? -1 : 1;
2498
- const leftNumeric = NUMERIC$1.test(String(left));
2499
- const rightNumeric = NUMERIC$1.test(String(right));
2500
- const normalizedLeft = leftNumeric ? Number(left) : left;
2501
- const normalizedRight = rightNumeric ? Number(right) : right;
2502
- return normalizedLeft === normalizedRight ? 0 : leftNumeric && !rightNumeric ? -1 : rightNumeric && !leftNumeric ? 1 : normalizedLeft < normalizedRight ? -1 : 1;
2503
- }
2504
2829
  const FULL = safeRegex(`^${FULL_PLAIN}$`);
2505
2830
  const LOOSE = safeRegex(`^${LOOSE_PLAIN}$`);
2506
- safeRegex(`^${PRERELEASE}$`);
2507
- safeRegex(`^${PRERELEASE_LOOSE}$`);
2508
- const COERCE_EXACT = safeRegex(COERCE);
2509
- const COERCE_FULL_EXACT = safeRegex(COERCE_FULL);
2510
- const NUMERIC = /^\d+$/;
2831
+ const NUMERIC$1 = /^\d+$/;
2511
2832
  function formatComparableVersion(version) {
2512
2833
  const base = `${version.major}.${version.minor}.${version.patch}`;
2513
2834
  return version.prerelease?.length ? `${base}-${version.prerelease.join(".")}` : base;
@@ -2528,7 +2849,7 @@ function parse(version, options = {}) {
2528
2849
  if (minor > Number.MAX_SAFE_INTEGER || minor < 0) throw new TypeError(`Invalid minor version: ${match[2]}`);
2529
2850
  if (patch > Number.MAX_SAFE_INTEGER || patch < 0) throw new TypeError(`Invalid patch version: ${match[3]}`);
2530
2851
  const prerelease = match[4] ? match[4].split(".").map((identifier) => {
2531
- if (NUMERIC.test(identifier)) {
2852
+ if (NUMERIC$1.test(identifier)) {
2532
2853
  const numeric = Number(identifier);
2533
2854
  if (numeric >= 0 && numeric < Number.MAX_SAFE_INTEGER) return numeric;
2534
2855
  }
@@ -2549,6 +2870,15 @@ function tryParse(version, options = {}) {
2549
2870
  return null;
2550
2871
  }
2551
2872
  }
2873
+ const NUMERIC = /^\d+$/;
2874
+ function compareIdentifiers(left, right) {
2875
+ if (typeof left === "number" && typeof right === "number") return left === right ? 0 : left < right ? -1 : 1;
2876
+ const leftNumeric = NUMERIC.test(String(left));
2877
+ const rightNumeric = NUMERIC.test(String(right));
2878
+ const normalizedLeft = leftNumeric ? Number(left) : left;
2879
+ const normalizedRight = rightNumeric ? Number(right) : right;
2880
+ return normalizedLeft === normalizedRight ? 0 : leftNumeric && !rightNumeric ? -1 : rightNumeric && !leftNumeric ? 1 : normalizedLeft < normalizedRight ? -1 : 1;
2881
+ }
2552
2882
  function compareMainParsed(left, right) {
2553
2883
  return left.major === right.major ? left.minor === right.minor ? left.patch === right.patch ? 0 : left.patch < right.patch ? -1 : 1 : left.minor < right.minor ? -1 : 1 : left.major < right.major ? -1 : 1;
2554
2884
  }
@@ -2570,26 +2900,15 @@ function comparePrereleaseParsed(left, right) {
2570
2900
  function compareParsed(left, right) {
2571
2901
  return compareMainParsed(left, right) || comparePrereleaseParsed(left, right);
2572
2902
  }
2573
- function coerceParsedVersion(value, options = {}) {
2574
- if (typeof value === "object") return value;
2575
- const input = typeof value === "number" ? String(value) : value;
2576
- if (typeof input !== "string") return null;
2577
- let match = null;
2578
- if (options.rtl) {
2579
- const expression = safeRegex(options.includePrerelease ? COERCE_FULL : COERCE, "g");
2580
- let next;
2581
- while ((next = expression.exec(input)) && (!match || match.index + match[0].length !== input.length)) {
2582
- if (!match || next.index + next[0].length !== match.index + match[0].length) match = next;
2583
- expression.lastIndex = next.index + next[1].length + next[2].length;
2584
- }
2585
- } else match = (options.includePrerelease ? COERCE_FULL_EXACT : COERCE_EXACT).exec(input);
2586
- if (!match) return null;
2587
- const major = match[2];
2588
- return tryParse(`${major}.${match[3] || "0"}.${match[4] || "0"}${options.includePrerelease && match[5] ? `-${match[5]}` : ""}${options.includePrerelease && match[6] ? `+${match[6]}` : ""}`, options);
2903
+ function compare$1(left, right, options = {}) {
2904
+ return compareParsed(parse(left, options), parse(right, options));
2589
2905
  }
2906
+ //#endregion
2907
+ //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/set-CC5YeoYX.mjs
2590
2908
  const STRICT_COMPARATOR = safeRegex(String.raw`^${GREATER_LESS_THAN}\s*(${FULL_PLAIN})$|^$`);
2591
2909
  const LOOSE_COMPARATOR$1 = safeRegex(String.raw`^${GREATER_LESS_THAN}\s*(${LOOSE_PLAIN})$|^$`);
2592
2910
  function parseComparator(comparator, options = {}) {
2911
+ if (typeof comparator !== "string") return comparator;
2593
2912
  const normalized = comparator.trim().replaceAll(/\s+/g, " ");
2594
2913
  const match = normalized.match(options.loose ? LOOSE_COMPARATOR$1 : STRICT_COMPARATOR);
2595
2914
  if (!match) throw new TypeError(`Invalid comparator: ${normalized}`);
@@ -2602,9 +2921,8 @@ function parseComparator(comparator, options = {}) {
2602
2921
  version
2603
2922
  };
2604
2923
  }
2605
- function compare$1(left, right, options = {}) {
2606
- return compareParsed(parse(left, options), parse(right, options));
2607
- }
2924
+ //#endregion
2925
+ //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/range-DvX-Y6iv.mjs
2608
2926
  const BUILD_STRIP = new RegExp(BUILD, "g");
2609
2927
  const BUILD_SAFE = safeRegex(BUILD);
2610
2928
  const STRICT_HYPHEN = safeRegex(String.raw`^\s*(${XRANGE_PLAIN})\s+-\s+(${XRANGE_PLAIN})\s*$`);
@@ -2730,9 +3048,9 @@ function parseSimpleRange(input, options) {
2730
3048
  function parseRange(range, options = {}) {
2731
3049
  if (typeof range !== "string") return range;
2732
3050
  const parsedOptions = { ...options };
2733
- const raw = range.trim().replaceAll(/\s+/g, " ");
2734
- let sets = raw.split("||").map((part) => parseSimpleRange(part.trim(), parsedOptions)).filter((set) => set.length);
2735
- if (!sets.length) throw new TypeError(`Range contains no valid comparator sets: ${raw}`);
3051
+ const normalizedRange = range.trim().replaceAll(/\s+/g, " ");
3052
+ let sets = normalizedRange.split("||").map((part) => parseSimpleRange(part.trim(), parsedOptions)).filter((set) => set.length);
3053
+ if (!sets.length) throw new TypeError(`Range contains no valid comparator sets: ${normalizedRange}`);
2736
3054
  if (sets.length > 1) {
2737
3055
  const first = sets[0];
2738
3056
  sets = sets.filter((set) => set[0]?.value !== "<0.0.0-0");
@@ -2743,19 +3061,39 @@ function parseRange(range, options = {}) {
2743
3061
  }
2744
3062
  }
2745
3063
  return {
2746
- normalized: sets.map((set) => set.map((comparator) => comparator.value).join(" ")).join("||"),
2747
3064
  options: parsedOptions,
2748
- raw,
2749
3065
  sets
2750
3066
  };
2751
3067
  }
3068
+ //#endregion
3069
+ //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/version-CQ98ZBpL.mjs
3070
+ const COERCE_EXACT = safeRegex(COERCE);
3071
+ const COERCE_FULL_EXACT = safeRegex(COERCE_FULL);
3072
+ safeRegex(`^${PRERELEASE}$`);
3073
+ safeRegex(`^${PRERELEASE_LOOSE}$`);
3074
+ function normalizeFull(version, options = {}) {
3075
+ const parsed = tryParse(version, options);
3076
+ return parsed ? formatFullVersion(parsed) : null;
3077
+ }
2752
3078
  function normalize(version, options = {}) {
2753
3079
  const parsed = tryParse(version, options);
2754
3080
  return parsed ? formatComparableVersion(parsed) : null;
2755
3081
  }
2756
3082
  function coerce(value, options = {}) {
2757
- const parsed = coerceParsedVersion(value, options);
2758
- return parsed ? formatFullVersion(parsed) : null;
3083
+ if (typeof value === "object") return value;
3084
+ const input = typeof value === "number" ? String(value) : value;
3085
+ let match = null;
3086
+ if (options.rtl) {
3087
+ const expression = safeRegex(options.includePrerelease ? COERCE_FULL : COERCE, "g");
3088
+ let next;
3089
+ while ((next = expression.exec(input)) && (!match || match.index + match[0].length !== input.length)) {
3090
+ if (!match || next.index + next[0].length !== match.index + match[0].length) match = next;
3091
+ expression.lastIndex = next.index + next[1].length + next[2].length;
3092
+ }
3093
+ } else match = (options.includePrerelease ? COERCE_FULL_EXACT : COERCE_EXACT).exec(input);
3094
+ if (!match) return null;
3095
+ const major = match[2];
3096
+ return tryParse(`${major}.${match[3] || "0"}.${match[4] || "0"}${options.includePrerelease && match[5] ? `-${match[5]}` : ""}${options.includePrerelease && match[6] ? `+${match[6]}` : ""}`, options);
2759
3097
  }
2760
3098
  //#endregion
2761
3099
  //#region ../plugin-core/dist/releaseCatalogCompiler.mjs
@@ -3035,15 +3373,16 @@ function compileAppVersion(releases) {
3035
3373
  const retainedIds = new Set(segmentReleases.flatMap(({ retainedIds, rollbackIds }) => [...retainedIds, ...rollbackIds]));
3036
3374
  const retainedReleases = releases.filter((release) => retainedIds.has(release.id));
3037
3375
  const descriptorIndex = new Map(retainedReleases.map((release, index) => [release.id, index]));
3376
+ const segments = mergeSegments(segmentReleases.map(({ segment, retainedIds, rollbackIds }) => ({
3377
+ ...segment,
3378
+ releaseIndexes: releases.filter((release) => retainedIds.has(release.id)).map((release) => descriptorIndex.get(release.id)).filter((index) => index !== void 0),
3379
+ rollbackReleaseIndexes: releases.filter((release) => rollbackIds.has(release.id)).map((release) => descriptorIndex.get(release.id)).filter((index) => index !== void 0)
3380
+ })).filter((segment) => segment.releaseIndexes.length > 0 || segment.rollbackReleaseIndexes.length > 0));
3038
3381
  return {
3039
3382
  envelope: {
3040
3383
  fallbackPolicy: RELEASE_CATALOG_FALLBACK_POLICY,
3041
3384
  schemaVersion: 1,
3042
- segments: mergeSegments(segmentReleases.map(({ segment, retainedIds, rollbackIds }) => ({
3043
- ...segment,
3044
- releaseIndexes: releases.filter((release) => retainedIds.has(release.id)).map((release) => descriptorIndex.get(release.id)).filter((index) => index !== void 0),
3045
- rollbackReleaseIndexes: releases.filter((release) => rollbackIds.has(release.id)).map((release) => descriptorIndex.get(release.id)).filter((index) => index !== void 0)
3046
- })).filter((segment) => segment.releaseIndexes.length > 0 || segment.rollbackReleaseIndexes.length > 0)),
3385
+ segments,
3047
3386
  strategy: "APP_VERSION"
3048
3387
  },
3049
3388
  retainedReleases
@@ -3111,7 +3450,8 @@ function versionInSegment(version, segment) {
3111
3450
  return true;
3112
3451
  }
3113
3452
  function canonicalizeAppVersion(appVersion) {
3114
- return coerce(appVersion);
3453
+ const version = coerce(appVersion);
3454
+ return version ? normalizeFull(version) : null;
3115
3455
  }
3116
3456
  function projectCompiledCatalog(catalog, appVersion) {
3117
3457
  let indexes;
@@ -3139,6 +3479,7 @@ function projectCompiledRollbackCatalog(catalog, appVersion) {
3139
3479
  const RELEASE_PAGE_SIZE = 1e3;
3140
3480
  const DEFAULT_MAX_ATTEMPTS = 3;
3141
3481
  var ReleaseCatalogMutationError = class extends Error {
3482
+ code;
3142
3483
  name = "ReleaseCatalogMutationError";
3143
3484
  constructor(code, message) {
3144
3485
  super(message);
@@ -3398,6 +3739,7 @@ async function rebuildReleaseCatalog(input) {
3398
3739
  //#endregion
3399
3740
  //#region ../plugin-core/dist/releaseManagement.mjs
3400
3741
  var ReleaseManagementError = class extends Error {
3742
+ code;
3401
3743
  name = "ReleaseManagementError";
3402
3744
  constructor(code, message) {
3403
3745
  super(message);
@@ -3477,7 +3819,7 @@ async function deleteRelease(input) {
3477
3819
  }
3478
3820
  //#endregion
3479
3821
  //#region ../plugin-core/dist/storageDownloadPath.mjs
3480
- const decodeBase64Url = (value) => {
3822
+ const decodeBase64Url$1 = (value) => {
3481
3823
  try {
3482
3824
  const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
3483
3825
  const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
@@ -3489,7 +3831,7 @@ const decodeBase64Url = (value) => {
3489
3831
  const parseStorageDownloadPath = (path) => {
3490
3832
  const match = /^\/storage\/([^/]+)\/([^/]+)$/.exec(path);
3491
3833
  if (!match) return null;
3492
- const storageUri = decodeBase64Url(match[1]);
3834
+ const storageUri = decodeBase64Url$1(match[1]);
3493
3835
  if (storageUri === null) return null;
3494
3836
  try {
3495
3837
  return {
@@ -3610,7 +3952,7 @@ const createReleaseCatalogRouteHandlers = (clientAccessHeaderName = "x-api-key")
3610
3952
  //#endregion
3611
3953
  //#region ../../packages/server/dist/handlerReleaseManagementRoutes.mjs
3612
3954
  const unavailable = () => Response.json({ error: "Not found" }, { status: 404 });
3613
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3955
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3614
3956
  const parseRevision = (value) => {
3615
3957
  if (value === void 0 || value === null || value === "") return void 0;
3616
3958
  const revision = typeof value === "number" ? value : Number(value);
@@ -3619,7 +3961,7 @@ const parseRevision = (value) => {
3619
3961
  };
3620
3962
  const parsePolicyInput = async (request) => {
3621
3963
  const body = await request.json();
3622
- if (!isRecord$1(body) || !isRecord$1(body.patch)) throw new HandlerBadRequestError("Invalid Release policy mutation");
3964
+ if (!isRecord$2(body) || !isRecord$2(body.patch)) throw new HandlerBadRequestError("Invalid Release policy mutation");
3623
3965
  return {
3624
3966
  expectedRevision: parseRevision(body.expectedRevision),
3625
3967
  patch: body.patch
@@ -3729,17 +4071,11 @@ const createReleaseManagementRouteHandlers = () => ({
3729
4071
  });
3730
4072
  //#endregion
3731
4073
  //#region ../../packages/server/dist/insights/errors.mjs
3732
- var InsightsScanLimitExceededError = class extends Error {
3733
- constructor(limit) {
3734
- super(`Insights event scan exceeded ${limit} rows.`);
3735
- this.limit = limit;
3736
- this.name = "InsightsScanLimitExceededError";
3737
- }
3738
- };
3739
4074
  var InsightsBadRequestError = class extends Error {
3740
4075
  name = "InsightsBadRequestError";
3741
4076
  };
3742
4077
  var InsightsPayloadTooLargeError = class extends Error {
4078
+ maximumBytes;
3743
4079
  name = "InsightsPayloadTooLargeError";
3744
4080
  constructor(maximumBytes) {
3745
4081
  super(`Event payload exceeds ${maximumBytes} bytes`);
@@ -3749,8 +4085,9 @@ var InsightsPayloadTooLargeError = class extends Error {
3749
4085
  //#endregion
3750
4086
  //#region ../../packages/server/dist/insights/eventInput.mjs
3751
4087
  const MAX_EVENT_STRING_LENGTH = 1024;
3752
- const EVENT_BODY_MAX_BYTES = 16 * 1024;
3753
- const eventKeys = new Set([
4088
+ const MAX_IDENTITY_LENGTH$2 = 255;
4089
+ const EVENT_BODY_MAX_BYTES = 16384;
4090
+ const eventKeys = /* @__PURE__ */ new Set([
3754
4091
  "type",
3755
4092
  "installId",
3756
4093
  "toBundleId",
@@ -3767,18 +4104,23 @@ const eventKeys = new Set([
3767
4104
  "updateStrategy",
3768
4105
  "sdkVersion"
3769
4106
  ]);
3770
- function isRecord(value) {
4107
+ function isRecord$1(value) {
3771
4108
  return typeof value === "object" && value !== null && !Array.isArray(value);
3772
4109
  }
3773
4110
  function requireStringField(payload, key) {
3774
4111
  const value = payload[key];
3775
- if (typeof value !== "string" || value.length === 0 || value.length > MAX_EVENT_STRING_LENGTH) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
4112
+ 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}`);
3776
4113
  return value;
3777
4114
  }
3778
4115
  function requireNullableStringField(payload, key) {
3779
4116
  if (payload[key] === null) return null;
3780
4117
  return requireStringField(payload, key);
3781
4118
  }
4119
+ function requireIdentityField(payload, key) {
4120
+ const value = requireStringField(payload, key);
4121
+ if (value.length > MAX_IDENTITY_LENGTH$2) throw new InsightsBadRequestError(`Invalid event field: ${key}`);
4122
+ return value;
4123
+ }
3782
4124
  async function readBoundedText(request) {
3783
4125
  const contentLength = request.headers.get("content-length");
3784
4126
  const declaredByteLength = Number(contentLength);
@@ -3810,13 +4152,13 @@ async function parseJson(request) {
3810
4152
  }
3811
4153
  }
3812
4154
  function requireEvent(payload) {
3813
- if (!isRecord(payload) || Object.keys(payload).some((key) => !eventKeys.has(key))) throw new InsightsBadRequestError("Invalid event payload");
4155
+ if (!isRecord$1(payload) || Object.keys(payload).some((key) => !eventKeys.has(key))) throw new InsightsBadRequestError("Invalid event payload");
3814
4156
  const platform = requireStringField(payload, "platform");
3815
4157
  if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid event field: platform");
3816
4158
  const base = {
3817
- installId: requireStringField(payload, "installId"),
4159
+ installId: requireIdentityField(payload, "installId"),
3818
4160
  toBundleId: requireStringField(payload, "toBundleId"),
3819
- ...payload.userId === void 0 ? {} : { userId: requireStringField(payload, "userId") },
4161
+ ...payload.userId === void 0 ? {} : { userId: requireIdentityField(payload, "userId") },
3820
4162
  ...payload.username === void 0 ? {} : { username: requireStringField(payload, "username") },
3821
4163
  platform,
3822
4164
  appVersion: requireStringField(payload, "appVersion"),
@@ -3855,59 +4197,130 @@ function requireEvent(payload) {
3855
4197
  async function parseBundleEventRequest(request) {
3856
4198
  return requireEvent(await parseJson(request));
3857
4199
  }
4200
+ function createBundleEventRow(input) {
4201
+ input = requireEvent(input);
4202
+ const base = {
4203
+ app_version: input.appVersion,
4204
+ channel: input.channel,
4205
+ cohort: input.cohort,
4206
+ fingerprint_hash: input.fingerprintHash,
4207
+ from_release_id: input.fromReleaseId,
4208
+ id: createUUIDv7(),
4209
+ install_id: input.installId,
4210
+ platform: input.platform,
4211
+ received_at_ms: Date.now(),
4212
+ sdk_version: input.sdkVersion ?? null,
4213
+ to_bundle_id: input.toBundleId,
4214
+ to_release_id: input.toReleaseId,
4215
+ user_id: input.userId ?? null,
4216
+ username: input.username ?? null
4217
+ };
4218
+ switch (input.type) {
4219
+ case "UPDATE_APPLIED":
4220
+ case "RECOVERED":
4221
+ case "RELEASE_ADOPTED": return {
4222
+ ...base,
4223
+ from_bundle_id: input.fromBundleId,
4224
+ type: input.type,
4225
+ update_strategy: input.updateStrategy
4226
+ };
4227
+ case "UNCHANGED": return {
4228
+ ...base,
4229
+ from_bundle_id: null,
4230
+ type: input.type,
4231
+ update_strategy: null
4232
+ };
4233
+ }
4234
+ }
3858
4235
  //#endregion
3859
4236
  //#region ../../packages/server/dist/insights/queryInput.mjs
3860
- const EVENT_LIST_BOUNDS = {
3861
- defaultValue: 50,
3862
- maximum: 100,
3863
- minimum: 1
4237
+ const MAX_PAGE_LIMIT$1 = 100;
4238
+ const MAX_IDENTITY_LENGTH$1 = 255;
4239
+ const MAX_CURSOR_LENGTH$1 = 8192;
4240
+ const readSingle = (url, key) => {
4241
+ const values = url.searchParams.getAll(key);
4242
+ if (values.length > 1) throw new InsightsBadRequestError(`Duplicate '${key}' query parameter.`);
4243
+ return values[0];
3864
4244
  };
3865
- const EVENT_LIST_OFFSET_BOUNDS = {
3866
- defaultValue: 0,
3867
- minimum: 0
4245
+ const readPageLimit = (url) => {
4246
+ const value = readSingle(url, "limit");
4247
+ if (value === void 0) return void 0;
4248
+ const limit = Number(value);
4249
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT$1) throw new InsightsBadRequestError("Invalid 'limit' query parameter.");
4250
+ return limit;
4251
+ };
4252
+ const readCursor = (url) => {
4253
+ const cursor = readSingle(url, "cursor");
4254
+ if (cursor !== void 0 && (cursor.length === 0 || cursor.length > MAX_CURSOR_LENGTH$1)) throw new InsightsBadRequestError("Invalid 'cursor' query parameter.");
4255
+ return cursor;
4256
+ };
4257
+ const readId = (url, key, maximumLength = MAX_IDENTITY_LENGTH$1) => {
4258
+ const value = readSingle(url, key);
4259
+ if (value === void 0 || value.length === 0 || value.length > maximumLength) throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
4260
+ return value;
3868
4261
  };
3869
- const MAX_USER_ID_LENGTH = 1024;
3870
- function parseInteger(url, key, bounds) {
3871
- const value = url.searchParams.get(key);
3872
- if (value === null) return bounds.defaultValue;
3873
- const parsed = Number(value);
3874
- if (!Number.isSafeInteger(parsed) || parsed < bounds.minimum || bounds.maximum !== void 0 && parsed > bounds.maximum) throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
3875
- return parsed;
3876
- }
3877
- const parsePagination = (request) => {
4262
+ const readTimestamp = (url, key) => {
4263
+ const raw = readSingle(url, key);
4264
+ if (raw === void 0) return void 0;
4265
+ const value = Number(raw);
4266
+ if (!raw.length || !Number.isSafeInteger(value) || value < 0) throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
4267
+ return value;
4268
+ };
4269
+ const readScope$1 = (url) => {
4270
+ const platform = readSingle(url, "platform");
4271
+ if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid 'platform' query parameter.");
4272
+ return {
4273
+ platform,
4274
+ channel: readId(url, "channel", 1024)
4275
+ };
4276
+ };
4277
+ const parseEventPageInput = (request) => {
3878
4278
  const url = new URL(request.url);
4279
+ const bundleFields = [
4280
+ "bundleId",
4281
+ "outcome",
4282
+ "platform",
4283
+ "channel"
4284
+ ];
4285
+ let bundle;
4286
+ if (bundleFields.some((key) => url.searchParams.has(key))) {
4287
+ const outcome = readSingle(url, "outcome");
4288
+ if (outcome !== "applied" && outcome !== "recovered" && outcome !== "adopted") throw new InsightsBadRequestError("Invalid 'outcome' query parameter.");
4289
+ bundle = {
4290
+ ...readScope$1(url),
4291
+ bundleId: readId(url, "bundleId", 1024),
4292
+ outcome
4293
+ };
4294
+ }
3879
4295
  return {
3880
- limit: parseInteger(url, "limit", EVENT_LIST_BOUNDS),
3881
- offset: parseInteger(url, "offset", EVENT_LIST_OFFSET_BOUNDS)
4296
+ beforeReceivedAtMs: readTimestamp(url, "beforeReceivedAtMs"),
4297
+ sinceMs: readTimestamp(url, "sinceMs"),
4298
+ cursor: readCursor(url),
4299
+ limit: readPageLimit(url),
4300
+ ...bundle === void 0 ? {} : { bundle }
3882
4301
  };
3883
4302
  };
3884
- const parseInsightsQuery = (request) => {
3885
- const window = new URL(request.url).searchParams.get("window") ?? "24h";
3886
- if (window !== "24h" && window !== "7d" && window !== "30d" && window !== "all") throw new InsightsBadRequestError("Invalid 'window' query parameter.");
4303
+ const parseUserInstallationPageInput = (request) => {
4304
+ const url = new URL(request.url);
4305
+ const cursor = readCursor(url);
4306
+ const limit = readPageLimit(url);
3887
4307
  return {
3888
- ...parsePagination(request),
3889
- window
4308
+ userId: readId(url, "userId"),
4309
+ ...cursor === void 0 ? {} : { cursor },
4310
+ ...limit === void 0 ? {} : { limit }
3890
4311
  };
3891
4312
  };
3892
- const parseActiveInstallationInput = (request) => {
4313
+ const parseReportingOverviewInput = (request) => {
3893
4314
  const url = new URL(request.url);
3894
- const windows = url.searchParams.getAll("window");
3895
- if (windows.length > 1) throw new InsightsBadRequestError("Duplicate 'window' query parameter.");
3896
- const window = windows[0] ?? "30d";
4315
+ const window = readSingle(url, "window") ?? "30d";
3897
4316
  if (window !== "24h" && window !== "7d" && window !== "30d") throw new InsightsBadRequestError("Invalid 'window' query parameter.");
3898
- const userIds = url.searchParams.getAll("userId");
3899
- if (userIds.length > 1) throw new InsightsBadRequestError("Duplicate 'userId' query parameter.");
3900
- const userId = userIds[0];
3901
- if (userId !== void 0 && (userId.length === 0 || userId.length > MAX_USER_ID_LENGTH)) throw new InsightsBadRequestError("Invalid 'userId' query parameter.");
3902
- return userId === void 0 ? { window } : {
4317
+ const bundleId = readSingle(url, "bundleId");
4318
+ return {
4319
+ ...readScope$1(url),
3903
4320
  window,
3904
- userId
4321
+ ...bundleId === void 0 ? {} : { bundleId: readId(url, "bundleId", 1024) }
3905
4322
  };
3906
4323
  };
3907
- const parseSearchInput = (request) => ({
3908
- ...parsePagination(request),
3909
- query: new URL(request.url).searchParams.get("query")?.trim() ?? ""
3910
- });
3911
4324
  //#endregion
3912
4325
  //#region ../../packages/server/dist/insights/routes.mjs
3913
4326
  const json = (body, status) => Response.json(body, {
@@ -3917,7 +4330,11 @@ const json = (body, status) => Response.json(body, {
3917
4330
  const requireParam = (params, key) => {
3918
4331
  const value = params[key];
3919
4332
  if (value === void 0 || value.length === 0) throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
3920
- return value;
4333
+ try {
4334
+ return decodeURIComponent(value);
4335
+ } catch {
4336
+ throw new InsightsBadRequestError(`Invalid route parameter: ${key}`);
4337
+ }
3921
4338
  };
3922
4339
  const run = async (operation) => {
3923
4340
  try {
@@ -3925,10 +4342,6 @@ const run = async (operation) => {
3925
4342
  } catch (error) {
3926
4343
  if (error instanceof InsightsBadRequestError) return json({ error: error.message }, 400);
3927
4344
  if (error instanceof InsightsPayloadTooLargeError) return json({ error: error.message }, 413);
3928
- if (error instanceof InsightsScanLimitExceededError) return json({ error: {
3929
- code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
3930
- limit: error.limit
3931
- } }, 503);
3932
4345
  throw error;
3933
4346
  }
3934
4347
  };
@@ -3938,32 +4351,27 @@ const createInsightsRouteHandlers = (provider) => ({
3938
4351
  await provider.appendBundleEvent(await parseBundleEventRequest(request));
3939
4352
  return new Response(null, { status: 204 });
3940
4353
  }),
3941
- getBundleEventSummary: (params) => query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
3942
- getBundleEventInsights: (params, request) => query(() => {
3943
- const input = parseInsightsQuery(request);
3944
- return provider.getBundleEventInsights(requireParam(params, "id"), input.window, input.limit, input.offset);
4354
+ getReportingOverview: (_params, request) => query(() => provider.getReportingOverview(parseReportingOverviewInput(request))),
4355
+ getInstallation: (params) => run(async () => {
4356
+ const installation = await provider.getInstallation({ installId: requireParam(params, "installId") });
4357
+ return installation === null ? json({ error: "Installation not found" }, 404) : json(installation, 200);
3945
4358
  }),
3946
- getBundleEventOverview: () => query(() => provider.getBundleEventOverview()),
3947
- getActiveInstallationOverview: (_params, request) => query(() => provider.getActiveInstallationOverview(parseActiveInstallationInput(request))),
3948
- searchInstallations: (_params, request) => query(() => {
3949
- const input = parseSearchInput(request);
3950
- return provider.searchInstallations(input.query, input.limit, input.offset);
3951
- }),
3952
- getInstallationHistory: (params, request) => query(() => {
3953
- const input = parsePagination(request);
3954
- return provider.getInstallationHistory(requireParam(params, "installId"), input.limit, input.offset);
3955
- })
4359
+ listEvents: (_params, request) => query(() => provider.listEvents(parseEventPageInput(request))),
4360
+ listInstallationEvents: (params, request) => query(() => provider.listInstallationEvents({
4361
+ ...parseEventPageInput(request),
4362
+ installId: requireParam(params, "installId")
4363
+ })),
4364
+ pageInstallationsByCurrentUserId: (_params, request) => query(() => provider.pageInstallationsByCurrentUserId(parseUserInstallationPageInput(request)))
3956
4365
  });
3957
4366
  const registerInsightsClientRoutes = (add) => {
3958
4367
  add("POST", "/events", "appendBundleEvent");
3959
4368
  };
3960
4369
  const registerInsightsAdminRoutes = (add) => {
3961
- add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
3962
- add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
3963
- add("GET", "/installations/overview", "getBundleEventOverview");
3964
- add("GET", "/installations/active", "getActiveInstallationOverview");
3965
- add("GET", "/installations", "searchInstallations");
3966
- add("GET", "/installations/:installId/events", "getInstallationHistory");
4370
+ add("GET", "/events", "listEvents");
4371
+ add("GET", "/overview", "getReportingOverview");
4372
+ add("GET", "/installations", "pageInstallationsByCurrentUserId");
4373
+ add("GET", "/installations/:installId/events", "listInstallationEvents");
4374
+ add("GET", "/installations/:installId", "getInstallation");
3967
4375
  };
3968
4376
  //#endregion
3969
4377
  //#region ../../packages/server/dist/internalRouter.mjs
@@ -4125,461 +4533,311 @@ function createHotUpdaterHandlers(api, insights, apiKeyAuth, downloadStorageObje
4125
4533
  });
4126
4534
  }
4127
4535
  //#endregion
4128
- //#region ../../packages/server/dist/insights/bounded/activeOverview.mjs
4129
- const HOUR_MS = 3600 * 1e3;
4130
- const DAY_MS = 24 * HOUR_MS;
4131
- const ACTIVE_BUNDLE_EVENT_TYPES = [
4132
- "UPDATE_APPLIED",
4133
- "RECOVERED",
4134
- "RELEASE_ADOPTED",
4135
- "UNCHANGED"
4136
- ];
4137
- const activeWindowDefinitions = {
4138
- "24h": {
4139
- bucketCount: 24,
4140
- bucketSizeMs: HOUR_MS
4141
- },
4142
- "7d": {
4143
- bucketCount: 7,
4144
- bucketSizeMs: DAY_MS
4145
- },
4146
- "30d": {
4147
- bucketCount: 30,
4148
- bucketSizeMs: DAY_MS
4149
- }
4536
+ //#region ../../packages/server/dist/insights/provider.mjs
4537
+ const DEFAULT_PAGE_LIMIT = 50;
4538
+ const MAX_PAGE_LIMIT = 100;
4539
+ const MAX_EVENT_ID_LENGTH = 1024;
4540
+ const MAX_IDENTITY_LENGTH = 255;
4541
+ const MAX_CURSOR_LENGTH = 8192;
4542
+ const WINDOW_MS = {
4543
+ "24h": 864e5,
4544
+ "7d": 6048e5,
4545
+ "30d": 2592e6
4546
+ };
4547
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4548
+ const requireString = (value, label, maximumLength) => {
4549
+ 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}.`);
4550
+ return value;
4150
4551
  };
4151
- function getActiveWindowDefinition(window) {
4152
- return activeWindowDefinitions[window];
4153
- }
4154
- function compareCodePoints$1(left, right) {
4155
- if (left < right) return -1;
4156
- if (left > right) return 1;
4157
- return 0;
4158
- }
4159
- function isNewer(candidate, current) {
4160
- return candidate.received_at_ms > current.received_at_ms || candidate.received_at_ms === current.received_at_ms && compareCodePoints$1(candidate.id, current.id) > 0;
4161
- }
4162
- function collectActiveInstallationOverview(request) {
4163
- const definition = getActiveWindowDefinition(request.window);
4164
- const windowStartMs = request.asOfMs - definition.bucketCount * definition.bucketSizeMs;
4165
- const rows = request.rows.filter((row) => row.received_at_ms >= windowStartMs && row.received_at_ms < request.asOfMs);
4166
- const latestByInstall = /* @__PURE__ */ new Map();
4167
- for (const row of rows) {
4168
- const current = latestByInstall.get(row.install_id);
4169
- if (current === void 0 || isNewer(row, current)) latestByInstall.set(row.install_id, row);
4552
+ const requireTimestamp = (value, label) => {
4553
+ if (!Number.isSafeInteger(value) || Number(value) < 0) throw new InsightsBadRequestError(`Invalid ${label}.`);
4554
+ return Number(value);
4555
+ };
4556
+ const readLimit = (value) => {
4557
+ const limit = value ?? DEFAULT_PAGE_LIMIT;
4558
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_LIMIT) throw new InsightsBadRequestError("Invalid page limit.");
4559
+ return limit;
4560
+ };
4561
+ const encodeBase64Url = (value) => {
4562
+ const bytes = new TextEncoder().encode(value);
4563
+ let binary = "";
4564
+ for (const byte of bytes) binary += String.fromCharCode(byte);
4565
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
4566
+ };
4567
+ const decodeBase64Url = (value) => {
4568
+ if (value.length === 0 || value.length > MAX_CURSOR_LENGTH) throw new InsightsBadRequestError("Invalid Insights cursor.");
4569
+ try {
4570
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
4571
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
4572
+ const binary = atob(padded);
4573
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
4574
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
4575
+ } catch {
4576
+ throw new InsightsBadRequestError("Invalid Insights cursor.");
4170
4577
  }
4171
- const selectedRows = [...latestByInstall.values()].filter((row) => request.userId === void 0 || row.user_id === request.userId);
4172
- const selectedInstallIds = new Set(selectedRows.map((row) => row.install_id));
4173
- const bundleCounts = /* @__PURE__ */ new Map();
4174
- for (const row of selectedRows) bundleCounts.set(row.to_bundle_id, (bundleCounts.get(row.to_bundle_id) ?? 0) + 1);
4175
- const latestByBucket = Array.from({ length: definition.bucketCount }, () => /* @__PURE__ */ new Map());
4176
- for (const row of rows) {
4177
- if (!selectedInstallIds.has(row.install_id)) continue;
4178
- const bucket = latestByBucket[Math.floor((row.received_at_ms - windowStartMs) / definition.bucketSizeMs)];
4179
- if (bucket === void 0) continue;
4180
- const current = bucket.get(row.install_id);
4181
- if (current === void 0 || isNewer(row, current)) bucket.set(row.install_id, row);
4578
+ };
4579
+ const encodeCursor = (value) => encodeBase64Url(JSON.stringify(value));
4580
+ const decodeCursor = (value) => {
4581
+ try {
4582
+ return JSON.parse(decodeBase64Url(value));
4583
+ } catch (error) {
4584
+ if (error instanceof InsightsBadRequestError) throw error;
4585
+ throw new InsightsBadRequestError("Invalid Insights cursor.");
4182
4586
  }
4183
- const bundleCountsByBucket = latestByBucket.map((bucket) => {
4184
- const counts = /* @__PURE__ */ new Map();
4185
- for (const row of bucket.values()) counts.set(row.to_bundle_id, (counts.get(row.to_bundle_id) ?? 0) + 1);
4186
- return counts;
4187
- });
4188
- const bundleObservationTotals = /* @__PURE__ */ new Map();
4189
- for (const counts of bundleCountsByBucket) for (const [bundleId, count] of counts) bundleObservationTotals.set(bundleId, (bundleObservationTotals.get(bundleId) ?? 0) + count);
4587
+ };
4588
+ const readScope = (input) => {
4589
+ if (input.platform !== "ios" && input.platform !== "android") throw new InsightsBadRequestError("Invalid Insights platform.");
4190
4590
  return {
4191
- asOfMs: request.asOfMs,
4192
- window: request.window,
4193
- activeInstallations: selectedInstallIds.size,
4194
- series: latestByBucket.map((bucket, index) => ({
4195
- bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
4196
- value: bucket.size
4197
- })),
4198
- bundleSeries: [...bundleObservationTotals].sort(([leftId, leftTotal], [rightId, rightTotal]) => rightTotal - leftTotal || compareCodePoints$1(leftId, rightId)).map(([bundleId]) => ({
4199
- bundleId,
4200
- series: bundleCountsByBucket.map((counts, index) => ({
4201
- bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
4202
- value: counts.get(bundleId) ?? 0
4203
- }))
4204
- })),
4205
- bundles: [...bundleCounts].map(([bundleId, installations]) => ({
4206
- bundleId,
4207
- installations
4208
- })).sort((left, right) => right.installations - left.installations || compareCodePoints$1(left.bundleId, right.bundleId))
4209
- };
4210
- }
4211
- //#endregion
4212
- //#region ../../packages/server/dist/insights/bounded/scan.mjs
4213
- const INSIGHTS_SCAN_MAX_ROWS = 5e4;
4214
- const INSIGHTS_MATERIALIZATION_LIMIT = INSIGHTS_SCAN_MAX_ROWS + 1;
4215
- const INSIGHTS_SCAN_PAGE_SIZE = 1e3;
4216
- const INSIGHTS_LOWER_BOUND_ID = "00000000-0000-0000-0000-000000000000";
4217
- const compareCodePoints = (left, right) => {
4218
- if (left < right) return -1;
4219
- if (left > right) return 1;
4220
- return 0;
4221
- };
4222
- const compareEventNewest = (left, right) => right.received_at_ms - left.received_at_ms || compareCodePoints(right.id, left.id);
4223
- const compareEventOldest = (left, right) => left.received_at_ms - right.received_at_ms || compareCodePoints(left.id, right.id);
4224
- const materializeEventRows = async (scope) => {
4225
- const rows = [];
4226
- const seenIds = /* @__PURE__ */ new Set();
4227
- let after = scope.lowerBoundMs === void 0 ? void 0 : {
4228
- receivedAtMs: scope.lowerBoundMs,
4229
- id: INSIGHTS_LOWER_BOUND_ID
4591
+ platform: input.platform,
4592
+ channel: requireString(input.channel, "channel", MAX_EVENT_ID_LENGTH)
4230
4593
  };
4231
- while (rows.length < INSIGHTS_MATERIALIZATION_LIMIT) {
4232
- const limit = Math.min(INSIGHTS_SCAN_PAGE_SIZE, INSIGHTS_MATERIALIZATION_LIMIT - rows.length);
4233
- const page = await scope.persistence.scan({
4234
- beforeReceivedAtMs: scope.cutoffMs,
4235
- ...after === void 0 ? {} : { after },
4236
- limit
4237
- });
4238
- if (page.length === 0) break;
4239
- if (page.length > limit) throw new InsightsPersistenceOrderError();
4240
- let previous = after === void 0 ? void 0 : {
4241
- received_at_ms: after.receivedAtMs,
4242
- id: after.id
4594
+ };
4595
+ const bundleFilter = (input) => {
4596
+ const scope = readScope(input);
4597
+ const bundleId = requireString(input.bundleId, "bundle ID", MAX_EVENT_ID_LENGTH);
4598
+ switch (input.outcome) {
4599
+ case "applied": return {
4600
+ ...scope,
4601
+ type: "UPDATE_APPLIED",
4602
+ toBundleId: bundleId
4243
4603
  };
4244
- for (const row of page) {
4245
- if (row.received_at_ms >= scope.cutoffMs || previous !== void 0 && compareEventOldest(previous, row) >= 0 || seenIds.has(row.id)) throw new InsightsPersistenceOrderError();
4246
- seenIds.add(row.id);
4247
- rows.push(row);
4248
- previous = row;
4249
- }
4250
- const last = page.at(-1);
4251
- if (last !== void 0) after = {
4252
- receivedAtMs: last.received_at_ms,
4253
- id: last.id
4604
+ case "recovered": return {
4605
+ ...scope,
4606
+ type: "RECOVERED",
4607
+ fromBundleId: bundleId
4608
+ };
4609
+ case "adopted": return {
4610
+ ...scope,
4611
+ type: "RELEASE_ADOPTED",
4612
+ toBundleId: bundleId
4254
4613
  };
4255
- if (rows.length > 5e4) throw new InsightsScanLimitExceededError(INSIGHTS_SCAN_MAX_ROWS);
4614
+ default: throw new InsightsBadRequestError("Invalid Insights outcome.");
4256
4615
  }
4257
- return rows;
4258
4616
  };
4259
- var InsightsPersistenceOrderError = class extends Error {
4260
- name = "InsightsPersistenceOrderError";
4261
- constructor() {
4262
- super("Insights persistence did not advance its scan cursor.");
4617
+ const sameFilter = (left, right) => {
4618
+ if (!isRecord(left) || left.kind !== right.kind) return false;
4619
+ switch (right.kind) {
4620
+ case "all": return true;
4621
+ case "installationMovement": return left.installId === right.installId;
4622
+ 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);
4263
4623
  }
4264
4624
  };
4265
- const materializeActiveRows = async (scope, window) => {
4266
- const definition = getActiveWindowDefinition(window);
4267
- const durationMs = definition.bucketCount * definition.bucketSizeMs;
4268
- return (await materializeEventRows({
4269
- ...scope,
4270
- lowerBoundMs: scope.cutoffMs - durationMs
4271
- })).filter((row) => row.received_at_ms >= scope.cutoffMs - durationMs && ACTIVE_BUNDLE_EVENT_TYPES.includes(row.type));
4272
- };
4273
- const startOfUtcHour = (value) => {
4274
- const date = new Date(value);
4275
- return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
4276
- };
4277
- const startOfUtcDay = (value) => {
4278
- const date = new Date(value);
4279
- return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
4280
- };
4281
- const getWindowRange = (window, now) => {
4282
- if (window === "24h") return {
4283
- sizeMs: 3600 * 1e3,
4284
- rangeStart: startOfUtcHour(now) - 1380 * 60 * 1e3
4285
- };
4286
- const days = window === "7d" ? 7 : 30;
4625
+ const readEventCursor = (value, filter) => {
4626
+ const cursor = decodeCursor(value);
4627
+ if (!isRecord(cursor) || cursor.version !== 2 || cursor.kind !== "events" || !isRecord(cursor.filter) || !isRecord(cursor.after)) throw new InsightsBadRequestError("Invalid Insights cursor.");
4628
+ if (!sameFilter(cursor.filter, filter)) throw new InsightsBadRequestError("Insights cursor does not match the requested events.");
4629
+ if (typeof cursor.after.id !== "string" || !isUUIDv7(cursor.after.id)) throw new InsightsBadRequestError("Invalid Insights event cursor ID.");
4287
4630
  return {
4288
- sizeMs: 1440 * 60 * 1e3,
4289
- rangeStart: startOfUtcDay(now) - (days - 1) * 24 * 60 * 60 * 1e3
4631
+ after: {
4632
+ id: cursor.after.id,
4633
+ receivedAtMs: requireTimestamp(cursor.after.receivedAtMs, "event cursor")
4634
+ },
4635
+ beforeReceivedAtMs: requireTimestamp(cursor.beforeReceivedAtMs, "event cutoff"),
4636
+ kind: "events",
4637
+ sinceMs: requireTimestamp(cursor.sinceMs, "event start"),
4638
+ filter,
4639
+ version: 2
4290
4640
  };
4291
4641
  };
4292
- const materializeRowsForWindow = async (scope, window) => {
4293
- if (window === "all") return materializeEventRows(scope);
4294
- const range = getWindowRange(window, scope.cutoffMs);
4295
- return (await materializeEventRows({
4296
- ...scope,
4297
- lowerBoundMs: range.rangeStart
4298
- })).filter(({ received_at_ms }) => received_at_ms >= range.rangeStart);
4299
- };
4300
- const bucketStart = (receivedAtMs, sizeMs) => sizeMs === 3600 * 1e3 ? startOfUtcHour(receivedAtMs) : startOfUtcDay(receivedAtMs);
4301
- const createSeries = (request) => {
4302
- const range = request.window === "all" ? void 0 : getWindowRange(request.window, request.cutoffMs);
4303
- const sizeMs = range?.sizeMs ?? 1440 * 60 * 1e3;
4304
- const installIdsByBucket = /* @__PURE__ */ new Map();
4305
- let oldestMs = request.cutoffMs;
4306
- for (const row of request.rows) {
4307
- oldestMs = Math.min(oldestMs, row.received_at_ms);
4308
- const start = bucketStart(row.received_at_ms, sizeMs);
4309
- const installIds = installIdsByBucket.get(start) ?? /* @__PURE__ */ new Set();
4310
- installIds.add(row.install_id);
4311
- installIdsByBucket.set(start, installIds);
4312
- }
4313
- const first = range?.rangeStart ?? startOfUtcDay(oldestMs);
4314
- const last = bucketStart(request.cutoffMs, sizeMs);
4315
- return Array.from({ length: Math.floor((last - first) / sizeMs) + 1 }, (_, index) => {
4316
- const start = first + index * sizeMs;
4317
- return {
4318
- bucketStartMs: start,
4319
- value: installIdsByBucket.get(start)?.size ?? 0
4320
- };
4321
- });
4322
- };
4323
- const collectEventActivity = (request) => {
4324
- const range = request.window === "all" ? void 0 : getWindowRange(request.window, request.cutoffMs);
4325
- const rows = range === void 0 ? request.rows : request.rows.filter(({ received_at_ms }) => received_at_ms >= range.rangeStart && received_at_ms < request.cutoffMs);
4326
- const installs = /* @__PURE__ */ new Set();
4327
- const installsByCohort = /* @__PURE__ */ new Map();
4328
- for (const row of rows) {
4329
- installs.add(row.install_id);
4330
- const cohort = installsByCohort.get(row.cohort) ?? /* @__PURE__ */ new Set();
4331
- cohort.add(row.install_id);
4332
- installsByCohort.set(row.cohort, cohort);
4333
- }
4642
+ const readUserInstallationCursor = (value, userId) => {
4643
+ const cursor = decodeCursor(value);
4644
+ 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.");
4334
4645
  return {
4335
- summary: installs.size,
4336
- cohorts: [...installsByCohort].sort(([left], [right]) => compareCodePoints(left, right)).map(([cohort, installIds]) => ({
4337
- cohort,
4338
- value: installIds.size
4339
- })),
4340
- series: createSeries({
4341
- ...request,
4342
- rows
4343
- })
4646
+ afterInstallId: requireString(cursor.afterInstallId, "installation cursor", MAX_IDENTITY_LENGTH),
4647
+ kind: "user-installations",
4648
+ userId,
4649
+ version: 1
4344
4650
  };
4345
4651
  };
4346
- //#endregion
4347
- //#region ../../packages/server/dist/insights/bounded/installationSearch.mjs
4348
- function toSearchRow(row) {
4349
- return {
4350
- installId: row.install_id,
4351
- username: row.username,
4352
- userId: row.user_id,
4353
- lastKnownBundleId: row.to_bundle_id,
4354
- latestStatus: row.type,
4355
- platform: row.platform,
4356
- appVersion: row.app_version,
4357
- channel: row.channel,
4358
- cohort: row.cohort,
4359
- receivedAtMs: row.received_at_ms
4360
- };
4361
- }
4362
- function matchesIdentity(row, query) {
4363
- return row.install_id.toLowerCase().includes(query) || row.user_id?.toLowerCase().includes(query) === true || row.username?.toLowerCase().includes(query) === true;
4364
- }
4365
- function searchEventInstallations(request) {
4366
- const query = request.query.toLowerCase();
4367
- const matchingInstallIds = /* @__PURE__ */ new Set();
4368
- for (const row of request.rows) if (query.length === 0 || matchesIdentity(row, query)) matchingInstallIds.add(row.install_id);
4369
- const latestByInstall = /* @__PURE__ */ new Map();
4370
- for (const row of request.rows) {
4371
- if (!matchingInstallIds.has(row.install_id)) continue;
4372
- const current = latestByInstall.get(row.install_id);
4373
- if (current === void 0 || compareEventNewest(row, current) < 0) latestByInstall.set(row.install_id, row);
4374
- }
4375
- const matchingRows = [...latestByInstall.values()].sort((left, right) => compareCodePoints(left.install_id, right.install_id));
4376
- const pageSize = Math.min(Math.max(request.limit, 0), 100);
4377
- return {
4378
- data: matchingRows.slice(request.offset, request.offset + pageSize).map(toSearchRow),
4379
- pagination: {
4380
- total: matchingRows.length,
4381
- limit: request.limit,
4382
- offset: request.offset
4383
- }
4384
- };
4385
- }
4386
- //#endregion
4387
- //#region ../../packages/server/dist/insights/bounded/persistence.mjs
4388
- function createBundleEventRow(input) {
4389
- const base = {
4390
- id: createUUIDv7(),
4391
- install_id: input.installId,
4392
- user_id: input.userId ?? null,
4393
- username: input.username ?? null,
4394
- from_release_id: input.fromReleaseId,
4395
- to_release_id: input.toReleaseId,
4396
- to_bundle_id: input.toBundleId,
4397
- platform: input.platform,
4398
- app_version: input.appVersion,
4399
- channel: input.channel,
4400
- cohort: input.cohort,
4401
- fingerprint_hash: input.fingerprintHash,
4402
- sdk_version: input.sdkVersion ?? null,
4403
- received_at_ms: Date.now()
4404
- };
4405
- switch (input.type) {
4406
- case "UPDATE_APPLIED":
4407
- case "RECOVERED":
4408
- case "RELEASE_ADOPTED": return {
4409
- ...base,
4410
- type: input.type,
4411
- from_bundle_id: input.fromBundleId,
4412
- update_strategy: input.updateStrategy
4413
- };
4414
- case "UNCHANGED": return {
4415
- ...base,
4416
- type: input.type,
4417
- from_bundle_id: null,
4418
- update_strategy: null
4419
- };
4652
+ const compareEventNewest = (left, right) => right.received_at_ms - left.received_at_ms || compareInsightsText(right.id, left.id);
4653
+ const isAfterEventCursor = (row, after) => row.received_at_ms < after.receivedAtMs || row.received_at_ms === after.receivedAtMs && compareInsightsText(row.id, after.id) < 0;
4654
+ const assertEventRows = (rows, input) => {
4655
+ if (rows.length > input.limit) throw new Error("Insights database returned too many event rows.");
4656
+ for (let index = 0; index < rows.length; index += 1) {
4657
+ const row = rows[index];
4658
+ const previous = rows[index - 1];
4659
+ 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.");
4420
4660
  }
4421
- }
4422
- //#endregion
4423
- //#region ../../packages/server/dist/insights/bounded/provider.mjs
4424
- const isTransitionEventRow = (row) => row.type === "UPDATE_APPLIED" || row.type === "RECOVERED";
4425
- const toHistoryRow = (row) => ({
4426
- id: row.id,
4427
- type: row.type,
4661
+ };
4662
+ const toEventHistoryRow = (row) => ({
4663
+ appVersion: row.app_version,
4664
+ channel: row.channel,
4665
+ cohort: row.cohort,
4428
4666
  fromBundleId: row.from_bundle_id,
4667
+ id: row.id,
4668
+ installId: row.install_id,
4669
+ platform: row.platform,
4670
+ receivedAtMs: row.received_at_ms,
4429
4671
  toBundleId: row.to_bundle_id,
4430
- username: row.username,
4672
+ type: row.type,
4431
4673
  userId: row.user_id,
4432
- platform: row.platform,
4674
+ username: row.username
4675
+ });
4676
+ const toInstallationRow = (row) => ({
4433
4677
  appVersion: row.app_version,
4434
4678
  channel: row.channel,
4435
4679
  cohort: row.cohort,
4436
- receivedAtMs: row.received_at_ms
4680
+ installId: row.install_id,
4681
+ lastKnownBundleId: row.to_bundle_id,
4682
+ latestStatus: row.type,
4683
+ platform: row.platform,
4684
+ receivedAtMs: row.received_at_ms,
4685
+ userId: row.user_id,
4686
+ username: row.username
4437
4687
  });
4438
- const isInstalledForBundle = (row, bundleId) => row.type === "UPDATE_APPLIED" && row.to_bundle_id === bundleId;
4439
- const isRecoveredFromBundle = (row, bundleId) => row.type === "RECOVERED" && row.from_bundle_id === bundleId;
4440
- const countDistinctInstallations = (rows) => new Set(rows.map(({ install_id }) => install_id)).size;
4441
- const getInsightsResult = async (persistence, bundleId, window, limit, offset) => {
4442
- const scope = {
4443
- persistence,
4444
- cutoffMs: Date.now()
4688
+ const pageEventRows = async (model, input, filter, map) => {
4689
+ const limit = readLimit(input.limit);
4690
+ const cursor = input.cursor === void 0 ? void 0 : readEventCursor(input.cursor, filter);
4691
+ const beforeReceivedAtMs = cursor?.beforeReceivedAtMs ?? (input.beforeReceivedAtMs === void 0 ? Date.now() : requireTimestamp(input.beforeReceivedAtMs, "event cutoff"));
4692
+ if (cursor !== void 0 && input.beforeReceivedAtMs !== void 0 && input.beforeReceivedAtMs !== beforeReceivedAtMs) throw new InsightsBadRequestError("Insights cursor does not match the requested event cutoff.");
4693
+ const sinceMs = cursor?.sinceMs ?? (input.sinceMs === void 0 ? 0 : requireTimestamp(input.sinceMs, "event start"));
4694
+ if (sinceMs > beforeReceivedAtMs || input.sinceMs !== void 0 && input.sinceMs !== sinceMs || cursor !== void 0 && (cursor.after.receivedAtMs < sinceMs || cursor.after.receivedAtMs >= beforeReceivedAtMs)) throw new InsightsBadRequestError("Insights cursor or range does not match the requested event start.");
4695
+ const databaseInput = {
4696
+ filter,
4697
+ sinceMs,
4698
+ beforeReceivedAtMs,
4699
+ ...cursor === void 0 ? {} : { after: cursor.after },
4700
+ limit: limit + 1
4445
4701
  };
4446
- const rows = (await materializeRowsForWindow(scope, window)).filter(isTransitionEventRow);
4447
- const installedRows = rows.filter((row) => isInstalledForBundle(row, bundleId));
4448
- const recoveredRows = rows.filter((row) => isRecoveredFromBundle(row, bundleId));
4449
- const installed = collectEventActivity({
4450
- rows: installedRows,
4451
- window,
4452
- cutoffMs: scope.cutoffMs
4453
- });
4454
- const recovered = collectEventActivity({
4455
- rows: recoveredRows,
4456
- window,
4457
- cutoffMs: scope.cutoffMs
4458
- });
4459
- const recentRows = [...installedRows, ...recoveredRows].sort(compareEventNewest);
4702
+ const rows = await model.listEvents(databaseInput);
4703
+ assertEventRows(rows, databaseInput);
4704
+ const pageRows = rows.slice(0, limit);
4705
+ const last = pageRows.at(-1);
4460
4706
  return {
4461
- summary: {
4462
- installed: installed.summary,
4463
- recovered: recovered.summary
4464
- },
4465
- series: {
4466
- installed: installed.series,
4467
- recovered: recovered.series
4468
- },
4469
- cohorts: {
4470
- installed: installed.cohorts,
4471
- recovered: recovered.cohorts
4472
- },
4473
- recentEvents: {
4474
- data: recentRows.slice(offset, offset + limit).map(toHistoryRow),
4475
- pagination: {
4476
- total: recentRows.length,
4477
- limit,
4478
- offset
4479
- }
4480
- }
4707
+ beforeReceivedAtMs,
4708
+ data: pageRows.map(map),
4709
+ nextCursor: rows.length > limit && last ? encodeCursor({
4710
+ after: {
4711
+ id: last.id,
4712
+ receivedAtMs: last.received_at_ms
4713
+ },
4714
+ beforeReceivedAtMs,
4715
+ kind: "events",
4716
+ filter,
4717
+ sinceMs,
4718
+ version: 2
4719
+ }) : null
4481
4720
  };
4482
4721
  };
4483
- const getInsightsSummaries = async (persistence, bundleIds, window) => {
4484
- const normalizedBundleIds = [...new Set(bundleIds)];
4485
- if (normalizedBundleIds.length === 0) return [];
4486
- const requestedBundleIds = new Set(normalizedBundleIds);
4487
- const installedByBundleId = /* @__PURE__ */ new Map();
4488
- const recoveredByBundleId = /* @__PURE__ */ new Map();
4489
- const rows = (await materializeRowsForWindow({
4490
- persistence,
4491
- cutoffMs: Date.now()
4492
- }, window)).filter(isTransitionEventRow);
4493
- for (const row of rows) {
4494
- const bundleId = row.type === "UPDATE_APPLIED" ? row.to_bundle_id : row.from_bundle_id;
4495
- if (!requestedBundleIds.has(bundleId)) continue;
4496
- const counts = row.type === "UPDATE_APPLIED" ? installedByBundleId : recoveredByBundleId;
4497
- const installIds = counts.get(bundleId) ?? /* @__PURE__ */ new Set();
4498
- installIds.add(row.install_id);
4499
- counts.set(bundleId, installIds);
4722
+ const assertInstallationRows = (rows, input) => {
4723
+ if (rows.length > input.limit) throw new Error("Insights database returned too many installation rows.");
4724
+ for (let index = 0; index < rows.length; index += 1) {
4725
+ const row = rows[index];
4726
+ const previous = rows[index - 1];
4727
+ 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.");
4500
4728
  }
4501
- return normalizedBundleIds.map((bundleId) => ({
4502
- bundleId,
4503
- installed: installedByBundleId.get(bundleId)?.size ?? 0,
4504
- recovered: recoveredByBundleId.get(bundleId)?.size ?? 0
4505
- }));
4506
4729
  };
4507
- const createInsightsProvider = (persistence) => Object.freeze({
4508
- mode: "bounded",
4509
- maxMatchingRows: INSIGHTS_SCAN_MAX_ROWS,
4730
+ const createInsightsProvider = (model) => Object.freeze({
4510
4731
  async appendBundleEvent(input) {
4511
- await persistence.append(createBundleEventRow(input));
4732
+ const event = createBundleEventRow(input);
4733
+ await model.record({
4734
+ event,
4735
+ installation: toInsightsInstallationRow(event)
4736
+ });
4512
4737
  },
4513
- async getBundleEventSummary(bundleId) {
4514
- const rows = (await materializeEventRows({
4515
- persistence,
4516
- cutoffMs: Date.now()
4517
- })).filter(isTransitionEventRow);
4518
- return {
4519
- installed: countDistinctInstallations(rows.filter((row) => isInstalledForBundle(row, bundleId))),
4520
- recovered: countDistinctInstallations(rows.filter((row) => isRecoveredFromBundle(row, bundleId)))
4738
+ listEvents(input) {
4739
+ const filter = input.bundle === void 0 ? { kind: "all" } : {
4740
+ kind: "bundle",
4741
+ ...bundleFilter(input.bundle)
4521
4742
  };
4743
+ return pageEventRows(model, input, filter, toEventHistoryRow);
4522
4744
  },
4523
- getBundleEventSummaries(bundleIds, window) {
4524
- return getInsightsSummaries(persistence, bundleIds, window);
4745
+ async listInstallationEvents(input) {
4746
+ if ("bundle" in input && input.bundle !== void 0) throw new InsightsBadRequestError("Installation movement queries cannot include a bundle filter.");
4747
+ const installId = requireString(input.installId, "install ID", MAX_IDENTITY_LENGTH);
4748
+ return pageEventRows(model, input, {
4749
+ kind: "installationMovement",
4750
+ installId
4751
+ }, (row) => toEventHistoryRow(row));
4525
4752
  },
4526
- getBundleEventInsights(bundleId, window, limit, offset) {
4527
- return getInsightsResult(persistence, bundleId, window, limit, offset);
4753
+ async getInstallation({ installId }) {
4754
+ const normalizedInstallId = requireString(installId, "install ID", MAX_IDENTITY_LENGTH);
4755
+ const rows = await model.findInstallations({ installId: normalizedInstallId });
4756
+ const row = rows[0] ?? null;
4757
+ if (rows.length > 1 || row !== null && row.install_id !== normalizedInstallId) throw new Error("Insights database returned an invalid installation.");
4758
+ return row === null ? null : toInstallationRow(row);
4528
4759
  },
4529
- async getBundleEventOverview() {
4530
- const rows = await materializeEventRows({
4531
- persistence,
4532
- cutoffMs: Date.now()
4533
- });
4534
- const latestByInstall = /* @__PURE__ */ new Map();
4535
- for (const row of rows) {
4536
- const current = latestByInstall.get(row.install_id);
4537
- if (current === void 0 || compareEventNewest(row, current) < 0) latestByInstall.set(row.install_id, row);
4538
- }
4539
- const counts = /* @__PURE__ */ new Map();
4540
- for (const row of latestByInstall.values()) counts.set(row.to_bundle_id, (counts.get(row.to_bundle_id) ?? 0) + 1);
4760
+ async pageInstallationsByCurrentUserId(input) {
4761
+ const userId = requireString(input.userId, "user ID", MAX_IDENTITY_LENGTH);
4762
+ const limit = readLimit(input.limit);
4763
+ const cursor = input.cursor === void 0 ? void 0 : readUserInstallationCursor(input.cursor, userId);
4764
+ const databaseInput = {
4765
+ userId,
4766
+ ...cursor === void 0 ? {} : { afterInstallId: cursor.afterInstallId },
4767
+ limit: limit + 1
4768
+ };
4769
+ const rows = await model.findInstallations(databaseInput);
4770
+ assertInstallationRows(rows, databaseInput);
4771
+ const pageRows = rows.slice(0, limit);
4772
+ const last = pageRows.at(-1);
4541
4773
  return {
4542
- trackedInstallations: latestByInstall.size,
4543
- bundles: [...counts].map(([bundleId, installations]) => ({
4544
- bundleId,
4545
- installations
4546
- })).sort((left, right) => right.installations - left.installations || compareCodePoints(left.bundleId, right.bundleId))
4774
+ data: pageRows.map(toInstallationRow),
4775
+ nextCursor: rows.length > limit && last ? encodeCursor({
4776
+ afterInstallId: last.install_id,
4777
+ kind: "user-installations",
4778
+ userId,
4779
+ version: 1
4780
+ }) : null
4547
4781
  };
4548
4782
  },
4549
- async getActiveInstallationOverview(input) {
4550
- const asOfMs = Date.now();
4551
- return collectActiveInstallationOverview({
4552
- rows: await materializeActiveRows({
4553
- persistence,
4554
- cutoffMs: asOfMs
4555
- }, input.window),
4556
- asOfMs,
4557
- window: input.window,
4558
- ...input.userId === void 0 ? {} : { userId: input.userId }
4559
- });
4560
- },
4561
- async searchInstallations(query, limit, offset) {
4562
- return searchEventInstallations({
4563
- rows: await materializeEventRows({
4564
- persistence,
4565
- cutoffMs: Date.now()
4783
+ async getReportingOverview(input) {
4784
+ const scope = readScope(input);
4785
+ const { window } = input;
4786
+ if (!Object.hasOwn(WINDOW_MS, window)) throw new InsightsBadRequestError("Invalid reporting installation window.");
4787
+ const beforeReceivedAtMs = Date.now();
4788
+ const sinceMs = Math.max(0, beforeReceivedAtMs - WINDOW_MS[window]);
4789
+ const measure = async (count) => {
4790
+ const value = await count;
4791
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Insights database returned an invalid count.");
4792
+ return {
4793
+ count: value,
4794
+ measuredAtMs: Date.now()
4795
+ };
4796
+ };
4797
+ const bundleId = input.bundleId === void 0 ? void 0 : requireString(input.bundleId, "bundle ID", MAX_EVENT_ID_LENGTH);
4798
+ const reporting = measure(model.countInstallations({
4799
+ ...scope,
4800
+ sinceMs
4801
+ }));
4802
+ if (bundleId === void 0) return {
4803
+ ...scope,
4804
+ window,
4805
+ sinceMs,
4806
+ beforeReceivedAtMs,
4807
+ reportingInstallations: await reporting
4808
+ };
4809
+ const countOutcome = (outcome) => measure(model.countEvents({
4810
+ filter: bundleFilter({
4811
+ ...scope,
4812
+ bundleId,
4813
+ outcome
4566
4814
  }),
4567
- query,
4568
- limit,
4569
- offset
4570
- });
4571
- },
4572
- async getInstallationHistory(installId, limit, offset) {
4573
- const ordered = (await materializeEventRows({
4574
- persistence,
4575
- cutoffMs: Date.now()
4576
- })).filter((row) => row.install_id === installId && isTransitionEventRow(row)).toSorted(compareEventNewest);
4815
+ sinceMs,
4816
+ beforeReceivedAtMs
4817
+ }));
4818
+ const [reportingInstallations, bundleInstallations, appliedReports, recoveredReports, adoptedReports] = await Promise.all([
4819
+ reporting,
4820
+ measure(model.countInstallations({
4821
+ ...scope,
4822
+ sinceMs,
4823
+ bundleId
4824
+ })),
4825
+ countOutcome("applied"),
4826
+ countOutcome("recovered"),
4827
+ countOutcome("adopted")
4828
+ ]);
4577
4829
  return {
4578
- data: ordered.slice(offset, offset + limit).map(toHistoryRow),
4579
- pagination: {
4580
- total: ordered.length,
4581
- limit,
4582
- offset
4830
+ ...scope,
4831
+ window,
4832
+ sinceMs,
4833
+ beforeReceivedAtMs,
4834
+ reportingInstallations,
4835
+ bundle: {
4836
+ bundleId,
4837
+ reportingInstallations: bundleInstallations,
4838
+ appliedReports,
4839
+ recoveredReports,
4840
+ adoptedReports
4583
4841
  }
4584
4842
  };
4585
4843
  }
@@ -4620,7 +4878,7 @@ const hashApiKey = async (apiKey) => {
4620
4878
  return bytesToBase64Url(new Uint8Array(digest));
4621
4879
  };
4622
4880
  const apiKeyId = () => {
4623
- const bytes = new Uint8Array(16);
4881
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
4624
4882
  crypto.getRandomValues(bytes);
4625
4883
  return `api-${bytesToBase64Url(bytes)}`;
4626
4884
  };
@@ -4657,7 +4915,7 @@ const registerApiKey = async (input) => {
4657
4915
  });
4658
4916
  };
4659
4917
  const createApiKey = (input) => {
4660
- const bytes = new Uint8Array(32);
4918
+ const bytes = /* @__PURE__ */ new Uint8Array(32);
4661
4919
  crypto.getRandomValues(bytes);
4662
4920
  return registerApiKey({
4663
4921
  apiKey: bytesToBase64Url(bytes),
@@ -5077,6 +5335,8 @@ const unsupportedSchemaUpgradeMessage = (version) => `Hot Updater v1 cannot migr
5077
5335
  //#endregion
5078
5336
  //#region ../../packages/server/dist/db/schemaReadiness.mjs
5079
5337
  var HotUpdaterSchemaMigrationRequiredError = class extends Error {
5338
+ adapterName;
5339
+ currentVersion;
5080
5340
  constructor(adapterName, currentVersion) {
5081
5341
  super(currentVersion === void 0 ? `Hot Updater database schema is not initialized for ${adapterName}. Run \`hot-updater db migrate\` before using this adapter.` : unsupportedSchemaUpgradeMessage(currentVersion));
5082
5342
  this.adapterName = adapterName;
@@ -5106,7 +5366,7 @@ const sqlProviders = [
5106
5366
  const noSqlProviders = ["mongodb"];
5107
5367
  [...sqlProviders, ...noSqlProviders];
5108
5368
  function isDatabasePlugin(plugin) {
5109
- return typeof plugin === "object" && plugin !== null && "name" in plugin && typeof plugin.name === "string" && "models" in plugin && typeof plugin.models === "object" && plugin.models !== null && "bundles" in plugin.models && typeof plugin.models.bundles === "object" && plugin.models.bundles !== null && "findById" in plugin.models.bundles && typeof plugin.models.bundles.findById === "function" && "findMany" in plugin.models.bundles && typeof plugin.models.bundles.findMany === "function" && "count" in plugin.models.bundles && typeof plugin.models.bundles.count === "function" && "bundlePatches" in plugin.models && typeof plugin.models.bundlePatches === "object" && plugin.models.bundlePatches !== null && "findByBundleIds" in plugin.models.bundlePatches && typeof plugin.models.bundlePatches.findByBundleIds === "function" && "channels" in plugin.models && typeof plugin.models.channels === "object" && plugin.models.channels !== null && "insert" in plugin.models.channels && typeof plugin.models.channels.insert === "function" && "list" in plugin.models.channels && typeof plugin.models.channels.list === "function" && "delete" in plugin.models.channels && typeof plugin.models.channels.delete === "function" && "insights" in plugin.models && typeof plugin.models.insights === "object" && plugin.models.insights !== null && "append" in plugin.models.insights && typeof plugin.models.insights.append === "function" && "scan" in plugin.models.insights && typeof plugin.models.insights.scan === "function" && "apiKeys" in plugin.models && typeof plugin.models.apiKeys === "object" && plugin.models.apiKeys !== null && "create" in plugin.models.apiKeys && typeof plugin.models.apiKeys.create === "function" && "findByHash" in plugin.models.apiKeys && typeof plugin.models.apiKeys.findByHash === "function" && "list" in plugin.models.apiKeys && typeof plugin.models.apiKeys.list === "function" && "revoke" in plugin.models.apiKeys && typeof plugin.models.apiKeys.revoke === "function" && "commit" in plugin && typeof plugin.commit === "function" && (!("dispose" in plugin) || plugin.dispose === void 0 || typeof plugin.dispose === "function");
5369
+ return typeof plugin === "object" && plugin !== null && "name" in plugin && typeof plugin.name === "string" && "models" in plugin && typeof plugin.models === "object" && plugin.models !== null && "bundles" in plugin.models && typeof plugin.models.bundles === "object" && plugin.models.bundles !== null && "findById" in plugin.models.bundles && typeof plugin.models.bundles.findById === "function" && "findMany" in plugin.models.bundles && typeof plugin.models.bundles.findMany === "function" && "count" in plugin.models.bundles && typeof plugin.models.bundles.count === "function" && "bundlePatches" in plugin.models && typeof plugin.models.bundlePatches === "object" && plugin.models.bundlePatches !== null && "findByBundleIds" in plugin.models.bundlePatches && typeof plugin.models.bundlePatches.findByBundleIds === "function" && "channels" in plugin.models && typeof plugin.models.channels === "object" && plugin.models.channels !== null && "insert" in plugin.models.channels && typeof plugin.models.channels.insert === "function" && "list" in plugin.models.channels && typeof plugin.models.channels.list === "function" && "delete" in plugin.models.channels && typeof plugin.models.channels.delete === "function" && "insights" in plugin.models && typeof plugin.models.insights === "object" && plugin.models.insights !== null && "record" in plugin.models.insights && typeof plugin.models.insights.record === "function" && "listEvents" in plugin.models.insights && typeof plugin.models.insights.listEvents === "function" && "findInstallations" in plugin.models.insights && typeof plugin.models.insights.findInstallations === "function" && "countEvents" in plugin.models.insights && typeof plugin.models.insights.countEvents === "function" && "countInstallations" in plugin.models.insights && typeof plugin.models.insights.countInstallations === "function" && "apiKeys" in plugin.models && typeof plugin.models.apiKeys === "object" && plugin.models.apiKeys !== null && "create" in plugin.models.apiKeys && typeof plugin.models.apiKeys.create === "function" && "findByHash" in plugin.models.apiKeys && typeof plugin.models.apiKeys.findByHash === "function" && "list" in plugin.models.apiKeys && typeof plugin.models.apiKeys.list === "function" && "revoke" in plugin.models.apiKeys && typeof plugin.models.apiKeys.revoke === "function" && "commit" in plugin && typeof plugin.commit === "function" && (!("dispose" in plugin) || plugin.dispose === void 0 || typeof plugin.dispose === "function");
5110
5370
  }
5111
5371
  //#endregion
5112
5372
  //#region ../../packages/server/dist/storageAccess.mjs
@@ -5209,27 +5469,41 @@ const hotUpdaterCoreMetadata = Symbol.for("@hot-updater/server/core-metadata");
5209
5469
  function createHotUpdaterCore(options) {
5210
5470
  for (const key of ["authorityId", "catalogId"]) if (Object.hasOwn(options, key)) throw new TypeError(`Remove ${key} from createHotUpdater options. Catalog identity is managed internally.`);
5211
5471
  const database = options.database;
5212
- const { downloadStorageObject, readStorageText, resolveFileUrl } = createStorageAccess((options.storage ?? []).map((storage) => {
5472
+ const storagePlugins = (options.storage ?? []).map((storage) => {
5213
5473
  assertStorageOperations(storage, ["get", "getDownloadUrl"]);
5214
5474
  return storage;
5215
- }));
5475
+ });
5476
+ const { downloadStorageObject, readStorageText, resolveFileUrl } = createStorageAccess(storagePlugins);
5216
5477
  const adapterCapabilities = database;
5217
5478
  if (!isDatabasePlugin(database)) throw new Error("@hot-updater/server only supports database plugins.");
5218
5479
  const plugin = database;
5219
- const assertSchemaReady = createSchemaReadinessChecker(adapterCapabilities.adapterName ?? plugin.name, adapterCapabilities.createMigrator);
5480
+ const adapterName = adapterCapabilities.adapterName ?? plugin.name;
5481
+ const assertSchemaReady = createSchemaReadinessChecker(adapterName, adapterCapabilities.createMigrator);
5220
5482
  const core = createDatabasePluginCore(plugin, resolveFileUrl, {
5221
5483
  beforeOperation: assertSchemaReady,
5222
5484
  readStorageText
5223
5485
  });
5224
5486
  const clientAccess = normalizeClientAccess(options.clientAccess);
5225
5487
  const insights = createInsightsProvider({
5226
- async append(row) {
5488
+ async record(input) {
5489
+ await assertSchemaReady();
5490
+ return plugin.models.insights.record(input);
5491
+ },
5492
+ async listEvents(input) {
5227
5493
  await assertSchemaReady();
5228
- return plugin.models.insights.append(row);
5494
+ return plugin.models.insights.listEvents(input);
5229
5495
  },
5230
- async scan(input) {
5496
+ async findInstallations(input) {
5231
5497
  await assertSchemaReady();
5232
- return plugin.models.insights.scan(input);
5498
+ return plugin.models.insights.findInstallations(input);
5499
+ },
5500
+ async countInstallations(input) {
5501
+ await assertSchemaReady();
5502
+ return plugin.models.insights.countInstallations(input);
5503
+ },
5504
+ async countEvents(input) {
5505
+ await assertSchemaReady();
5506
+ return plugin.models.insights.countEvents(input);
5233
5507
  }
5234
5508
  });
5235
5509
  const apiKeys = createApiKeyManagement({
@@ -5270,6 +5544,7 @@ function createHotUpdater(options) {
5270
5544
  //#endregion
5271
5545
  //#region src/firebaseDatabaseParserShared.ts
5272
5546
  var FirebaseDatabaseDataError = class extends Error {
5547
+ source;
5273
5548
  name = "FirebaseDatabaseDataError";
5274
5549
  constructor(source) {
5275
5550
  super(`Invalid Firebase database data at "${source}".`);
@@ -5381,6 +5656,24 @@ const parseFirebaseBundleEventRow = (value, source) => {
5381
5656
  received_at_ms: number(property(input, "received_at_ms"), source)
5382
5657
  };
5383
5658
  };
5659
+ const parseFirebaseInsightsInstallationRow = (value, source) => {
5660
+ const input = record(value, source);
5661
+ const type = string(property(input, "type"), source);
5662
+ if (type !== "UPDATE_APPLIED" && type !== "RECOVERED" && type !== "RELEASE_ADOPTED" && type !== "UNCHANGED") throw new FirebaseDatabaseDataError(source);
5663
+ return {
5664
+ id: string(property(input, "id"), source),
5665
+ type,
5666
+ install_id: string(property(input, "install_id"), source),
5667
+ user_id: nullableString(property(input, "user_id"), source),
5668
+ username: nullableString(property(input, "username"), source),
5669
+ to_bundle_id: string(property(input, "to_bundle_id"), source),
5670
+ platform: platform(property(input, "platform"), source),
5671
+ app_version: string(property(input, "app_version"), source),
5672
+ channel: string(property(input, "channel"), source),
5673
+ cohort: string(property(input, "cohort"), source),
5674
+ received_at_ms: number(property(input, "received_at_ms"), source)
5675
+ };
5676
+ };
5384
5677
  const parseFirebaseApiKeyRow = (value, source) => {
5385
5678
  const input = record(value, source);
5386
5679
  const role = string(property(input, "role"), source);
@@ -5461,13 +5754,15 @@ const matchesCondition = (row, condition) => {
5461
5754
  switch (condition.operator ?? "eq") {
5462
5755
  case "eq": {
5463
5756
  if (typeof expected !== "string") return actual === expected;
5464
- const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
5757
+ const mode = "mode" in condition ? condition.mode : void 0;
5758
+ const comparison = normalizeStringComparison(actual, expected, mode);
5465
5759
  return comparison !== null && comparison[0] === comparison[1];
5466
5760
  }
5467
5761
  case "ne": {
5468
5762
  if (actual === null || actual === void 0) return false;
5469
5763
  if (typeof expected !== "string") return actual !== expected;
5470
- const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
5764
+ const mode = "mode" in condition ? condition.mode : void 0;
5765
+ const comparison = normalizeStringComparison(actual, expected, mode);
5471
5766
  return comparison === null || comparison[0] !== comparison[1];
5472
5767
  }
5473
5768
  case "gt":
@@ -5492,17 +5787,20 @@ const matchesCondition = (row, condition) => {
5492
5787
  }
5493
5788
  case "contains": {
5494
5789
  if (typeof expected !== "string") return false;
5495
- const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
5790
+ const mode = "mode" in condition ? condition.mode : void 0;
5791
+ const comparison = normalizeStringComparison(actual, expected, mode);
5496
5792
  return comparison?.[0].includes(comparison[1]) ?? false;
5497
5793
  }
5498
5794
  case "starts_with": {
5499
5795
  if (typeof expected !== "string") return false;
5500
- const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
5796
+ const mode = "mode" in condition ? condition.mode : void 0;
5797
+ const comparison = normalizeStringComparison(actual, expected, mode);
5501
5798
  return comparison?.[0].startsWith(comparison[1]) ?? false;
5502
5799
  }
5503
5800
  case "ends_with": {
5504
5801
  if (typeof expected !== "string") return false;
5505
- const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
5802
+ const mode = "mode" in condition ? condition.mode : void 0;
5803
+ const comparison = normalizeStringComparison(actual, expected, mode);
5506
5804
  return comparison?.[0].endsWith(comparison[1]) ?? false;
5507
5805
  }
5508
5806
  }
@@ -5547,6 +5845,7 @@ const queryFirebaseDatabaseRows = (rows, input) => {
5547
5845
  //#endregion
5548
5846
  //#region src/firebaseDatabaseState.ts
5549
5847
  var FirebaseDatabaseConstraintError = class extends Error {
5848
+ constraint;
5550
5849
  name = "FirebaseDatabaseConstraintError";
5551
5850
  constructor(constraint) {
5552
5851
  super(`Firebase database constraint failed: ${constraint}`);
@@ -5557,6 +5856,7 @@ const cloneFirebaseDatabaseSnapshot = (snapshot) => ({
5557
5856
  bundles: new Map(snapshot.bundles),
5558
5857
  bundlePatches: new Map(snapshot.bundlePatches),
5559
5858
  bundleEvents: new Map(snapshot.bundleEvents),
5859
+ bundleInstallations: new Map(snapshot.bundleInstallations),
5560
5860
  channels: new Map(snapshot.channels),
5561
5861
  apiKeys: new Map(snapshot.apiKeys),
5562
5862
  releaseCatalogs: new Map(snapshot.releaseCatalogs),
@@ -5586,6 +5886,13 @@ const createFirebaseDatabaseState = (snapshot) => ({
5586
5886
  requireUnique(snapshot.bundleEvents, input.data.id, input.model);
5587
5887
  snapshot.bundleEvents.set(input.data.id, input.data);
5588
5888
  return input.data;
5889
+ case "bundle_installations": {
5890
+ const current = snapshot.bundleInstallations.get(input.data.install_id);
5891
+ if (current && input.onConflict === "ignore") return current;
5892
+ if (current) throw new FirebaseDatabaseConstraintError("bundle_installations.install_id.unique");
5893
+ snapshot.bundleInstallations.set(input.data.install_id, input.data);
5894
+ return input.data;
5895
+ }
5589
5896
  case "releases":
5590
5897
  requireUnique(snapshot.releases, input.data.id, input.model);
5591
5898
  if (!snapshot.channels.has(input.data.channel_id)) throw new FirebaseDatabaseConstraintError("releases.channel_id.foreign-key");
@@ -5615,6 +5922,16 @@ const createFirebaseDatabaseState = (snapshot) => ({
5615
5922
  }
5616
5923
  },
5617
5924
  async update(input) {
5925
+ if (input.model === "bundle_installations") {
5926
+ const current = [...snapshot.bundleInstallations.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
5927
+ if (!current) return null;
5928
+ const updated = {
5929
+ ...current,
5930
+ ...input.update
5931
+ };
5932
+ snapshot.bundleInstallations.set(current.install_id, updated);
5933
+ return updated;
5934
+ }
5618
5935
  if (input.model === "api_keys") {
5619
5936
  const current = [...snapshot.apiKeys.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
5620
5937
  if (!current) return null;
@@ -5676,6 +5993,8 @@ const createFirebaseDatabaseState = (snapshot) => ({
5676
5993
  case "bundles": return distinctCount([...snapshot.bundles.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
5677
5994
  case "bundle_patches": return distinctCount([...snapshot.bundlePatches.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
5678
5995
  case "releases": return distinctCount([...snapshot.releases.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
5996
+ case "bundle_installations": return distinctCount([...snapshot.bundleInstallations.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
5997
+ case "bundle_events": return distinctCount([...snapshot.bundleEvents.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
5679
5998
  }
5680
5999
  },
5681
6000
  async findOne(input) {
@@ -5686,6 +6005,7 @@ const createFirebaseDatabaseState = (snapshot) => ({
5686
6005
  case "bundle_patches": return [...snapshot.bundlePatches.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
5687
6006
  case "releases": return [...snapshot.releases.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
5688
6007
  case "release_catalogs": return [...snapshot.releaseCatalogs.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
6008
+ case "bundle_installations": return [...snapshot.bundleInstallations.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
5689
6009
  }
5690
6010
  },
5691
6011
  async findMany(input) {
@@ -5693,6 +6013,7 @@ const createFirebaseDatabaseState = (snapshot) => ({
5693
6013
  case "bundles": return queryFirebaseDatabaseRows([...snapshot.bundles.values()], input);
5694
6014
  case "bundle_patches": return queryFirebaseDatabaseRows([...snapshot.bundlePatches.values()], input);
5695
6015
  case "bundle_events": return queryFirebaseDatabaseRows([...snapshot.bundleEvents.values()], input);
6016
+ case "bundle_installations": return queryFirebaseDatabaseRows([...snapshot.bundleInstallations.values()], input);
5696
6017
  case "channels": return queryFirebaseDatabaseRows([...snapshot.channels.values()], input);
5697
6018
  case "api_keys": return queryFirebaseDatabaseRows([...snapshot.apiKeys.values()], input);
5698
6019
  case "releases": return queryFirebaseDatabaseRows([...snapshot.releases.values()], input);
@@ -5705,6 +6026,7 @@ const createFirebaseDatabaseState = (snapshot) => ({
5705
6026
  const FIREBASE_V1_COLLECTION_NAMES = {
5706
6027
  apiKeys: "hot_updater_v1_api_keys",
5707
6028
  bundleEvents: "hot_updater_v1_bundle_events",
6029
+ bundleInstallations: "hot_updater_v1_bundle_installations",
5708
6030
  bundlePatches: "hot_updater_v1_bundle_patches",
5709
6031
  bundles: "hot_updater_v1_bundles",
5710
6032
  channels: "hot_updater_v1_channels",
@@ -5715,6 +6037,7 @@ const FIREBASE_V1_COLLECTION_NAMES = {
5715
6037
  //#endregion
5716
6038
  //#region src/firebaseDatabasePersistence.ts
5717
6039
  var FirebaseDatabaseAdapterVersionError = class extends Error {
6040
+ version;
5718
6041
  name = "FirebaseDatabaseAdapterVersionError";
5719
6042
  constructor(version) {
5720
6043
  super(`Unsupported Firebase database adapter version: ${String(version)}`);
@@ -5725,6 +6048,7 @@ const createFirebaseDatabaseCollections = (db) => ({
5725
6048
  bundles: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundles),
5726
6049
  bundlePatches: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundlePatches),
5727
6050
  bundleEvents: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundleEvents),
6051
+ bundleInstallations: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundleInstallations),
5728
6052
  channels: db.collection(FIREBASE_V1_COLLECTION_NAMES.channels),
5729
6053
  apiKeys: db.collection(FIREBASE_V1_COLLECTION_NAMES.apiKeys),
5730
6054
  releaseCatalogs: db.collection(FIREBASE_V1_COLLECTION_NAMES.releaseCatalogs),
@@ -5733,14 +6057,15 @@ const createFirebaseDatabaseCollections = (db) => ({
5733
6057
  });
5734
6058
  const firebaseChannelDocumentId = (name) => `name_${Buffer.from(name, "utf8").toString("base64url")}`;
5735
6059
  const firebaseChannelIdDocumentId = (id) => `channel_id_${Buffer.from(id, "utf8").toString("base64url")}`;
6060
+ const firebaseInstallationDocumentId = (id) => `install_${Buffer.from(id, "utf8").toString("base64url")}`;
5736
6061
  const requireFirebaseDocumentKey = (model, documentId, row) => {
5737
- if (documentId !== ("id" in row ? row.id : row.scope_key)) throw new FirebaseDatabaseConstraintError(`${model}.id.document-key`);
6062
+ if (documentId !== (model === "bundle_installations" ? firebaseInstallationDocumentId(row.install_id) : "id" in row ? row.id : row.scope_key)) throw new FirebaseDatabaseConstraintError(`${model}.id.document-key`);
5738
6063
  return row;
5739
6064
  };
5740
6065
  const documentMap = (model, documents) => {
5741
6066
  const rows = /* @__PURE__ */ new Map();
5742
6067
  for (const { row } of documents) {
5743
- const key = "id" in row ? row.id : row.scope_key;
6068
+ const key = model === "bundle_installations" ? row.install_id : "id" in row ? row.id : row.scope_key;
5744
6069
  if (rows.has(key)) throw new FirebaseDatabaseConstraintError(`${model}.id.unique`);
5745
6070
  rows.set(key, row);
5746
6071
  }
@@ -5755,10 +6080,6 @@ const patchMap = (snapshot) => documentMap("bundle_patches", snapshot.docs.map((
5755
6080
  document,
5756
6081
  row: parseFirebasePatchRow(document.data(), `bundle_patches/${document.id}`)
5757
6082
  })));
5758
- const eventMap = (snapshot) => documentMap("bundle_events", snapshot.docs.map((document) => ({
5759
- document,
5760
- row: parseFirebaseBundleEventRow(document.data(), `bundle_events/${document.id}`)
5761
- })));
5762
6083
  const channelMap = (snapshot) => {
5763
6084
  const rows = /* @__PURE__ */ new Map();
5764
6085
  const names = /* @__PURE__ */ new Set();
@@ -5789,18 +6110,18 @@ const toSnapshot = (documents) => {
5789
6110
  return {
5790
6111
  bundles: bundleMap(documents[0]),
5791
6112
  bundlePatches: patchMap(documents[1]),
5792
- bundleEvents: eventMap(documents[2]),
5793
- channels: channelMap(documents[3]),
5794
- apiKeys: apiKeyMap(documents[4]),
5795
- releases: releaseMap(documents[5]),
5796
- releaseCatalogs: releaseCatalogMap(documents[6])
6113
+ bundleEvents: /* @__PURE__ */ new Map(),
6114
+ bundleInstallations: /* @__PURE__ */ new Map(),
6115
+ channels: channelMap(documents[2]),
6116
+ apiKeys: apiKeyMap(documents[3]),
6117
+ releases: releaseMap(documents[4]),
6118
+ releaseCatalogs: releaseCatalogMap(documents[5])
5797
6119
  };
5798
6120
  };
5799
6121
  const loadFirebaseDatabaseSnapshot = async (collections) => {
5800
- const [bundles, patches, events, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
6122
+ const [bundles, patches, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
5801
6123
  collections.bundles.get(),
5802
6124
  collections.bundlePatches.get(),
5803
- collections.bundleEvents.get(),
5804
6125
  collections.channels.get(),
5805
6126
  collections.apiKeys.get(),
5806
6127
  collections.releases.get(),
@@ -5809,7 +6130,6 @@ const loadFirebaseDatabaseSnapshot = async (collections) => {
5809
6130
  return toSnapshot([
5810
6131
  bundles,
5811
6132
  patches,
5812
- events,
5813
6133
  channels,
5814
6134
  apiKeys,
5815
6135
  releases,
@@ -5817,10 +6137,9 @@ const loadFirebaseDatabaseSnapshot = async (collections) => {
5817
6137
  ]);
5818
6138
  };
5819
6139
  const loadFirebaseTransactionSnapshot = async (transaction, collections) => {
5820
- const [bundles, patches, events, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
6140
+ const [bundles, patches, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
5821
6141
  transaction.get(collections.bundles),
5822
6142
  transaction.get(collections.bundlePatches),
5823
- transaction.get(collections.bundleEvents),
5824
6143
  transaction.get(collections.channels),
5825
6144
  transaction.get(collections.apiKeys),
5826
6145
  transaction.get(collections.releases),
@@ -5829,7 +6148,6 @@ const loadFirebaseTransactionSnapshot = async (transaction, collections) => {
5829
6148
  return toSnapshot([
5830
6149
  bundles,
5831
6150
  patches,
5832
- events,
5833
6151
  channels,
5834
6152
  apiKeys,
5835
6153
  releases,
@@ -5855,13 +6173,6 @@ const persistFirebaseDatabaseSnapshot = ({ transaction, collections, before, aft
5855
6173
  after: after.bundlePatches,
5856
6174
  documentId: (row) => row.id
5857
6175
  });
5858
- persistCollection({
5859
- transaction,
5860
- collection: collections.bundleEvents,
5861
- before: before.bundleEvents,
5862
- after: after.bundleEvents,
5863
- documentId: (row) => row.id
5864
- });
5865
6176
  persistCollection({
5866
6177
  transaction,
5867
6178
  collection: collections.channels,
@@ -5893,7 +6204,7 @@ const persistFirebaseDatabaseSnapshot = ({ transaction, collections, before, aft
5893
6204
  documentId: (row) => row.scope_key
5894
6205
  });
5895
6206
  };
5896
- const migrateFirebaseDatabase = async (_db, collections) => {
6207
+ const migrateFirebaseDatabase = async (collections) => {
5897
6208
  const versionDocument = collections.settings.doc("database_adapter_version");
5898
6209
  const version = await versionDocument.get();
5899
6210
  const adapterVersion = version.data()?.version;
@@ -5904,7 +6215,9 @@ const migrateFirebaseDatabase = async (_db, collections) => {
5904
6215
  collections.bundlePatches.limit(1).get(),
5905
6216
  collections.channels.limit(1).get(),
5906
6217
  collections.releases.limit(1).get(),
5907
- collections.releaseCatalogs.limit(1).get()
6218
+ collections.releaseCatalogs.limit(1).get(),
6219
+ collections.bundleInstallations.limit(1).get(),
6220
+ collections.bundleEvents.limit(1).get()
5908
6221
  ])).some((snapshot) => !snapshot.empty)) throw new FirebaseDatabaseAdapterVersionError("v0");
5909
6222
  try {
5910
6223
  await versionDocument.create({ version: 4 });
@@ -5919,13 +6232,40 @@ const exactId = (input) => {
5919
6232
  const [condition] = input.where;
5920
6233
  return condition.field === "id" && (condition.operator === void 0 || condition.operator === "eq") && typeof condition.value === "string" ? condition.value : void 0;
5921
6234
  };
6235
+ const exactInstallId = (input) => {
6236
+ const condition = input.where?.find(({ field, operator }) => field === "install_id" && (operator === void 0 || operator === "eq"));
6237
+ return typeof condition?.value === "string" ? condition.value : void 0;
6238
+ };
6239
+ const firestoreOperator = (operator) => {
6240
+ switch (operator ?? "eq") {
6241
+ case "eq": return "==";
6242
+ case "ne": return "!=";
6243
+ case "gt": return ">";
6244
+ case "gte": return ">=";
6245
+ case "lt": return "<";
6246
+ case "lte": return "<=";
6247
+ case "in": return "in";
6248
+ case "not_in": return "not-in";
6249
+ default: return;
6250
+ }
6251
+ };
6252
+ const applyFirebaseWhere = (initial, where) => {
6253
+ let query = initial;
6254
+ for (const condition of where) {
6255
+ const operator = firestoreOperator(condition.operator);
6256
+ if (condition.connector === "OR" || operator === void 0) throw new FirebaseDatabaseConstraintError("query.unsupported");
6257
+ query = query.where(condition.field, operator, condition.value);
6258
+ }
6259
+ return query;
6260
+ };
5922
6261
  const firebaseDatabase = (config) => {
5923
- const adapter = createDatabasePluginAdapter("firebaseDatabase", (() => {
5924
- const db = (0, firebase_admin_firestore.getFirestore)((0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config));
6262
+ const implementation = (() => {
6263
+ const app = (0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config);
6264
+ const db = (0, firebase_admin_firestore.getFirestore)(app);
5925
6265
  const collections = createFirebaseDatabaseCollections(db);
5926
6266
  let migration;
5927
6267
  const ensureMigrated = () => {
5928
- migration ??= migrateFirebaseDatabase(db, collections).catch((error) => {
6268
+ migration ??= migrateFirebaseDatabase(collections).catch((error) => {
5929
6269
  migration = void 0;
5930
6270
  throw error;
5931
6271
  });
@@ -5948,14 +6288,74 @@ const firebaseDatabase = (config) => {
5948
6288
  };
5949
6289
  const read = async (operation) => {
5950
6290
  await ensureMigrated();
5951
- return operation(createFirebaseDatabaseState(await loadFirebaseDatabaseSnapshot(collections)));
6291
+ const snapshot = await loadFirebaseDatabaseSnapshot(collections);
6292
+ return operation(createFirebaseDatabaseState(snapshot));
5952
6293
  };
5953
6294
  return {
5954
- create: (input) => mutate((database) => database.create(input)),
5955
- update: (input) => mutate((database) => database.update(input)),
6295
+ recordInsights: async ({ event, installation }) => {
6296
+ await ensureMigrated();
6297
+ await db.runTransaction(async (transaction) => {
6298
+ const eventReference = collections.bundleEvents.doc(event.id);
6299
+ const installationReference = collections.bundleInstallations.doc(firebaseInstallationDocumentId(installation.install_id));
6300
+ const [storedEvent, storedInstallation] = await transaction.getAll(eventReference, installationReference);
6301
+ if (storedEvent.exists) return;
6302
+ const current = storedInstallation.exists ? requireFirebaseDocumentKey("bundle_installations", storedInstallation.id, parseFirebaseInsightsInstallationRow(storedInstallation.data(), `bundle_installations/${storedInstallation.id}`)) : null;
6303
+ transaction.create(eventReference, event);
6304
+ if (current === null || installation.received_at_ms > current.received_at_ms || installation.received_at_ms === current.received_at_ms && compareInsightsText(installation.id, current.id) > 0) transaction.set(installationReference, installation);
6305
+ });
6306
+ },
6307
+ create: async (input) => {
6308
+ if (input.model !== "bundle_events" && input.model !== "bundle_installations") return mutate((database) => database.create(input));
6309
+ await ensureMigrated();
6310
+ return db.runTransaction(async (transaction) => {
6311
+ const collection = input.model === "bundle_events" ? collections.bundleEvents : collections.bundleInstallations;
6312
+ const documentId = input.model === "bundle_events" ? input.data.id : firebaseInstallationDocumentId(input.data.install_id);
6313
+ const reference = collection.doc(documentId);
6314
+ const document = await transaction.get(reference);
6315
+ if (document.exists) {
6316
+ const row = input.model === "bundle_events" ? requireFirebaseDocumentKey("bundle_events", document.id, parseFirebaseBundleEventRow(document.data(), `bundle_events/${document.id}`)) : requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`));
6317
+ if (input.onConflict === "ignore") return row;
6318
+ throw new FirebaseDatabaseConstraintError(`${input.model}.id.unique`);
6319
+ }
6320
+ transaction.create(reference, input.data);
6321
+ return input.data;
6322
+ });
6323
+ },
6324
+ update: async (input) => {
6325
+ if (input.model !== "bundle_installations") return mutate((database) => database.update(input));
6326
+ const installId = exactInstallId(input);
6327
+ if (installId === void 0) return mutate((database) => database.update(input));
6328
+ await ensureMigrated();
6329
+ return db.runTransaction(async (transaction) => {
6330
+ const reference = collections.bundleInstallations.doc(firebaseInstallationDocumentId(installId));
6331
+ const document = await transaction.get(reference);
6332
+ if (!document.exists) return null;
6333
+ const current = requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`));
6334
+ if (!matchesFirebaseDatabaseWhere(current, input.where)) return null;
6335
+ const updated = {
6336
+ ...current,
6337
+ ...input.update
6338
+ };
6339
+ transaction.set(reference, updated, { merge: true });
6340
+ return updated;
6341
+ });
6342
+ },
5956
6343
  delete: (input) => mutate((database) => database.delete(input)),
5957
- count: (input) => read((database) => database.count(input)),
6344
+ count: async (input) => {
6345
+ if (input.model !== "bundle_installations" && input.model !== "bundle_events") return read((database) => database.count(input));
6346
+ await ensureMigrated();
6347
+ let query = applyFirebaseWhere(input.model === "bundle_events" ? collections.bundleEvents : collections.bundleInstallations, input.where ?? []);
6348
+ if (input.model === "bundle_events") query = query.orderBy("received_at_ms", "desc").orderBy("id", "desc");
6349
+ return (await query.count().get()).data().count;
6350
+ },
5958
6351
  findOne: async (input) => {
6352
+ if (input.model === "bundle_installations") {
6353
+ const installId = exactInstallId(input);
6354
+ if (installId === void 0) return read((database) => database.findOne(input));
6355
+ await ensureMigrated();
6356
+ const document = await collections.bundleInstallations.doc(firebaseInstallationDocumentId(installId)).get();
6357
+ return document.exists ? requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`)) : null;
6358
+ }
5959
6359
  const id = exactId(input);
5960
6360
  if (id === void 0) return read((database) => database.findOne(input));
5961
6361
  await ensureMigrated();
@@ -5976,9 +6376,21 @@ const firebaseDatabase = (config) => {
5976
6376
  }
5977
6377
  },
5978
6378
  findMany: async (input) => {
5979
- if (input.model !== "channels") return read((database) => database.findMany(input));
5980
- await ensureMigrated();
5981
- return queryFirebaseDatabaseRows(await loadFirebaseChannels(collections), input);
6379
+ if (input.model === "bundle_events" || input.model === "bundle_installations") {
6380
+ await ensureMigrated();
6381
+ const collection = input.model === "bundle_events" ? collections.bundleEvents : collections.bundleInstallations;
6382
+ let query = applyFirebaseWhere(collection, input.where ?? []);
6383
+ for (const order of input.orderBy ?? []) {
6384
+ if (order.nulls !== void 0) throw new FirebaseDatabaseConstraintError("query.unsupported");
6385
+ query = query.orderBy(order.field, order.direction);
6386
+ }
6387
+ return (await query.offset(input.offset).limit(input.limit).get()).docs.map((document) => input.model === "bundle_events" ? requireFirebaseDocumentKey("bundle_events", document.id, parseFirebaseBundleEventRow(document.data(), `bundle_events/${document.id}`)) : requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`)));
6388
+ }
6389
+ if (input.model === "channels") {
6390
+ await ensureMigrated();
6391
+ return queryFirebaseDatabaseRows(await loadFirebaseChannels(collections), input);
6392
+ }
6393
+ return read((database) => database.findMany(input));
5982
6394
  },
5983
6395
  insertChannel: async (input) => {
5984
6396
  await ensureMigrated();
@@ -6030,7 +6442,8 @@ const firebaseDatabase = (config) => {
6030
6442
  },
6031
6443
  transaction: (callback) => mutate(callback)
6032
6444
  };
6033
- })());
6445
+ })();
6446
+ const adapter = createDatabasePluginAdapter("firebaseDatabase", implementation);
6034
6447
  return createDatabasePlugin({
6035
6448
  name: "firebaseDatabase",
6036
6449
  models: adapter.models,
@@ -6040,7 +6453,8 @@ const firebaseDatabase = (config) => {
6040
6453
  //#endregion
6041
6454
  //#region src/firebaseStorage.ts
6042
6455
  const firebaseStorage = (config) => {
6043
- const bucket = (0, firebase_admin_storage.getStorage)((0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config)).bucket(config.storageBucket);
6456
+ const app = (0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config);
6457
+ const bucket = (0, firebase_admin_storage.getStorage)(app).bucket(config.storageBucket);
6044
6458
  const getStorageKey = createStorageKeyBuilder(config.basePath);
6045
6459
  const parseAndValidate = (storageUri) => {
6046
6460
  const parsed = parseStorageUri(storageUri, "gs");