@hot-updater/console 1.0.0-rc.20 → 1.0.0-rc.21

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.
Files changed (180) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{ConsoleFeatureUnavailable-B9VYGA3N.js → ConsoleFeatureUnavailable-D0LPL1Gy.js} +1 -1
  3. package/.output/public/assets/{HashValueDisplay-D880zntR.js → HashValueDisplay-BmuB4FW-.js} +1 -1
  4. package/.output/public/assets/InsightsInfo--v4SC07c.js +1 -0
  5. package/.output/public/assets/InsightsPageHeader-dU83CAQY.js +1 -0
  6. package/.output/public/assets/{NotFoundPage-Dys8C0cW.js → NotFoundPage-B5mhGG2a.js} +1 -1
  7. package/.output/public/assets/api-D-vOVlQ4.js +1 -0
  8. package/.output/public/assets/api-keys-Csh8ARKU.js +1 -0
  9. package/.output/public/assets/api-keys-OMPUoA_A.js +1 -0
  10. package/.output/public/assets/arrow-left-Bwq9X1eS.js +1 -0
  11. package/.output/public/assets/arrow-up-right-BkOVn4if.js +1 -0
  12. package/.output/public/assets/{card-DzCPSRsh.js → card-C_7d6O9a.js} +1 -1
  13. package/.output/public/assets/console-features-api-C1jRZTGw.js +1 -0
  14. package/.output/public/assets/dialog-DE8wFN_P.js +1 -0
  15. package/.output/public/assets/{dist-Clb6cQ9l.js → dist-C7ENGzpS.js} +1 -1
  16. package/.output/public/assets/empty-CVd5NlQD.js +1 -0
  17. package/.output/public/assets/{field-Bou7shoO.js → field-C06q1iC9.js} +1 -1
  18. package/.output/public/assets/index-BeWlQtO6.js +11 -0
  19. package/.output/public/assets/{input-group-DVtWFUUF.js → input-group-Bi61biIA.js} +1 -1
  20. package/.output/public/assets/insights-BjnAtC0f.js +2 -0
  21. package/.output/public/assets/insights-D86m11yH.js +42 -0
  22. package/.output/public/assets/insights-DpYstMoY.js +1 -0
  23. package/.output/public/assets/{insights-recovery-rpc-wvdyM4LD.js → insights-recovery-rpc-BIm5_ALc.js} +1 -1
  24. package/.output/public/assets/{insights-usage-rpc-qUkNtrWr.js → insights-usage-rpc-VHj8vyBt.js} +1 -1
  25. package/.output/public/assets/insights_.distribution-ADcrX48c.js +2 -0
  26. package/.output/public/assets/insights_.distribution-CuuJUN6Y.js +1 -0
  27. package/.output/public/assets/{insights_.distribution-CItZw3-8.js → insights_.distribution-DJRjuXsO.js} +1 -1
  28. package/.output/public/assets/installations-BSBBzqWs.js +2 -0
  29. package/.output/public/assets/installations-DqIE6WcO.js +1 -0
  30. package/.output/public/assets/installations-UsELbDvw.js +1 -0
  31. package/.output/public/assets/{loader-circle-Ca7C9YY1.js → loader-circle-CnCBxzHp.js} +1 -1
  32. package/.output/public/assets/mutation-Csp7xUNh.js +1 -0
  33. package/.output/public/assets/preload-helper-B-cOmpLH.js +1 -0
  34. package/.output/public/assets/{refresh-cw-CVGqvy8Z.js → refresh-cw-FF1NtzlQ.js} +1 -1
  35. package/.output/public/assets/{routes-veKtDW39.js → routes-CoN9XHP_.js} +2 -2
  36. package/.output/public/assets/routes-DT1jB0Zi.js +1 -0
  37. package/.output/public/assets/sidebar-D8mJRF15.js +3 -0
  38. package/.output/public/assets/styles-Bwt7v6U5.css +2 -0
  39. package/.output/public/assets/table-WBgfvgXg.js +1 -0
  40. package/.output/public/assets/{useCompositeListItem-CF7vsfcA.js → useCompositeListItem-CloBmDwn.js} +1 -1
  41. package/.output/public/assets/useMutation-DO8cvsTx.js +1 -0
  42. package/.output/public/assets/{useOpenInteractionType-AnAMWYIY.js → useOpenInteractionType-ReGr9GzJ.js} +1 -1
  43. package/.output/public/assets/valueToPercent-BvPAi4bP.js +1 -0
  44. package/.output/server/{__23tanstack-start-server-fn-resolver-CUQLz7hs.mjs → __23tanstack-start-server-fn-resolver-DMRD4e4f.mjs} +34 -42
  45. package/.output/server/_libs/@tanstack/react-router+[...].mjs +3756 -4022
  46. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +138 -95
  47. package/.output/server/_libs/tanstack__history.mjs +68 -41
  48. package/.output/server/_libs/tanstack__query-core.mjs +1618 -309
  49. package/.output/server/_libs/tanstack__react-query.mjs +255 -20
  50. package/.output/server/_ssr/{ConsoleFeatureUnavailable-DoxET2AT.mjs → ConsoleFeatureUnavailable-Du7jNPNS.mjs} +7 -7
  51. package/.output/server/_ssr/{HashValueDisplay-BplboHEb.mjs → HashValueDisplay-GQvFkXoi.mjs} +2 -2
  52. package/.output/server/_ssr/{InsightsInfo-o2YPd5Qc.mjs → InsightsInfo-Do7QSaFR.mjs} +2 -2
  53. package/.output/server/_ssr/{InsightsPageHeader-NExCJL1I.mjs → InsightsPageHeader-DSUWUWRc.mjs} +6 -6
  54. package/.output/server/_ssr/{NotFoundPage-Cd-A8ock.mjs → NotFoundPage-DsF4Vrhg.mjs} +2 -2
  55. package/.output/server/_ssr/{api-B1Ro0uys.mjs → api-BEiVs0H7.mjs} +9 -8
  56. package/.output/server/_ssr/{api-keys-Dvvy1sem.mjs → api-keys-BrvBWGRf.mjs} +2 -2
  57. package/.output/server/_ssr/{api-keys-CT3HG7KT.mjs → api-keys-K5v32nf7.mjs} +8 -8
  58. package/.output/server/_ssr/{api-keys-rpc-b1qTTFfG.mjs → api-keys-rpc-B96bBllm.mjs} +5 -5
  59. package/.output/server/_ssr/{api-rpc-Cbns2MGx.mjs → api-rpc-DcUe280B.mjs} +13 -55
  60. package/.output/server/_ssr/{auth-rpc-DChS7m9y.mjs → auth-rpc-C68W56DP.mjs} +6 -6
  61. package/.output/server/_ssr/{api-C0G9MTMI.mjs → auth.server-DM1J6NwR.mjs} +2815 -1308
  62. package/.output/server/_ssr/{bundleActivity-D17Pb_h5.mjs → bundleActivity-CpuY9CTP.mjs} +1 -1
  63. package/.output/server/_ssr/{card-DMbcvlPz.mjs → card-CPAEKx0w.mjs} +1 -1
  64. package/.output/server/_ssr/config-nsIcsHh8.mjs +15 -0
  65. package/.output/server/_ssr/config.server-DDCZk3Sq.mjs +36 -0
  66. package/.output/server/_ssr/{console-access-KpPv5lin.mjs → console-access-Da-2Qd7q.mjs} +3 -3
  67. package/.output/server/_ssr/{console-features-CsbyHYfK.mjs → console-features-Dzu2iUGD.mjs} +6 -6
  68. package/.output/server/_ssr/{console-features-api-B-KVuo4p.mjs → console-features-api-xAsSgFa_.mjs} +6 -6
  69. package/.output/server/_ssr/{console-features-rpc-Djyo5740.mjs → console-features-rpc-qARGpB24.mjs} +5 -5
  70. package/.output/server/_ssr/{createMiddleware-B_I3Ll4M.mjs → createMiddleware-BdBf_ezN.mjs} +41 -56
  71. package/.output/server/_ssr/{createServerRpc-DuNm5mmo.mjs → createServerRpc-B_y2lrwA.mjs} +2 -2
  72. package/.output/server/_ssr/databaseJsonValue-Bhxciy7Z.mjs +68 -0
  73. package/.output/server/_ssr/databaseRows-DwMAjaF8.mjs +74 -0
  74. package/.output/server/_ssr/{dialog-cOmLTUV2.mjs → dialog-q__BJ7mQ.mjs} +1 -1
  75. package/.output/server/_ssr/{dist-B-r4u3Lg.mjs → dist-Cxo9ghYv.mjs} +114 -3
  76. package/.output/server/_ssr/{downloadBundle-CexOFoZ4.mjs → downloadBundle-BW9I-pMQ.mjs} +6 -5
  77. package/.output/server/_ssr/{empty-BHoY_5jt.mjs → empty-DcBTAooP.mjs} +2 -2
  78. package/.output/server/_ssr/{empty-plugin-adapters-B5QvGpEl.mjs → empty-plugin-adapters-CYdinE-i.mjs} +1 -1
  79. package/.output/server/_ssr/{field-mjhcNEXD.mjs → field-QCDBVpXA.mjs} +2 -2
  80. package/.output/server/_ssr/{getBundleChildren-DbDXe9wB.mjs → getBundleChildren-DIAGCF4a.mjs} +2 -2
  81. package/.output/server/_ssr/{input-group-96H30HWC.mjs → input-group-hCk2nUoU.mjs} +1 -1
  82. package/.output/server/_ssr/{insights-DgFv1NsO.mjs → insights-BfBO0RFr.mjs} +18 -18
  83. package/.output/server/_ssr/{insights-D359GJFL.mjs → insights-DsAU3-TB.mjs} +5 -5
  84. package/.output/server/_ssr/{insights-D25VDVaz.mjs → insights-RIfg447W.mjs} +2 -2
  85. package/.output/server/_ssr/{insights-api-ChAKrbhG.mjs → insights-api-CDn-QQHU.mjs} +5 -5
  86. package/.output/server/_ssr/{insights-failures-WQ-ttjOx.mjs → insights-failures-BBGMhuUr.mjs} +2 -2
  87. package/.output/server/_ssr/{insights-failures-rpc-BFsqlltN.mjs → insights-failures-rpc-rA2dctK2.mjs} +8 -8
  88. package/.output/server/_ssr/{insights-recovery-DkL6bxMW.mjs → insights-recovery-Dpk6Mfb9.mjs} +1 -1
  89. package/.output/server/_ssr/{insights-recovery-rpc-2P5m8F62.mjs → insights-recovery-rpc-BCaF-xSd.mjs} +11 -11
  90. package/.output/server/_ssr/{insights-recovery-rpc-Tj8vNC19.mjs → insights-recovery-rpc-CcgYyUHk.mjs} +5 -5
  91. package/.output/server/_ssr/{insights-rpc-DrkVmV9o.mjs → insights-rpc-keycmwzw.mjs} +5 -5
  92. package/.output/server/_ssr/{insights-search-BsalnNw-.mjs → insights-search-BRg5VsNe.mjs} +1 -1
  93. package/.output/server/_ssr/{insights-usage-1nVTQrLy.mjs → insights-usage-cbw9n2Fq.mjs} +1 -1
  94. package/.output/server/_ssr/{insights-usage-rpc-Ct_4xHhZ.mjs → insights-usage-rpc-BKgV1QDf.mjs} +8 -8
  95. package/.output/server/_ssr/{insights-usage-rpc-DaYIRtFK.mjs → insights-usage-rpc-D-JYachy.mjs} +5 -5
  96. package/.output/server/_ssr/{insightsRecovery-CpGucWN1.mjs → insightsRecovery-B7oMc8d8.mjs} +2 -2
  97. package/.output/server/_ssr/{insightsUsage-JYpKZAF7.mjs → insightsUsage-Oa7kk5ek.mjs} +3 -3
  98. package/.output/server/_ssr/{insights_.distribution-jQZEF07J.mjs → insights_.distribution-BIqOqRW0.mjs} +13 -13
  99. package/.output/server/_ssr/{insights_.distribution-Bcu6uYs_.mjs → insights_.distribution-CuUrDa7P.mjs} +2 -2
  100. package/.output/server/_ssr/{insights_.distribution-D3Csuyjw.mjs → insights_.distribution-RT_6x_ud.mjs} +5 -5
  101. package/.output/server/_ssr/{installations-Cxikllmx.mjs → installations-BO5CjX2z.mjs} +11 -11
  102. package/.output/server/_ssr/{installations-BI5kelHv.mjs → installations-BjaHiIfh.mjs} +2 -2
  103. package/.output/server/_ssr/{installations-B8Vh54lH.mjs → installations-Dt5luA-N.mjs} +4 -4
  104. package/.output/server/_ssr/{listReleases-DqU9t1l3.mjs → listReleases-BitHzOYB.mjs} +1 -1
  105. package/.output/server/_ssr/{router-CRkm-wHD.mjs → router-DfVAixxe.mjs} +42 -39
  106. package/.output/server/_ssr/{routes-CNAi0Mmv.mjs → routes-C6LstNp8.mjs} +14 -14
  107. package/.output/server/_ssr/{routes-B3IJo_jL.mjs → routes-Q3Uhmwwz.mjs} +11 -11
  108. package/.output/server/_ssr/{runtime.server-NucbYjc7.mjs → runtime.server-fL0o6I2b.mjs} +500 -413
  109. package/.output/server/_ssr/schema-BKY9SqaY.mjs +25 -0
  110. package/.output/server/_ssr/{select-BkPAJFL7.mjs → select-CUlInmjk.mjs} +1 -1
  111. package/.output/server/_ssr/server-C43wq9ft.mjs +8330 -0
  112. package/.output/server/_ssr/{sidebar-Cw1VuC7d.mjs → sidebar-DKbx2ach.mjs} +3 -3
  113. package/.output/server/_ssr/ssr.mjs +1 -1
  114. package/.output/server/_ssr/start-BXUOv_n8.mjs +4 -0
  115. package/.output/server/_ssr/{table-q0P9wz7x.mjs → table-NGWV8B-S.mjs} +1 -1
  116. package/.output/server/_ssr/{updateFailures-DMU9f0pK.mjs → updateFailures-Jwe2Baab.mjs} +3 -3
  117. package/.output/server/_ssr/{uuidv7-BzSO-fr-.mjs → uuidv7-C8bwiZr8.mjs} +2 -2
  118. package/.output/server/_tanstack-start-manifest_v-WQ5qGn8n.mjs +124 -0
  119. package/.output/server/index.mjs +218 -218
  120. package/LICENSE +13 -14
  121. package/README.md +16 -12
  122. package/lib/index.d.mts +18 -8
  123. package/lib/vite-internal.mjs +15 -8
  124. package/lib/vite.d.mts +2 -0
  125. package/package.json +14 -14
  126. package/src/components/AppSidebar.tsx +9 -0
  127. package/src/components/ConsoleFeatureUnavailable.tsx +1 -3
  128. package/src/components/features/bundles/BundleMetadata.tsx +1 -1
  129. package/src/components/features/bundles/RolloutCohortsDialog.tsx +1 -1
  130. package/src/components/features/releases/ReleaseEditorSheet.tsx +6 -6
  131. package/src/index.ts +27 -15
  132. package/src/lib/api-rpc.ts +1 -54
  133. package/src/lib/api.ts +8 -89
  134. package/src/lib/console-features.ts +5 -5
  135. package/src/lib/scroll-restoration.ts +1 -1
  136. package/src/lib/server/config.server.ts +15 -52
  137. package/src/lib/server/console-runtime.server.ts +84 -6
  138. package/src/lib/server/downloadBundle.ts +10 -6
  139. package/src/lib/server/releaseReachability.ts +4 -4
  140. package/src/lib/server/runtime.server.ts +34 -46
  141. package/src/routeTree.gen.ts +31 -31
  142. package/src/routes/api/bundles/$bundleId/download.ts +2 -2
  143. package/src/vite-internal.ts +17 -8
  144. package/src/vite.ts +2 -0
  145. package/.output/public/assets/InsightsInfo-By_sPjTE.js +0 -1
  146. package/.output/public/assets/InsightsPageHeader-9_kx_LL7.js +0 -1
  147. package/.output/public/assets/api-CtarlV0l.js +0 -1
  148. package/.output/public/assets/api-keys-DFF_QqtN.js +0 -1
  149. package/.output/public/assets/api-keys-evfsQ4sN.js +0 -1
  150. package/.output/public/assets/arrow-left-BP9D-ta_.js +0 -1
  151. package/.output/public/assets/arrow-up-right-CDP9Tevw.js +0 -1
  152. package/.output/public/assets/console-features-api-yv7rm3DX.js +0 -1
  153. package/.output/public/assets/dialog-BDvCy_QO.js +0 -1
  154. package/.output/public/assets/empty-C5x9mn_s.js +0 -1
  155. package/.output/public/assets/index-0eg3l8aC.js +0 -10
  156. package/.output/public/assets/insights-2MbdiSNv.js +0 -1
  157. package/.output/public/assets/insights-C6tmCZHk.js +0 -2
  158. package/.output/public/assets/insights-CadS_aIA.js +0 -42
  159. package/.output/public/assets/insights_.distribution-BhBs6PNA.js +0 -1
  160. package/.output/public/assets/insights_.distribution-CJUH_wAS.js +0 -2
  161. package/.output/public/assets/installations-CH2H7JQt.js +0 -2
  162. package/.output/public/assets/installations-DMeMuwsW.js +0 -1
  163. package/.output/public/assets/installations-DnvxI9Dt.js +0 -1
  164. package/.output/public/assets/mutation-x9L28_CD.js +0 -1
  165. package/.output/public/assets/preload-helper-BN-UkjOE.js +0 -1
  166. package/.output/public/assets/routes-BhK8DuZA.js +0 -1
  167. package/.output/public/assets/sidebar-B45aMqRm.js +0 -3
  168. package/.output/public/assets/styles-Dn0pK1EX.css +0 -2
  169. package/.output/public/assets/table-CwLnN6KX.js +0 -1
  170. package/.output/public/assets/useMutation-CN1ygYEr.js +0 -1
  171. package/.output/public/assets/valueToPercent-C-t8FT0y.js +0 -1
  172. package/.output/server/_ssr/auth.server-Ccqz2iVv.mjs +0 -37
  173. package/.output/server/_ssr/config-DMEJdhoW.mjs +0 -13
  174. package/.output/server/_ssr/config.server-DyypN6cQ.mjs +0 -60
  175. package/.output/server/_ssr/databaseRows-CXxf_VIB.mjs +0 -132
  176. package/.output/server/_ssr/deleteBundle-Dcc21Lbd.mjs +0 -56
  177. package/.output/server/_ssr/server-DKJYsaCP.mjs +0 -7442
  178. package/.output/server/_ssr/start-DpViQGnt.mjs +0 -4
  179. package/.output/server/_tanstack-start-manifest_v-Bb0gKDbb.mjs +0 -124
  180. package/src/lib/server/deleteBundle.ts +0 -136
@@ -1,9 +1,11 @@
1
- import { C as isUUIDv7, D as parseReleaseCatalogScopeKey, E as normalizeRolloutCohortCount, T as normalizeCohortValue, a as RELEASE_CATALOG_FALLBACK_POLICY, b as getRolledOutNumericCohorts, c as encodeChannelKey, f as getManifestFileHash, h as getNumericCohortValue, l as getAssetBaseStorageUri, n as MAX_COMPILED_CATALOG_BYTES, o as createReleaseCatalogScopeKey, p as getManifestStorageUri, r as NIL_UUID, s as decodeChannelKey, u as getBundlePatch, x as isCustomCohort } from "./dist-B-r4u3Lg.mjs";
2
- import { a as bundleToRow, c as rowToBundle, i as bundleToPatchRows, r as DatabaseRowReferencedError, s as releaseRowToRelease, t as DatabaseBundleNotFoundError } from "./databaseRows-CXxf_VIB.mjs";
1
+ import { C as isUUIDv7, D as parseReleaseCatalogScopeKey, E as normalizeRolloutCohortCount, T as normalizeCohortValue, a as RELEASE_CATALOG_FALLBACK_POLICY, b as getRolledOutNumericCohorts, c as decodeChannelKey, d as getBundlePatch, g as getNumericCohortValue, l as encodeChannelKey, m as getManifestStorageUri, n as MAX_COMPILED_CATALOG_BYTES, o as canonicalizeAppVersion, p as getManifestFileHash, r as NIL_UUID, s as createReleaseCatalogScopeKey, u as getAssetBaseStorageUri, x as isCustomCohort } from "./dist-Cxo9ghYv.mjs";
2
+ import { n as DatabaseBundleNotFoundError, r as DatabaseRowReferencedError } from "./databaseJsonValue-Bhxciy7Z.mjs";
3
+ import { i as rowToBundle, n as bundleToRow, r as releaseRowToRelease, t as bundleToPatchRows } from "./databaseRows-DwMAjaF8.mjs";
3
4
  import { t as ReleaseManagementError } from "./releaseManagement-B3e7I8J9.mjs";
4
- import { n as createUUIDv7After, t as createUUIDv7 } from "./uuidv7-BzSO-fr-.mjs";
5
- import { n as rangesIntersect, t as normalizeRange } from "../_libs/verkit.mjs";
6
- //#region node_modules/.nitro/vite/services/ssr/assets/api-C0G9MTMI.js
5
+ import { n as createUUIDv7After, t as createUUIDv7 } from "./uuidv7-C8bwiZr8.mjs";
6
+ import { n as defineTable, r as isRemoteDatabase, t as defineAggregate } from "./schema-BKY9SqaY.mjs";
7
+ import { n as rangesIntersect } from "../_libs/verkit.mjs";
8
+ //#region node_modules/.nitro/vite/services/ssr/assets/auth.server-DM1J6NwR.js
7
9
  var SHA256_FILE_HASH_RE = /^[0-9a-f]{64}$/;
8
10
  var isContentAddressedAssetFileHash = (value) => {
9
11
  return typeof value === "string" && SHA256_FILE_HASH_RE.test(value);
@@ -130,6 +132,9 @@ var getBundleArchiveStorageUri = ({ manifestStorageUri, bundleId }) => replaceSt
130
132
  keySuffix: createBundleStorageKey(bundleId, "manifest.json"),
131
133
  replacement: createBundleStorageKey(bundleId, "bundle.tar.br")
132
134
  });
135
+ function assertStorageOperations(adapter, operations) {
136
+ for (const operation of operations) if (typeof adapter[operation] !== "function") throw new Error(`Storage adapter "${adapter.name}" does not implement ${operation}.`);
137
+ }
133
138
  var LETTER_DASH_NUMBER = "[a-zA-Z0-9-]";
134
139
  var NUMERIC_IDENTIFIER = String.raw`0|[1-9]\d*`;
135
140
  var NUMERIC_IDENTIFIER_LOOSE = String.raw`\d+`;
@@ -173,10 +178,6 @@ function formatComparableVersion(version) {
173
178
  const base = `${version.major}.${version.minor}.${version.patch}`;
174
179
  return version.prerelease?.length ? `${base}-${version.prerelease.join(".")}` : base;
175
180
  }
176
- function formatFullVersion(version) {
177
- const comparable = formatComparableVersion(version);
178
- return version.build?.length ? `${comparable}+${version.build.join(".")}` : comparable;
179
- }
180
181
  function parse(version, options = {}) {
181
182
  if (typeof version !== "string") return version;
182
183
  if (version.length > 256) throw new TypeError(`Version exceeds the maximum length of 256 characters`);
@@ -245,6 +246,9 @@ function compare(left, right, options = {}) {
245
246
  }
246
247
  var STRICT_COMPARATOR = safeRegex(String.raw`^${GREATER_LESS_THAN}\s*(${FULL_PLAIN})$|^$`);
247
248
  var LOOSE_COMPARATOR$1 = safeRegex(String.raw`^${GREATER_LESS_THAN}\s*(${LOOSE_PLAIN})$|^$`);
249
+ function formatComparator(comparator) {
250
+ return comparator.version ? `${comparator.operator}${formatComparableVersion(comparator.version)}` : "";
251
+ }
248
252
  function parseComparator(comparator, options = {}) {
249
253
  if (typeof comparator !== "string") return comparator;
250
254
  const normalized = comparator.trim().replaceAll(/\s+/g, " ");
@@ -259,6 +263,9 @@ function parseComparator(comparator, options = {}) {
259
263
  version
260
264
  };
261
265
  }
266
+ function formatRange(range) {
267
+ return range.sets.map((set) => set.map(formatComparator).join(" ")).join("||");
268
+ }
262
269
  var BUILD_STRIP = new RegExp(BUILD, "g");
263
270
  var BUILD_SAFE = safeRegex(BUILD);
264
271
  var STRICT_HYPHEN = safeRegex(String.raw`^\s*(${XRANGE_PLAIN})\s+-\s+(${XRANGE_PLAIN})\s*$`);
@@ -401,34 +408,25 @@ function parseRange(range, options = {}) {
401
408
  sets
402
409
  };
403
410
  }
404
- var COERCE_EXACT = safeRegex(COERCE);
405
- var COERCE_FULL_EXACT = safeRegex(COERCE_FULL);
411
+ function tryParseRange(range, options = {}) {
412
+ try {
413
+ return parseRange(range, options);
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+ function normalizeRange$1(range, options = {}) {
419
+ const parsed = tryParseRange(range, options);
420
+ return parsed ? formatRange(parsed) || "*" : null;
421
+ }
422
+ safeRegex(COERCE);
423
+ safeRegex(COERCE_FULL);
406
424
  safeRegex(`^${PRERELEASE}$`);
407
425
  safeRegex(`^${PRERELEASE_LOOSE}$`);
408
- function normalizeFull(version, options = {}) {
409
- const parsed = tryParse(version, options);
410
- return parsed ? formatFullVersion(parsed) : null;
411
- }
412
426
  function normalize(version, options = {}) {
413
427
  const parsed = tryParse(version, options);
414
428
  return parsed ? formatComparableVersion(parsed) : null;
415
429
  }
416
- function coerce(value, options = {}) {
417
- if (typeof value === "object") return value;
418
- const input = typeof value === "number" ? String(value) : value;
419
- let match = null;
420
- if (options.rtl) {
421
- const expression = safeRegex(options.includePrerelease ? COERCE_FULL : COERCE, "g");
422
- let next;
423
- while ((next = expression.exec(input)) && (!match || match.index + match[0].length !== input.length)) {
424
- if (!match || next.index + next[0].length !== match.index + match[0].length) match = next;
425
- expression.lastIndex = next.index + next[1].length + next[2].length;
426
- }
427
- } else match = (options.includePrerelease ? COERCE_FULL_EXACT : COERCE_EXACT).exec(input);
428
- if (!match) return null;
429
- const major = match[2];
430
- return tryParse(`${major}.${match[3] || "0"}.${match[4] || "0"}${options.includePrerelease && match[5] ? `-${match[5]}` : ""}${options.includePrerelease && match[6] ? `+${match[6]}` : ""}`, options);
431
- }
432
430
  var ReleaseCatalogCompilationError = class extends Error {
433
431
  code;
434
432
  constructor(code, message) {
@@ -780,10 +778,6 @@ function versionInSegment(version, segment) {
780
778
  }
781
779
  return true;
782
780
  }
783
- function canonicalizeAppVersion(appVersion) {
784
- const version = coerce(appVersion);
785
- return version ? normalizeFull(version) : null;
786
- }
787
781
  function projectCompiledCatalog(catalog, appVersion) {
788
782
  let indexes;
789
783
  if (catalog.strategy === "FINGERPRINT") indexes = catalog.releaseIndexes;
@@ -813,42 +807,93 @@ var ReleaseCatalogMutationError = class extends Error {
813
807
  this.code = code;
814
808
  }
815
809
  };
816
- /** A stored catalog payload, checked against its strategy's shape. */
817
- var parseCompiledCatalog = (payload, strategy) => {
818
- const parsed = JSON.parse(payload);
819
- if (typeof parsed !== "object" || parsed === null || Reflect.get(parsed, "schemaVersion") !== 1 || Reflect.get(parsed, "strategy") !== strategy || Reflect.get(parsed, "fallbackPolicy") !== "BUILTIN_IF_ACTIVE_INELIGIBLE" || !Array.isArray(Reflect.get(parsed, "releaseDescriptors"))) throw new Error("Stored Release catalog payload is invalid");
820
- if (strategy === "APP_VERSION" && !Array.isArray(Reflect.get(parsed, "segments"))) throw new Error("Stored app-version Release catalog is invalid");
821
- if (strategy === "FINGERPRINT" && !Array.isArray(Reflect.get(parsed, "releaseIndexes"))) throw new Error("Stored fingerprint Release catalog is invalid");
822
- return parsed;
810
+ var decodeBase64Url = (value) => {
811
+ try {
812
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
813
+ const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
814
+ return new TextDecoder(void 0, { fatal: true }).decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
815
+ } catch {
816
+ return null;
817
+ }
823
818
  };
824
- /** The scope key an update check reads. */
825
- var releaseCatalogScopeKeyOf = (input) => input.strategy === "APP_VERSION" ? createReleaseCatalogScopeKey({
826
- channelKey: input.channelKey,
827
- platform: input.platform,
828
- strategy: "APP_VERSION"
829
- }) : createReleaseCatalogScopeKey({
830
- channelKey: input.channelKey,
831
- fingerprintHash: input.fingerprintHash,
832
- platform: input.platform,
833
- strategy: "FINGERPRINT"
834
- });
835
- /** Projects a stored catalog row for one update check; null when the row is not this scope's. */
836
- var projectReleaseCatalogRow = (row, input, scopeKey = releaseCatalogScopeKeyOf(input)) => {
837
- if (row === null || row.platform !== input.platform || row.strategy !== input.strategy || row.channel_key !== input.channelKey || row.scope_key !== scopeKey || row.fingerprint_hash !== (input.strategy === "FINGERPRINT" ? input.fingerprintHash : null)) return null;
838
- const compiled = parseCompiledCatalog(row.payload, input.strategy);
839
- const appVersion = input.strategy === "APP_VERSION" ? input.appVersion : void 0;
840
- return {
841
- catalogId: row.catalog_id,
842
- catalogHash: row.catalog_hash,
843
- fallbackPolicy: RELEASE_CATALOG_FALLBACK_POLICY,
844
- generation: row.generation,
845
- releases: projectCompiledCatalog(compiled, appVersion),
846
- rollbackReleases: projectCompiledRollbackCatalog(compiled, appVersion),
847
- schemaVersion: 1,
848
- scopeKey
849
- };
819
+ var parseStorageDownloadPath = (path) => {
820
+ const match = /^\/storage\/([^/]+)\/([^/]+)$/.exec(path);
821
+ if (!match) return null;
822
+ const storageUri = decodeBase64Url(match[1]);
823
+ if (storageUri === null) return null;
824
+ try {
825
+ return {
826
+ signature: decodeURIComponent(match[2]),
827
+ storageUri
828
+ };
829
+ } catch {
830
+ return null;
831
+ }
832
+ };
833
+ var DatabaseConstraintError = class extends Error {
834
+ reason;
835
+ model;
836
+ name = "DatabaseConstraintError";
837
+ constructor(reason, model) {
838
+ super(`${model}: ${reason}`);
839
+ this.reason = reason;
840
+ this.model = model;
841
+ }
842
+ };
843
+ /** The retry budget ran out; ingestion answers 503 with Retry-After. */
844
+ var DatabaseConflictError = class extends Error {
845
+ name = "DatabaseConflictError";
846
+ };
847
+ /** The write may or may not have committed; it is reported, never rerun. */
848
+ var DatabaseAmbiguousCommitError = class extends Error {
849
+ name = "DatabaseAmbiguousCommitError";
850
+ };
851
+ var DatabaseTransactionError = class extends Error {
852
+ name = "DatabaseTransactionError";
853
+ };
854
+ /** A read that does not fit its model, such as an unknown index, a missing `where` field, or an out-of-range limit. */
855
+ var DatabaseQueryError = class extends Error {
856
+ name = "DatabaseQueryError";
857
+ };
858
+ /** A malformed cursor, or one from a read with another model, index, filter, order, or range. */
859
+ var DatabaseCursorError = class extends Error {
860
+ name = "DatabaseCursorError";
861
+ };
862
+ var REGISTER_COUNT = 1024;
863
+ var MAX_RANK = 55;
864
+ var OFFSET = 33;
865
+ var decode = (summary) => {
866
+ if (!summary) return new Uint8Array(REGISTER_COUNT);
867
+ if (summary.length !== REGISTER_COUNT) throw new Error("Invalid distinct sketch");
868
+ const registers = new Uint8Array(REGISTER_COUNT);
869
+ for (let index = 0; index < REGISTER_COUNT; index += 1) {
870
+ const value = summary.charCodeAt(index) - OFFSET;
871
+ if (!Number.isInteger(value) || value < 0 || value > MAX_RANK) throw new Error("Invalid distinct sketch");
872
+ registers[index] = value;
873
+ }
874
+ return registers;
875
+ };
876
+ var encode$1 = (registers) => Array.from(registers, (value) => String.fromCharCode(value + OFFSET)).join("");
877
+ /**
878
+ * The union of sketches, register by register: how a plugin combines a
879
+ * `distinct` column across the aggregate rows it reads.
880
+ */
881
+ var mergeDistinct = (summaries) => {
882
+ const merged = new Uint8Array(REGISTER_COUNT);
883
+ for (const summary of summaries) {
884
+ const registers = decode(summary);
885
+ for (let index = 0; index < REGISTER_COUNT; index += 1) merged[index] = Math.max(merged[index], registers[index]);
886
+ }
887
+ return encode$1(merged);
850
888
  };
851
- /** The scope and range a base-candidate key names; null for any other string. */
889
+ /** A misconfigured `createHotUpdater` call, reported at startup. */
890
+ var HotUpdaterConfigError = class extends Error {
891
+ name = "HotUpdaterConfigError";
892
+ };
893
+ /**
894
+ * The scope and range a base-candidate key names, null for any other
895
+ * string: what an implementation of `findBaseBundleIds` reads the key with.
896
+ */
852
897
  var parseBaseCandidateKey = (key) => {
853
898
  try {
854
899
  const parsed = JSON.parse(key);
@@ -859,7 +904,7 @@ var parseBaseCandidateKey = (key) => {
859
904
  scopeKey,
860
905
  range: null
861
906
  } : null;
862
- const range = parsed.length === 2 && typeof text === "string" ? normalizeRange(text) : null;
907
+ const range = parsed.length === 2 && typeof text === "string" ? normalizeRange$1(text) : null;
863
908
  return range === null ? null : {
864
909
  scopeKey,
865
910
  range
@@ -868,124 +913,24 @@ var parseBaseCandidateKey = (key) => {
868
913
  return null;
869
914
  }
870
915
  };
871
- /** A compiled segment as a range: its version when it holds exactly one. */
872
- var segmentRange = ({ lower, upper }) => lower?.inclusive && upper?.inclusive && lower.version === upper.version ? lower.version : [lower === null ? "" : `${lower.inclusive ? ">=" : ">"}${lower.version}`, upper === null ? "" : `${upper.inclusive ? "<=" : "<"}${upper.version}`].join(" ").trim() || "*";
873
- /**
874
- * Auto-patch bases from a scope's compiled Release Catalog, which holds
875
- * every enabled bundle release of the scope: those whose range intersects
876
- * `range` (all of them in a fingerprint scope), newest release first, each
877
- * bundle once and older than `bundleId`, at most `limit`.
878
- */
879
- var baseBundleIdsOf = (row, range, bundleId, limit) => {
880
- const catalog = parseCompiledCatalog(row.payload, row.strategy);
881
- const serving = new Set(catalog.strategy === "FINGERPRINT" ? catalog.rollbackReleaseIndexes : catalog.segments.flatMap((segment) => range !== null && rangesIntersect(range, segmentRange(segment)) ? segment.rollbackReleaseIndexes : []));
882
- const newestFirst = catalog.releaseDescriptors.map((descriptor, index) => ({
883
- descriptor,
884
- index
885
- })).sort((left, right) => left.descriptor.releaseId < right.descriptor.releaseId ? 1 : -1);
886
- const ids = [];
887
- for (const { descriptor, index } of newestFirst) {
888
- if (ids.length >= limit) break;
889
- const id = descriptor.bundleId;
890
- if (serving.has(index) && descriptor.kind === "BUNDLE" && id !== null && id < bundleId && !ids.includes(id)) ids.push(id);
891
- }
892
- return ids;
893
- };
894
- var MINUTE_MS = 6e4;
895
- var HOUR_MS = 60 * MINUTE_MS;
896
- var DAY_MS = 24 * HOUR_MS;
897
- /** A model's retention as its physical table carries it, which each adapter maps natively. */
898
- var physicalRetention = ({ retention }) => retention === void 0 ? {} : { retention: {
899
- column: retention.field,
900
- ms: retention.days * DAY_MS
901
- } };
902
- /** Models in a reference or a rooted index, which an expired row's delete would break. */
903
- var linkedModels = (schema) => Object.entries(schema).flatMap(([name, { fields, indexes }]) => [...Object.values(fields).map(({ references }) => references), ...Object.values(indexes).map(({ root }) => root)].flatMap((link) => link ? [name, link.model] : []));
904
- /**
905
- * What is wrong with a model's retention: it counts from an integer field,
906
- * over whole days, and expired rows are deleted without cascades, counter
907
- * moves, or root bumps, so the model is in no reference or rooted index.
908
- */
909
- var retentionProblems = (schema, model) => {
910
- const definition = schema[model];
911
- if (definition.retention === void 0) return [];
912
- const { field, days } = definition.retention;
913
- const declared = definition.fields[field] ?? (definition.kind === "table" ? definition.derived[field] : void 0);
914
- return [
915
- ...declared?.type !== "integer" || "multi" in declared && declared.multi === true ? [`retention field "${field}" is not an integer field`] : [],
916
- ...Number.isSafeInteger(days) && days >= 1 ? [] : ["retention days must be a whole number of at least 1"],
917
- ...linkedModels(schema).includes(model) ? ["a model with retention is in no reference or rooted index"] : []
918
- ];
919
- };
920
- /**
921
- * Rows a pass deletes from each table: one statement or two a table, so a
922
- * pass stays well inside D1's 50 queries an invocation.
923
- */
924
- var PASS_LIMIT = 500;
925
- /** The lease row: when the next pass is due, in epoch ms. */
926
- var LEASE_KEY = "retention.nextPassAt";
927
- /**
928
- * The adapter, pruning during its writes with no scheduler: once the lease
929
- * row in `leaseTable` is due, the write that takes it first runs one bounded
930
- * pass over the tables with retention, and other writers skip. The next pass is due in an hour, or in a minute while a pass still
931
- * found a full batch of expired rows in some table. A backend that expires
932
- * rows natively, or a schema without retention, gets its adapter back.
933
- */
934
- var pruneDuringWrites = (adapter, tables, { leaseTable: table, now }) => {
935
- const expiring = tables.filter(({ retention }) => retention !== void 0);
936
- if (adapter.prune === void 0 || expiring.length === 0) return adapter;
937
- const key = [LEASE_KEY];
938
- let checkAt = 0;
939
- const lease = async (row, due) => {
940
- const value = String(due);
941
- const guard = { v: row === null ? -1 : Number(row._v) };
942
- const next = {
943
- key: LEASE_KEY,
944
- value,
945
- _v: guard.v + 1
946
- };
947
- const op = row === null ? {
948
- type: "insert",
949
- table,
950
- row: next
951
- } : {
952
- type: "patch",
953
- table,
954
- key,
955
- set: { value },
956
- guard,
957
- previous: row
958
- };
959
- return (await adapter.write([op])).ok ? next : null;
960
- };
961
- const pass = async () => {
962
- const at = now();
963
- if (at < checkAt) return;
964
- checkAt = at + MINUTE_MS;
965
- const [row = null] = await adapter.get(table, [key]);
966
- checkAt = row === null ? at : Number(row.value);
967
- if (checkAt > at) return;
968
- const held = await lease(row, at + HOUR_MS);
969
- if (held === null) return;
970
- checkAt = at + HOUR_MS;
971
- const deleted = [];
972
- for (const t of expiring) deleted.push(await adapter.prune(t, at - t.retention.ms, PASS_LIMIT));
973
- if (deleted.includes(PASS_LIMIT) && await lease(held, at + MINUTE_MS) !== null) checkAt = at + MINUTE_MS;
974
- };
975
- const warn = (error) => console.warn("[hot-updater] Pruning expired rows failed.", error);
976
- return Object.assign(Object.create(adapter), { async write(ops) {
977
- await pass().catch(warn);
978
- return adapter.write(ops);
979
- } });
980
- };
981
916
  var DatabaseSchemaError = class extends Error {
982
917
  name = "DatabaseSchemaError";
983
918
  };
919
+ /**
920
+ * The column `name` of `table`, or a `DatabaseSchemaError`: how an adapter
921
+ * that is neither SQL nor key-value, such as the MongoDB adapter, reads a
922
+ * column's type.
923
+ */
984
924
  var findPhysicalColumn = (table, name) => {
985
925
  const column = table.columns.find((candidate) => candidate.name === name);
986
926
  if (column === void 0) throw new DatabaseSchemaError(`${table.name} has no column ${name}.`);
987
927
  return column;
988
928
  };
929
+ /**
930
+ * The index `name` of `table`, or a `DatabaseSchemaError`: how an adapter
931
+ * that is neither SQL nor key-value, such as the MongoDB adapter, resolves a
932
+ * query's `index`.
933
+ */
989
934
  var findPhysicalIndex = (table, name) => {
990
935
  const index = table.indexes.find((candidate) => candidate.name === name);
991
936
  if (index === void 0) throw new DatabaseSchemaError(`${table.name} has no index ${name}.`);
@@ -996,40 +941,24 @@ var findPhysicalIndex = (table, name) => {
996
941
  * columns, then the key columns that neither `eq` nor `sort` fixes.
997
942
  */
998
943
  var indexOrderColumns = (table, index) => [...index.sort, ...table.key.filter((column) => !index.sort.includes(column) && !index.eq.includes(column))];
999
- /** The primary key of a row, in key-column order. */
944
+ /**
945
+ * The primary key of a row, in key-column order: what `get` takes for it.
946
+ * An adapter that is neither SQL nor key-value, such as the MongoDB adapter,
947
+ * addresses rows by it.
948
+ */
1000
949
  var rowKey = (table, row) => table.key.map((column) => {
1001
950
  const value = row[column];
1002
951
  if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") throw new DatabaseSchemaError(`${table.name}.${column} is a key column and must be a string, number, or boolean.`);
1003
952
  return value;
1004
953
  });
1005
- var REGISTER_COUNT = 1024;
1006
- var MAX_RANK = 55;
1007
- var OFFSET = 33;
1008
- var decode = (summary) => {
1009
- if (!summary) return new Uint8Array(REGISTER_COUNT);
1010
- if (summary.length !== REGISTER_COUNT) throw new Error("Invalid distinct sketch");
1011
- const registers = new Uint8Array(REGISTER_COUNT);
1012
- for (let index = 0; index < REGISTER_COUNT; index += 1) {
1013
- const value = summary.charCodeAt(index) - OFFSET;
1014
- if (!Number.isInteger(value) || value < 0 || value > MAX_RANK) throw new Error("Invalid distinct sketch");
1015
- registers[index] = value;
1016
- }
1017
- return registers;
1018
- };
1019
- var encode$1 = (registers) => Array.from(registers, (value) => String.fromCharCode(value + OFFSET)).join("");
1020
- /** The union of sketches, register by register. */
1021
- var mergeDistinct = (summaries) => {
1022
- const merged = new Uint8Array(REGISTER_COUNT);
1023
- for (const summary of summaries) {
1024
- const registers = decode(summary);
1025
- for (let index = 0; index < REGISTER_COUNT; index += 1) merged[index] = Math.max(merged[index], registers[index]);
1026
- }
1027
- return encode$1(merged);
1028
- };
1029
954
  var DatabaseValueError = class extends Error {
1030
955
  name = "DatabaseValueError";
1031
956
  };
1032
- /** Compares strings in UTF-8 byte order, which is Unicode code point order. */
957
+ /**
958
+ * Compares strings in UTF-8 byte order, which is Unicode code point order:
959
+ * the order the engine sorts string keys in. A plugin that merges rows from
960
+ * several reads, as Insights does, keeps that order with it.
961
+ */
1033
962
  var compareUtf8 = (left, right) => {
1034
963
  let leftIndex = 0;
1035
964
  let rightIndex = 0;
@@ -1087,8 +1016,111 @@ var satisfiesBound = (tuple, bound, side) => {
1087
1016
  };
1088
1017
  /** Whether a stored row belongs to the result range of a query. */
1089
1018
  var matchesQuery = (table, index, request, row) => indexEntries(table, index, row).some((entry) => compareTuples(entry, request.eq) === 0) && satisfiesBound(indexOrderTuple(table, index, row), request.lower, "lower") && satisfiesBound(indexOrderTuple(table, index, row), request.upper, "upper");
1090
- var DatabaseAdapterContractError = class extends Error {
1091
- name = "DatabaseAdapterContractError";
1019
+ var keyId = (key) => JSON.stringify(key);
1020
+ var numberOf = (value) => typeof value === "number" ? value : 0;
1021
+ var violatesUnique = (table, rows, id, row) => table.indexes.some((index) => {
1022
+ if (!index.unique) return false;
1023
+ const entries = indexEntries(table, index, row);
1024
+ return entries.length > 0 && [...rows].some(([otherId, other]) => otherId !== id && indexEntries(table, index, other).some((entry) => entries.some((candidate) => compareTuples(entry, candidate) === 0)));
1025
+ });
1026
+ var putRow = (table, rows, id, row) => {
1027
+ if (violatesUnique(table, rows, id, row)) return false;
1028
+ rows.set(id, row);
1029
+ return true;
1030
+ };
1031
+ /** Applies one op to a draft; false means its guard or a constraint failed. */
1032
+ var applyOp = (rows, op) => {
1033
+ const id = keyId(op.type === "insert" ? rowKey(op.table, op.row) : op.key);
1034
+ const current = rows.get(id);
1035
+ const version = numberOf(current?.["_v"]);
1036
+ switch (op.type) {
1037
+ case "insert": return current === void 0 && putRow(op.table, rows, id, structuredClone(op.row));
1038
+ case "patch": return current !== void 0 && version === op.guard.v && putRow(op.table, rows, id, {
1039
+ ...current,
1040
+ ...structuredClone(op.set),
1041
+ ["_v"]: version + 1
1042
+ });
1043
+ case "delete": return current !== void 0 && version === op.guard.v && rows.delete(id);
1044
+ case "increment": {
1045
+ const base = current ?? op.init;
1046
+ if (base === void 0 || op.guard !== void 0 && (current === void 0 || version !== op.guard.v)) return false;
1047
+ const next = structuredClone(base);
1048
+ for (const [column, delta] of Object.entries(op.by)) next[column] = numberOf(base[column]) + delta;
1049
+ next["_v"] = numberOf(base["_v"]) + 1;
1050
+ return putRow(op.table, rows, id, next);
1051
+ }
1052
+ case "check": return current !== void 0 && version === op.guard.v;
1053
+ }
1054
+ };
1055
+ /**
1056
+ * The reference adapter: every read is a consistent snapshot and every write
1057
+ * applies to a copy that replaces the state only when all ops succeed.
1058
+ */
1059
+ var createMemoryAdapter = (options = {}) => {
1060
+ const prefix = options.tablePrefix ?? "";
1061
+ let state = /* @__PURE__ */ new Map();
1062
+ const rowsOf = (table) => state.get(prefix + table.name) ?? /* @__PURE__ */ new Map();
1063
+ return {
1064
+ id: "memory",
1065
+ fits: () => true,
1066
+ async get(table, keys) {
1067
+ const rows = rowsOf(table);
1068
+ return keys.map((key) => {
1069
+ const row = rows.get(keyId(key));
1070
+ return row === void 0 ? null : structuredClone(row);
1071
+ });
1072
+ },
1073
+ async query(table, request) {
1074
+ const index = findPhysicalIndex(table, request.index);
1075
+ const direction = request.order === "asc" ? 1 : -1;
1076
+ return [...rowsOf(table).values()].filter((row) => matchesQuery(table, index, request, row)).sort((left, right) => direction * compareTuples(indexOrderTuple(table, index, left), indexOrderTuple(table, index, right))).slice(0, request.limit).map((row) => structuredClone(row));
1077
+ },
1078
+ async write(ops) {
1079
+ const draft = new Map(state);
1080
+ const copied = /* @__PURE__ */ new Set();
1081
+ for (const [position, op] of ops.entries()) {
1082
+ const name = prefix + op.table.name;
1083
+ if (!copied.has(name)) {
1084
+ draft.set(name, new Map(draft.get(name)));
1085
+ copied.add(name);
1086
+ }
1087
+ if (!applyOp(draft.get(name), op)) return {
1088
+ ok: false,
1089
+ failedOp: position
1090
+ };
1091
+ }
1092
+ state = draft;
1093
+ return { ok: true };
1094
+ },
1095
+ async prune(table, before, limit) {
1096
+ const column = table.retention?.column ?? "";
1097
+ /** Oldest first; the key breaks a tie. */
1098
+ const order = (row) => [row[column], ...rowKey(table, row)];
1099
+ const doomed = [...rowsOf(table)].filter(([, row]) => {
1100
+ const time = row[column];
1101
+ return typeof time === "number" && time <= before;
1102
+ }).sort(([, left], [, right]) => compareTuples(order(left), order(right))).slice(0, limit);
1103
+ const rows = new Map(rowsOf(table));
1104
+ for (const [id] of doomed) rows.delete(id);
1105
+ state = new Map(state).set(prefix + table.name, rows);
1106
+ return doomed.length;
1107
+ },
1108
+ migrations: { async apply(tables) {
1109
+ const next = new Map(state);
1110
+ for (const table of tables) {
1111
+ const name = prefix + table.name;
1112
+ next.set(name, next.get(name) ?? /* @__PURE__ */ new Map());
1113
+ }
1114
+ state = next;
1115
+ } },
1116
+ async dispose() {
1117
+ state = /* @__PURE__ */ new Map();
1118
+ }
1119
+ };
1120
+ };
1121
+ /** What `verifyAdapter` throws when an adapter call breaks the adapter contract. */
1122
+ var DatabaseAdapterContractError = class extends Error {
1123
+ name = "DatabaseAdapterContractError";
1092
1124
  };
1093
1125
  var EMPTY = {
1094
1126
  gets: 0,
@@ -1121,43 +1153,43 @@ var createReadMeter = () => {
1121
1153
  }
1122
1154
  };
1123
1155
  };
1124
- var fail$1 = (message) => {
1156
+ var fail$2 = (message) => {
1125
1157
  throw new DatabaseAdapterContractError(message);
1126
1158
  };
1127
1159
  var sameKey = (left, right) => left.length === right.length && compareTuples(left, right) === 0;
1128
1160
  var verifyRequest = (table, request) => {
1129
1161
  const index = findPhysicalIndex(table, request.index);
1130
- if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 500) fail$1(`query limit must be 1–500.`);
1131
- if (request.eq.length !== index.eq.length || !request.eq.every(isKeyValue)) fail$1(`query on ${table.name}.${index.name} must bind every eq column.`);
1162
+ if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 500) fail$2(`query limit must be 1–500.`);
1163
+ if (request.eq.length !== index.eq.length || !request.eq.every(isKeyValue)) fail$2(`query on ${table.name}.${index.name} must bind every eq column.`);
1132
1164
  return index;
1133
1165
  };
1134
1166
  var verifyRows = (table, request, rows) => {
1135
1167
  const index = findPhysicalIndex(table, request.index);
1136
- if (rows.length > request.limit) fail$1(`${table.name}.${index.name} returned more than ${request.limit} rows.`);
1168
+ if (rows.length > request.limit) fail$2(`${table.name}.${index.name} returned more than ${request.limit} rows.`);
1137
1169
  let previous;
1138
1170
  for (const row of rows) {
1139
- if (!matchesQuery(table, index, request, row)) fail$1(`${table.name}.${index.name} returned a row outside the request.`);
1171
+ if (!matchesQuery(table, index, request, row)) fail$2(`${table.name}.${index.name} returned a row outside the request.`);
1140
1172
  const tuple = indexOrderTuple(table, index, row);
1141
1173
  if (previous !== void 0) {
1142
1174
  const order = compareTuples(previous, tuple);
1143
- if (request.order === "asc" ? order >= 0 : order <= 0) fail$1(`${table.name}.${index.name} returned rows out of order.`);
1175
+ if (request.order === "asc" ? order >= 0 : order <= 0) fail$2(`${table.name}.${index.name} returned rows out of order.`);
1144
1176
  }
1145
1177
  previous = tuple;
1146
1178
  }
1147
1179
  return previous;
1148
1180
  };
1149
1181
  var verifyWrite = (adapter, ops) => {
1150
- if (ops.length === 0) fail$1("write needs at least one op.");
1182
+ if (ops.length === 0) fail$2("write needs at least one op.");
1151
1183
  const seen = /* @__PURE__ */ new Set();
1152
1184
  for (const op of ops) {
1153
1185
  const key = op.type === "insert" ? rowKey(op.table, op.row) : op.key;
1154
1186
  const id = JSON.stringify([op.table.name, key]);
1155
- if (seen.has(id)) fail$1(`write touches ${op.table.name} ${id} twice.`);
1187
+ if (seen.has(id)) fail$2(`write touches ${op.table.name} ${id} twice.`);
1156
1188
  seen.add(id);
1157
- if (op.type === "insert" && typeof op.row["_v"] !== "number") fail$1(`insert into ${op.table.name} must set _v.`);
1158
- if (op.type === "patch" && Object.keys(op.set).some((column) => column === "_v" || op.table.key.includes(column))) fail$1(`patch of ${op.table.name} may not set key columns or _v.`);
1189
+ if (op.type === "insert" && typeof op.row["_v"] !== "number") fail$2(`insert into ${op.table.name} must set _v.`);
1190
+ if (op.type === "patch" && Object.keys(op.set).some((column) => column === "_v" || op.table.key.includes(column))) fail$2(`patch of ${op.table.name} may not set key columns or _v.`);
1159
1191
  }
1160
- if (!adapter.fits(ops)) fail$1("write does not fit in one atomic write.");
1192
+ if (!adapter.fits(ops)) fail$2("write does not fit in one atomic write.");
1161
1193
  };
1162
1194
  /** Wraps an adapter to check the contract at its boundary and count reads. */
1163
1195
  var verifyAdapter = (adapter, options = {}) => {
@@ -1173,9 +1205,9 @@ var verifyAdapter = (adapter, options = {}) => {
1173
1205
  gets: 1,
1174
1206
  keys: keys.length
1175
1207
  });
1176
- if (rows.length !== keys.length) fail$1(`${table.name} get returned ${rows.length} rows for ${keys.length} keys.`);
1208
+ if (rows.length !== keys.length) fail$2(`${table.name} get returned ${rows.length} rows for ${keys.length} keys.`);
1177
1209
  rows.forEach((row, position) => {
1178
- if (row !== null && !sameKey(rowKey(table, row), keys[position])) fail$1(`${table.name} get returned a row for another key.`);
1210
+ if (row !== null && !sameKey(rowKey(table, row), keys[position])) fail$2(`${table.name} get returned a row for another key.`);
1179
1211
  });
1180
1212
  return rows;
1181
1213
  },
@@ -1196,242 +1228,447 @@ var verifyAdapter = (adapter, options = {}) => {
1196
1228
  ...request,
1197
1229
  limit: 1,
1198
1230
  ...request.order === "asc" ? { lower: bound } : { upper: bound }
1199
- })).length > 0) fail$1(`${table.name}.${index.name} returned a short page before the range ended.`);
1231
+ })).length > 0) fail$2(`${table.name}.${index.name} returned a short page before the range ended.`);
1200
1232
  }
1201
1233
  return rows;
1202
1234
  },
1203
1235
  ...adapter.prune && { async prune(table, before, limit) {
1204
- if (table.retention === void 0) fail$1(`${table.name} has no retention to prune by.`);
1205
- if (!Number.isSafeInteger(limit) || limit < 1) fail$1("prune limit must be a positive integer.");
1236
+ if (table.retention === void 0) fail$2(`${table.name} has no retention to prune by.`);
1237
+ if (!Number.isSafeInteger(limit) || limit < 1) fail$2("prune limit must be a positive integer.");
1206
1238
  const deleted = await adapter.prune(table, before, limit);
1207
- if (!Number.isSafeInteger(deleted) || deleted < 0 || deleted > limit) fail$1(`${table.name} prune reported ${deleted} rows for ${limit}.`);
1239
+ if (!Number.isSafeInteger(deleted) || deleted < 0 || deleted > limit) fail$2(`${table.name} prune reported ${deleted} rows for ${limit}.`);
1208
1240
  return deleted;
1209
1241
  } },
1210
1242
  async write(ops) {
1211
1243
  verifyWrite(adapter, ops);
1212
1244
  const result = await adapter.write(ops);
1213
1245
  if (!result.ok && "failedOp" in result) {
1214
- if (!Number.isSafeInteger(result.failedOp) || result.failedOp < 0 || result.failedOp >= ops.length) fail$1(`write reported failedOp ${result.failedOp} for ${ops.length} ops.`);
1246
+ if (!Number.isSafeInteger(result.failedOp) || result.failedOp < 0 || result.failedOp >= ops.length) fail$2(`write reported failedOp ${result.failedOp} for ${ops.length} ops.`);
1215
1247
  }
1216
1248
  return result;
1217
1249
  }
1218
1250
  };
1219
1251
  };
1220
- var SHARD_COLUMN = "_shard";
1221
- var NAME = /^[a-z][a-z0-9_]*$/u;
1222
- var INDEX_NAME = /^[A-Za-z][A-Za-z0-9_]*$/u;
1223
- var tableName = (module, model) => module.namespace ? `${module.namespace}_${model}` : model;
1224
- var integer = (name, defaulted = false) => ({
1225
- name,
1226
- type: "integer",
1227
- nullable: false,
1228
- ...defaulted ? { default: 0 } : {}
1229
- });
1230
- /** A declared or derived field's type, and whether it holds several values. */
1231
- var fieldType = (model, name) => {
1232
- const field = model.fields[name] ?? (model.kind === "table" ? model.derived[name] : void 0);
1233
- return field && {
1234
- type: field.type,
1235
- multi: "multi" in field && !!field.multi
1236
- };
1237
- };
1238
- var metricsOf$1 = (model) => [
1239
- ...model.counters,
1240
- ...model.gauges,
1241
- ...model.distinct
1242
- ];
1243
- /** Field names, the key, and each field's options. */
1244
- var fieldProblems = (definition, add) => {
1245
- const names = [...Object.keys(definition.fields), ...definition.kind === "table" ? Object.keys(definition.derived) : metricsOf$1(definition)];
1246
- for (const name of new Set(names)) {
1247
- if (!NAME.test(name)) add(`field "${name}" must match ${NAME.source}`);
1248
- if (names.indexOf(name) !== names.lastIndexOf(name)) add(`field "${name}" is declared twice`);
1249
- }
1250
- const { key } = definition;
1251
- if (key.length === 0) add("key is empty");
1252
- if (new Set(key).size !== key.length) add("key repeats a field");
1253
- for (const name of key) {
1254
- const field = definition.fields[name];
1255
- if (field === void 0) add(`key field "${name}" is not a declared field`);
1256
- else if (field.required === false) add(`key field "${name}" is nullable`);
1257
- else if (field.type === "json") add(`key field "${name}" is json`);
1258
- }
1259
- for (const [name, { type, maxLength, ascii }] of Object.entries(definition.fields)) {
1260
- if (maxLength !== void 0 && (type !== "string" || !Number.isSafeInteger(maxLength) || maxLength < 1)) add(`field "${name}" has an invalid maxLength`);
1261
- if (ascii && type !== "string") add(`field "${name}" is ascii but not a string`);
1262
- }
1252
+ /**
1253
+ * The settings rows migrations write, last, and the fence reads; retention
1254
+ * passes keep their lease here too. Tooling that creates the tables creates
1255
+ * this one with them: an ORM's schema generator, a provider's setup, and an
1256
+ * access policy that names the tables a server reads.
1257
+ */
1258
+ var SETTINGS_TABLE = {
1259
+ name: "private_hot_updater_settings",
1260
+ columns: [
1261
+ {
1262
+ name: "key",
1263
+ type: "string",
1264
+ nullable: false,
1265
+ maxLength: 255
1266
+ },
1267
+ {
1268
+ name: "value",
1269
+ type: "string",
1270
+ nullable: false,
1271
+ maxLength: 255
1272
+ },
1273
+ {
1274
+ name: "_v",
1275
+ type: "integer",
1276
+ nullable: false,
1277
+ default: 0
1278
+ }
1279
+ ],
1280
+ key: ["key"],
1281
+ indexes: []
1263
1282
  };
1264
- /** A table's derived fields and references. */
1265
- var tableProblems = (definition, add, table) => {
1266
- for (const [name, derived] of Object.entries(definition.derived)) {
1267
- if (typeof derived.compute !== "function") add(`derived field "${name}" has no compute function`);
1268
- if (derived.multi && derived.type === "json") add(`derived field "${name}" cannot be multi-valued json`);
1269
- }
1270
- for (const [name, { type, references }] of Object.entries(definition.fields)) {
1271
- if (references === void 0) continue;
1272
- const target = table(references.model);
1273
- const targetKey = target?.key.length === 1 ? target.fields[target.key[0]] : void 0;
1274
- if (targetKey === void 0) add(`field "${name}" references "${references.model}", which is not a single-key table of this module`);
1275
- else if (targetKey.type !== type) add(`field "${name}" and the key of "${references.model}" differ in type`);
1276
- if (references.onDelete === "cascade" && !Object.values(definition.indexes).some(({ eq }) => eq.length === 1 && eq[0] === name)) add(`cascade on "${name}" needs an index whose eq is exactly [${name}]`);
1283
+ /** The engine's storage-layout generation; it changes only with a migration. */
1284
+ var ENGINE_SCHEMA_KEY = "schema.engine";
1285
+ var CORE_SCHEMA_KEY = "schema.core";
1286
+ var stored = ({ key, expected, found }) => `"${key}" is ${found === null ? "missing" : `"${found}"`}; expected "${expected}"`;
1287
+ var settingMessage = (adapterName, { key, expected, found }) => `Hot Updater schema setting "${key}" for ${adapterName} is ${found === null ? "missing" : `"${found}"`}; expected "${expected}". ${key === "schema.engine" ? "Create a new empty database and run `hot-updater db migrate`; databases from before the storage engine are not converted." : "Run `hot-updater db migrate`."}`;
1288
+ /** The plugin a `schema.<id>` setting belongs to; undefined for core's and the engine's. */
1289
+ var pluginOf = ({ key }) => key.startsWith("schema.") && key !== "schema.engine" && key !== CORE_SCHEMA_KEY ? key.slice(7) : void 0;
1290
+ var listed = (items) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
1291
+ /** How to create plugins' tables when the fence was given no fix. */
1292
+ var PLUGIN_TABLES_FIX = "Run `hot-updater db migrate`. A managed server runs only its provider's plugins, and rerunning `hot-updater init --provider <provider>` applies the provider's migrations. Run `hot-updater db` commands in the server's project: they load the server file named on the command line, or src/hotUpdater.* or src/db.*, and don't read hot-updater.config.ts.";
1293
+ var pluginsMessage = (adapterName, plugins, settings, fix) => `The tables of ${plugins.length === 1 ? "plugin" : "plugins"} ${listed(plugins.map((id) => `"${id}"`))} are not migrated on ${adapterName}: schema ${settings.length === 1 ? "setting" : "settings"} ${settings.map(stored).join(", and ")}. ${fix}`;
1294
+ /** The database needs `hot-updater db migrate`, or a new database; the handler answers 503. */
1295
+ var HotUpdaterSchemaMigrationRequiredError = class extends Error {
1296
+ adapterName;
1297
+ currentVersion;
1298
+ setting;
1299
+ /** Every setting the fence found missing or different; `setting` is the first. */
1300
+ settings;
1301
+ /**
1302
+ * The ids of the plugins whose `schema.<id>` setting is among them: the
1303
+ * plugins whose tables a migration has yet to create or change.
1304
+ */
1305
+ plugins;
1306
+ constructor(adapterName, currentVersion, setting, options) {
1307
+ const settings = options?.settings ?? (setting === void 0 ? [] : [setting]);
1308
+ const plugins = settings.flatMap((mismatch) => pluginOf(mismatch) ?? []);
1309
+ super(setting === void 0 ? currentVersion === void 0 ? `Hot Updater database schema is not initialized for ${adapterName}. Run \`hot-updater db migrate\` before using this adapter.` : `Hot Updater v1 cannot migrate schema ${currentVersion} in place. Create a new empty database and run \`hot-updater db migrate\`.` : plugins.length > 0 && plugins.length === settings.length ? pluginsMessage(adapterName, plugins, settings, options?.fix ?? PLUGIN_TABLES_FIX) : settingMessage(adapterName, setting), options?.cause === void 0 ? void 0 : { cause: options.cause });
1310
+ this.adapterName = adapterName;
1311
+ this.currentVersion = currentVersion;
1312
+ this.setting = setting;
1313
+ this.name = "HotUpdaterSchemaMigrationRequiredError";
1314
+ this.settings = settings;
1315
+ this.plugins = plugins;
1277
1316
  }
1278
1317
  };
1279
- /** An aggregate's identity, metrics, and shards. */
1280
- var aggregateProblems = (definition, add) => {
1281
- const identity = Object.keys(definition.fields);
1282
- if (definition.key.join("\0") !== identity.join("\0")) add(`key must list the identity fields in declaration order: ${identity.join(", ")}`);
1283
- if (metricsOf$1(definition).length === 0) add("aggregate has no metrics");
1284
- if (definition.distinct.length > 0 && definition.counters.length + definition.gauges.length > 0) add("distinct sketches need an aggregate of their own");
1285
- const { shards } = definition;
1286
- if (!Number.isSafeInteger(shards) || shards < 1 || shards > 64) add(`shards must be 1–64`);
1287
- };
1288
- /** Every problem in one model's declaration, its roots and references included. */
1289
- var problemsOf = (module, model, definition) => {
1290
- const problems = [];
1291
- const add = (message) => problems.push(`${module.id}.${model}: ${message}`);
1292
- const table = (name) => {
1293
- const target = module.schema[name];
1294
- return target?.kind === "table" ? target : void 0;
1295
- };
1296
- if (!NAME.test(model)) add("model names use lowercase letters, digits, and _");
1297
- fieldProblems(definition, add);
1298
- for (const [name, index] of Object.entries(definition.indexes)) {
1299
- const columns = [...index.eq, ...index.sort];
1300
- if (!INDEX_NAME.test(name)) add(`index "${name}" must match ${INDEX_NAME.source}`);
1301
- if (columns.length === 0) add(`index "${name}" has no fields`);
1302
- if (index.unique && index.sort.length > 0) add(`unique index "${name}" cannot sort`);
1303
- if (columns.filter((column) => {
1304
- const type = fieldType(definition, column);
1305
- if (type === void 0) add(`index "${name}" names undeclared field "${column}"`);
1306
- else if (type.type === "json") add(`index "${name}" names json field "${column}"`);
1307
- else if (type.multi && index.sort.includes(column)) add(`index "${name}" sorts by multi-valued field "${column}"`);
1308
- return type?.type !== "json" && type?.multi;
1309
- }).length > 1) add(`index "${name}" has more than one multi-valued field`);
1310
- if (index.root === void 0) continue;
1311
- const root = table(index.root.model);
1312
- if (root === void 0) add(`index "${name}" is rooted at unknown table "${index.root.model}"`);
1313
- else if (root.key.length > index.eq.length || root.key.some((field, position) => fieldType(definition, index.eq[position])?.type !== root.fields[field].type)) add(`index "${name}" eq must start with the key of "${index.root.model}"`);
1318
+ /** Driver codes for a table or column the database lacks. */
1319
+ var MISSING_SCHEMA_CODES = /* @__PURE__ */ new Set([
1320
+ "42P01",
1321
+ "42703",
1322
+ "ER_NO_SUCH_TABLE",
1323
+ "ER_BAD_FIELD_ERROR"
1324
+ ]);
1325
+ var MISSING_SCHEMA_MESSAGE = /no such (?:table|column)|(?:relation|column) .+ does not exist|table .+ doesn't exist|unknown column/iu;
1326
+ /**
1327
+ * Whether a read failed because a table or column is missing, so the
1328
+ * database needs migrations. ORMs may wrap the driver's error in `cause`.
1329
+ */
1330
+ var isMissingSchemaError = (error) => {
1331
+ for (let depth = 0; error instanceof Object && depth < 8; depth += 1) {
1332
+ const { code, message, cause } = error;
1333
+ if (MISSING_SCHEMA_CODES.has(String(code)) || typeof message === "string" && MISSING_SCHEMA_MESSAGE.test(message)) return true;
1334
+ error = cause;
1314
1335
  }
1315
- if (definition.kind === "table") tableProblems(definition, add, table);
1316
- else aggregateProblems(definition, add);
1317
- for (const problem of retentionProblems(module.schema, model)) add(problem);
1318
- return problems;
1336
+ return false;
1319
1337
  };
1320
- /** Rejects invalid module schemas at startup, listing every problem. */
1321
- var validateSchema = (modules) => {
1322
- const problems = [];
1323
- const owners = /* @__PURE__ */ new Map();
1324
- for (const module of modules) for (const [model, definition] of Object.entries(module.schema)) {
1325
- const name = tableName(module, model);
1326
- const owner = owners.get(name);
1327
- if (owner !== void 0) problems.push(`${module.id}.${model}: table "${name}" is also declared by ${owner}`);
1328
- owners.set(name, `${module.id}.${model}`);
1329
- problems.push(...problemsOf(module, model, definition));
1338
+ var readSettings = (adapter, keys) => adapter.get(SETTINGS_TABLE, keys.map((key) => [key]));
1339
+ /**
1340
+ * Throws unless every expected setting is stored with its value: one batch
1341
+ * read. The error lists every setting missing or different; `fix` says how
1342
+ * to create the plugins' tables on this database.
1343
+ */
1344
+ var checkSchemaFence = async (adapter, adapterName, expected, fix) => {
1345
+ const keys = Object.keys(expected);
1346
+ let rows;
1347
+ try {
1348
+ rows = await readSettings(adapter, keys);
1349
+ } catch (cause) {
1350
+ if (!isMissingSchemaError(cause)) throw cause;
1351
+ throw new HotUpdaterSchemaMigrationRequiredError(adapterName, void 0, {
1352
+ key: ENGINE_SCHEMA_KEY,
1353
+ expected: "1",
1354
+ found: null
1355
+ }, { cause });
1330
1356
  }
1331
- if (problems.length > 0) throw new DatabaseSchemaError(`Invalid database schema:\n- ${problems.join("\n- ")}`);
1357
+ const settings = keys.flatMap((key, position) => {
1358
+ const found = rows[position]?.value;
1359
+ return found === expected[key] ? [] : [{
1360
+ key,
1361
+ expected: expected[key],
1362
+ found: typeof found === "string" ? found : null
1363
+ }];
1364
+ });
1365
+ const [first] = settings;
1366
+ if (first !== void 0) throw new HotUpdaterSchemaMigrationRequiredError(adapterName, void 0, first, {
1367
+ settings,
1368
+ ...fix === void 0 ? {} : { fix }
1369
+ });
1332
1370
  };
1333
- var indexesOf = (definition) => [...Object.entries(definition.indexes).map(([name, index]) => ({
1334
- name,
1335
- eq: [...index.eq],
1336
- sort: [...index.sort],
1337
- ...index.unique ? { unique: true } : {}
1338
- })), ...Object.entries(definition.fields).flatMap(([name, field]) => field.unique ? [{
1339
- name,
1340
- eq: [name],
1341
- sort: [],
1342
- unique: true
1343
- }] : [])];
1344
- /** Validates the modules, then resolves them into physical tables with engine columns. */
1345
- var resolveSchema = (modules) => {
1346
- validateSchema(modules);
1347
- const references = [];
1348
- for (const module of modules) for (const [model, definition] of Object.entries(module.schema)) {
1349
- if (definition.kind !== "table") continue;
1350
- const table = tableName(module, model);
1351
- for (const [field, { references: to }] of Object.entries(definition.fields)) {
1352
- if (to === void 0) continue;
1353
- const { onDelete } = to;
1354
- const target = tableName(module, to.model);
1355
- const counter = onDelete === "none" ? {} : { counter: `_refs_${table}_${field}` };
1356
- references.push({
1357
- table,
1358
- field,
1359
- target,
1360
- onDelete,
1361
- ...counter
1362
- });
1363
- }
1364
- }
1365
- const models = /* @__PURE__ */ new Map();
1366
- for (const module of modules) for (const [model, definition] of Object.entries(module.schema)) {
1367
- const name = tableName(module, model);
1368
- const referencedBy = references.filter(({ target }) => target === name);
1369
- const columns = Object.entries(definition.fields).map(([field, { type, required, maxLength, ascii }]) => ({
1370
- name: field,
1371
- type,
1372
- nullable: required === false,
1373
- ...maxLength === void 0 ? {} : { maxLength },
1374
- ...ascii ? { ascii } : {}
1375
- }));
1376
- if (definition.kind === "table") for (const [field, { type, multi }] of Object.entries(definition.derived)) columns.push({
1377
- name: field,
1378
- type,
1379
- nullable: true,
1380
- ...multi ? { multi } : {}
1381
- });
1382
- else columns.push(integer(SHARD_COLUMN), ...[...definition.counters, ...definition.gauges].map((metric) => integer(metric)), ...definition.distinct.map((metric) => ({
1383
- name: metric,
1384
- type: "string",
1385
- nullable: true
1386
- })));
1387
- for (const { counter } of referencedBy) if (counter !== void 0) columns.push(integer(counter, true));
1388
- columns.push(integer("_v", true));
1389
- models.set(name, {
1390
- module: module.id,
1391
- model,
1392
- definition,
1393
- table: {
1394
- name,
1395
- columns,
1396
- key: definition.kind === "aggregate" ? [...definition.key, SHARD_COLUMN] : [...definition.key],
1397
- indexes: indexesOf(definition),
1398
- ...physicalRetention(definition)
1399
- },
1400
- references: references.filter(({ table }) => table === name),
1401
- referencedBy,
1402
- roots: new Map(Object.entries(definition.indexes).flatMap(([index, { root }]) => root === void 0 ? [] : [[index, tableName(module, root.model)]]))
1371
+ /** The database name a fenced adapter carries, so a server can fence its plugins' rows too. */
1372
+ var FENCED = Symbol.for("@hot-updater/server/schema-fence");
1373
+ /** The database name an adapter behind the schema fence reports under. */
1374
+ var fencedName = (adapter) => adapter[FENCED];
1375
+ /**
1376
+ * The adapter behind the schema fence: a process's first read or write
1377
+ * checks the settings first. Success is kept; a failure is checked again on
1378
+ * the next call.
1379
+ */
1380
+ var withSchemaFence = (adapter, adapterName, expected, fix) => {
1381
+ let ready;
1382
+ const fence = () => {
1383
+ ready ??= checkSchemaFence(adapter, adapterName, expected, fix).catch((error) => {
1384
+ ready = void 0;
1385
+ throw error;
1403
1386
  });
1404
- }
1387
+ return ready;
1388
+ };
1405
1389
  return {
1406
- models,
1407
- tables: [...models.values()].map(({ table }) => table)
1390
+ ...adapter,
1391
+ [FENCED]: adapterName,
1392
+ get: (table, keys) => fence().then(() => adapter.get(table, keys)),
1393
+ query: (table, request) => fence().then(() => adapter.query(table, request)),
1394
+ write: (ops) => fence().then(() => adapter.write(ops)),
1395
+ ...adapter.prune && { prune: (table, before, limit) => fence().then(() => adapter.prune(table, before, limit)) }
1408
1396
  };
1409
1397
  };
1410
- var DatabaseConstraintError = class extends Error {
1411
- reason;
1412
- model;
1413
- name = "DatabaseConstraintError";
1414
- constructor(reason, model) {
1415
- super(`${model}: ${reason}`);
1416
- this.reason = reason;
1417
- this.model = model;
1418
- }
1419
- };
1420
- /** The retry budget ran out; ingestion answers 503 with Retry-After. */
1421
- var DatabaseConflictError = class extends Error {
1422
- name = "DatabaseConflictError";
1423
- };
1424
- /** The write may or may not have committed; it is reported, never rerun. */
1425
- var DatabaseAmbiguousCommitError = class extends Error {
1426
- name = "DatabaseAmbiguousCommitError";
1427
- };
1428
- var DatabaseTransactionError = class extends Error {
1429
- name = "DatabaseTransactionError";
1430
- };
1431
- var DatabaseCursorError = class extends Error {
1432
- name = "DatabaseCursorError";
1398
+ var METER = Symbol.for("@hot-updater/plugin-core/read-meter");
1399
+ /** The meter `meterReads` gave `database`, if it did. */
1400
+ var readMeterOf = (database) => typeof database === "object" && database !== null ? database[METER] : void 0;
1401
+ /** A channel's id is `channel:` and its name's channel key, so ids share channel_key's bound. */
1402
+ var CHANNEL_ID = {
1403
+ type: "string",
1404
+ maxLength: 1408,
1405
+ ascii: true
1433
1406
  };
1434
- var encoder = new TextEncoder();
1407
+ /** Core's module: always installed, never a plugin. */
1408
+ var coreModule = {
1409
+ id: "core",
1410
+ schema: {
1411
+ bundles: defineTable({
1412
+ id: {
1413
+ type: "string",
1414
+ maxLength: 36
1415
+ },
1416
+ platform: {
1417
+ type: "string",
1418
+ maxLength: 16
1419
+ },
1420
+ git_commit_hash: {
1421
+ type: "string",
1422
+ maxLength: 255,
1423
+ required: false
1424
+ },
1425
+ metadata: { type: "json" },
1426
+ manifest_storage_uri: { type: "string" },
1427
+ manifest_file_hash: { type: "string" },
1428
+ asset_base_storage_uri: { type: "string" }
1429
+ }, {
1430
+ key: ["id"],
1431
+ indexes: {
1432
+ byPlatform: {
1433
+ eq: ["platform"],
1434
+ sort: ["id"]
1435
+ },
1436
+ all: {
1437
+ eq: [],
1438
+ sort: ["id"]
1439
+ }
1440
+ }
1441
+ }),
1442
+ bundle_patches: defineTable({
1443
+ id: {
1444
+ type: "string",
1445
+ maxLength: 255
1446
+ },
1447
+ bundle_id: {
1448
+ type: "string",
1449
+ maxLength: 36,
1450
+ references: {
1451
+ model: "bundles",
1452
+ onDelete: "cascade"
1453
+ }
1454
+ },
1455
+ base_bundle_id: {
1456
+ type: "string",
1457
+ maxLength: 36,
1458
+ references: {
1459
+ model: "bundles",
1460
+ onDelete: "cascade"
1461
+ }
1462
+ },
1463
+ base_file_hash: { type: "string" },
1464
+ patch_file_hash: { type: "string" },
1465
+ patch_storage_uri: { type: "string" },
1466
+ byte_size: { type: "integer" },
1467
+ order_index: { type: "integer" }
1468
+ }, {
1469
+ key: ["id"],
1470
+ indexes: {
1471
+ pair: {
1472
+ eq: ["bundle_id", "base_bundle_id"],
1473
+ sort: [],
1474
+ unique: true
1475
+ },
1476
+ byBundle: {
1477
+ eq: ["bundle_id"],
1478
+ sort: ["order_index"],
1479
+ root: { model: "bundles" }
1480
+ },
1481
+ byBase: {
1482
+ eq: ["base_bundle_id"],
1483
+ sort: ["bundle_id"]
1484
+ }
1485
+ }
1486
+ }),
1487
+ releases: defineTable({
1488
+ id: {
1489
+ type: "string",
1490
+ maxLength: 36
1491
+ },
1492
+ revision: { type: "integer" },
1493
+ scope_key: {
1494
+ type: "string",
1495
+ maxLength: 2048,
1496
+ ascii: true
1497
+ },
1498
+ channel_id: {
1499
+ ...CHANNEL_ID,
1500
+ references: {
1501
+ model: "channels",
1502
+ onDelete: "restrict"
1503
+ }
1504
+ },
1505
+ platform: {
1506
+ type: "string",
1507
+ maxLength: 16
1508
+ },
1509
+ kind: {
1510
+ type: "string",
1511
+ maxLength: 16
1512
+ },
1513
+ bundle_id: {
1514
+ type: "string",
1515
+ maxLength: 36,
1516
+ required: false,
1517
+ references: {
1518
+ model: "bundles",
1519
+ onDelete: "restrict"
1520
+ }
1521
+ },
1522
+ strategy: {
1523
+ type: "string",
1524
+ maxLength: 16
1525
+ },
1526
+ target_app_version: {
1527
+ type: "string",
1528
+ required: false
1529
+ },
1530
+ fingerprint_hash: {
1531
+ type: "string",
1532
+ maxLength: 255,
1533
+ required: false
1534
+ },
1535
+ enabled: { type: "boolean" },
1536
+ should_force_update: { type: "boolean" },
1537
+ message: {
1538
+ type: "string",
1539
+ required: false
1540
+ },
1541
+ rollout_cohort_count: { type: "integer" },
1542
+ target_cohorts: { type: "json" },
1543
+ operation: {
1544
+ type: "string",
1545
+ maxLength: 16
1546
+ },
1547
+ source_release_id: {
1548
+ type: "string",
1549
+ maxLength: 36,
1550
+ required: false,
1551
+ references: {
1552
+ model: "releases",
1553
+ onDelete: "none"
1554
+ }
1555
+ },
1556
+ created_at_ms: { type: "integer" },
1557
+ updated_at_ms: { type: "integer" }
1558
+ }, {
1559
+ key: ["id"],
1560
+ indexes: {
1561
+ byScope: {
1562
+ eq: ["scope_key"],
1563
+ sort: ["id"],
1564
+ root: { model: "release_catalogs" }
1565
+ },
1566
+ byScopeEnabled: {
1567
+ eq: ["scope_key", "enabled"],
1568
+ sort: ["id"],
1569
+ root: { model: "release_catalogs" }
1570
+ },
1571
+ byChannelPlatform: {
1572
+ eq: ["channel_id", "platform"],
1573
+ sort: ["id"]
1574
+ },
1575
+ byChannelPlatformEnabled: {
1576
+ eq: [
1577
+ "channel_id",
1578
+ "platform",
1579
+ "enabled"
1580
+ ],
1581
+ sort: ["id"]
1582
+ },
1583
+ byBundle: {
1584
+ eq: ["bundle_id"],
1585
+ sort: ["id"]
1586
+ },
1587
+ all: {
1588
+ eq: [],
1589
+ sort: ["id"]
1590
+ }
1591
+ }
1592
+ }),
1593
+ release_catalogs: defineTable({
1594
+ scope_key: {
1595
+ type: "string",
1596
+ maxLength: 2048,
1597
+ ascii: true
1598
+ },
1599
+ catalog_id: {
1600
+ type: "string",
1601
+ maxLength: 255
1602
+ },
1603
+ strategy: {
1604
+ type: "string",
1605
+ maxLength: 16
1606
+ },
1607
+ channel_id: {
1608
+ ...CHANNEL_ID,
1609
+ references: {
1610
+ model: "channels",
1611
+ onDelete: "none"
1612
+ }
1613
+ },
1614
+ channel_key: {
1615
+ type: "string",
1616
+ maxLength: 1400,
1617
+ ascii: true
1618
+ },
1619
+ platform: {
1620
+ type: "string",
1621
+ maxLength: 16
1622
+ },
1623
+ fingerprint_hash: {
1624
+ type: "string",
1625
+ maxLength: 255,
1626
+ required: false
1627
+ },
1628
+ generation: { type: "integer" },
1629
+ payload: { type: "string" },
1630
+ catalog_hash: {
1631
+ type: "string",
1632
+ maxLength: 71
1633
+ },
1634
+ byte_size: { type: "integer" },
1635
+ is_tombstone: { type: "boolean" },
1636
+ updated_at_ms: { type: "integer" }
1637
+ }, {
1638
+ key: ["scope_key"],
1639
+ indexes: { all: {
1640
+ eq: [],
1641
+ sort: ["scope_key"]
1642
+ } }
1643
+ }),
1644
+ channels: defineTable({
1645
+ id: CHANNEL_ID,
1646
+ name: {
1647
+ type: "string",
1648
+ maxLength: 255,
1649
+ unique: true
1650
+ }
1651
+ }, {
1652
+ key: ["id"],
1653
+ indexes: { all: {
1654
+ eq: [],
1655
+ sort: ["name"]
1656
+ } }
1657
+ }),
1658
+ bundle_totals: defineAggregate({ platform_key: {
1659
+ type: "string",
1660
+ maxLength: 16
1661
+ } }, {
1662
+ key: ["platform_key"],
1663
+ counters: ["bundles"],
1664
+ indexes: { byPlatform: {
1665
+ eq: ["platform_key"],
1666
+ sort: []
1667
+ } }
1668
+ })
1669
+ }
1670
+ };
1671
+ var encoder = new TextEncoder();
1435
1672
  var decoder = new TextDecoder();
1436
1673
  var toBase64Url = (text) => {
1437
1674
  let binary = "";
@@ -1573,26 +1810,6 @@ var compileAggregates = async (adapter, changes) => {
1573
1810
  }));
1574
1811
  return ops;
1575
1812
  };
1576
- var defineTable = (fields, options) => ({
1577
- kind: "table",
1578
- fields,
1579
- key: options.key,
1580
- derived: options.derived ?? {},
1581
- indexes: options.indexes ?? {},
1582
- ...options.retention === void 0 ? {} : { retention: options.retention }
1583
- });
1584
- var defineAggregate = (fields, options) => ({
1585
- kind: "aggregate",
1586
- fields,
1587
- key: options.key,
1588
- counters: options.counters ?? [],
1589
- gauges: options.gauges ?? [],
1590
- distinct: options.distinct ?? [],
1591
- shards: options.shards ?? 1,
1592
- indexes: options.indexes ?? {},
1593
- ...options.batched ? { batched: true } : {},
1594
- ...options.retention === void 0 ? {} : { retention: options.retention }
1595
- });
1596
1813
  /** Log rows spread over 8 tables, each its own DynamoDB partition. */
1597
1814
  var LOG_TABLES = [
1598
1815
  0,
@@ -1688,7 +1905,7 @@ var mergeAll = (into, changes) => {
1688
1905
  return into;
1689
1906
  };
1690
1907
  /** A row's metrics as values to merge: counters, gauges, and the sketches it has. */
1691
- var metricsOf = (model, row) => {
1908
+ var metricsOf$1 = (model, row) => {
1692
1909
  const { counters, gauges, distinct } = shapeOf(model);
1693
1910
  return Object.fromEntries([...[...counters, ...gauges].map((name) => [name, Number(row[name] ?? 0)]), ...distinct.flatMap((name) => row[name] === null || row[name] === void 0 ? [] : [[name, row[name]]])]);
1694
1911
  };
@@ -1722,7 +1939,7 @@ var rewrite = (change, key, current) => {
1722
1939
  const { counters, gauges, distinct } = shapeOf(model);
1723
1940
  const base = current ?? blank(model, key);
1724
1941
  const sum = mergeAll(/* @__PURE__ */ new Map(), [change]);
1725
- merge(sum, model, key, metricsOf(model, base));
1942
+ merge(sum, model, key, metricsOf$1(model, base));
1726
1943
  const set = valuesOf([...sum.values()][0]);
1727
1944
  if (current && Object.keys(set).every((name) => current[name] === set[name])) return void 0;
1728
1945
  const row = {
@@ -2210,173 +2427,301 @@ var createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "log
2210
2427
  }
2211
2428
  };
2212
2429
  };
2213
- var DatabaseQueryError = class extends Error {
2214
- name = "DatabaseQueryError";
2215
- };
2216
- var sameFields = (left, right) => left.length === right.length && left.every((field) => right.includes(field));
2217
- var keyValue = (value, field) => {
2218
- if (isKeyValue(value)) return value;
2219
- throw new DatabaseQueryError(`${field} needs a string, number, or boolean.`);
2220
- };
2221
- var bound = (exclusive, inclusive, columns) => {
2222
- if (exclusive !== void 0 && inclusive !== void 0) throw new DatabaseQueryError(`range on ${columns[0]} sets both bounds of one side.`);
2223
- const value = exclusive ?? inclusive;
2224
- if (value === void 0) return void 0;
2225
- const values = Array.isArray(value) ? value : [value];
2226
- if (values.length === 0 || values.length > columns.length) throw new DatabaseQueryError(`range on ${columns[0]} takes 1–${columns.length} values of ${columns.join(", ")}.`);
2227
- return {
2228
- values: values.map((item, position) => keyValue(item, columns[position])),
2229
- inclusive: inclusive !== void 0
2230
- };
2430
+ var MINUTE_MS = 6e4;
2431
+ var HOUR_MS = 60 * MINUTE_MS;
2432
+ var DAY_MS = 24 * HOUR_MS;
2433
+ /** A model's retention as its physical table carries it, which each adapter maps natively. */
2434
+ var physicalRetention = ({ retention }) => retention === void 0 ? {} : { retention: {
2435
+ column: retention.field,
2436
+ ms: retention.days * DAY_MS
2437
+ } };
2438
+ /** Models in a reference or a rooted index, which an expired row's delete would break. */
2439
+ var linkedModels = (schema) => Object.entries(schema).flatMap(([name, { fields, indexes }]) => [...Object.values(fields).map(({ references }) => references), ...Object.values(indexes).map(({ root }) => root)].flatMap((link) => link ? [name, link.model] : []));
2440
+ /**
2441
+ * What is wrong with a model's retention: it counts from an integer field,
2442
+ * over whole days, and expired rows are deleted without cascades, counter
2443
+ * moves, or root bumps, so the model is in no reference or rooted index.
2444
+ */
2445
+ var retentionProblems = (schema, model) => {
2446
+ const definition = schema[model];
2447
+ if (definition.retention === void 0) return [];
2448
+ const { field, days } = definition.retention;
2449
+ const declared = definition.fields[field] ?? (definition.kind === "table" ? definition.derived[field] : void 0);
2450
+ return [
2451
+ ...declared?.type !== "integer" || "multi" in declared && declared.multi === true ? [`retention field "${field}" is not an integer field`] : [],
2452
+ ...Number.isSafeInteger(days) && days >= 1 ? [] : ["retention days must be a whole number of at least 1"],
2453
+ ...linkedModels(schema).includes(model) ? ["a model with retention is in no reference or rooted index"] : []
2454
+ ];
2231
2455
  };
2232
- var createEngineReads = ({ adapter, schema, maxPageSize: requested = 500 }) => {
2233
- const maxPageSize = Math.min(requested, 500);
2234
- let count = {
2235
- calls: 0,
2236
- rows: 0
2237
- };
2238
- const returned = (rows) => {
2239
- const size = Array.isArray(rows) ? rows.length : rows === null ? 0 : 1;
2240
- count = {
2241
- calls: count.calls + 1,
2242
- rows: count.rows + size
2456
+ /**
2457
+ * Rows a pass deletes from each table: one statement or two a table, so a
2458
+ * pass stays well inside D1's 50 queries an invocation.
2459
+ */
2460
+ var PASS_LIMIT = 500;
2461
+ /** The lease row: when the next pass is due, in epoch ms. */
2462
+ var LEASE_KEY = "retention.nextPassAt";
2463
+ /**
2464
+ * The adapter, pruning during its writes with no scheduler: once the lease
2465
+ * row in `leaseTable` is due, the write that takes it first runs one bounded
2466
+ * pass over the tables with retention, and other writers skip. The next pass
2467
+ * is due in an hour, or in a minute while a pass still found a full batch of
2468
+ * expired rows in some table; a pass that fails is due again at once. A
2469
+ * backend that expires rows natively, or a schema without retention, gets its
2470
+ * adapter back.
2471
+ */
2472
+ var pruneDuringWrites = (adapter, tables, { leaseTable: table, now }) => {
2473
+ const expiring = tables.filter(({ retention }) => retention !== void 0);
2474
+ if (adapter.prune === void 0 || expiring.length === 0) return adapter;
2475
+ const key = [LEASE_KEY];
2476
+ let checkAt = 0;
2477
+ const lease = async (row, due) => {
2478
+ const value = String(due);
2479
+ const guard = { v: row === null ? -1 : Number(row._v) };
2480
+ const next = {
2481
+ key: LEASE_KEY,
2482
+ value,
2483
+ _v: guard.v + 1
2243
2484
  };
2244
- return rows;
2245
- };
2246
- /** A model of `kind` by physical table name, for reads and transactions. */
2247
- const modelOf = (name, kind = "table") => {
2248
- const model = schema.models.get(name);
2249
- if (model?.definition.kind === kind) return model;
2250
- throw new DatabaseQueryError(model === void 0 ? `Unknown model ${name}.` : kind === "table" ? `${name} is an aggregate; read it with findAggregates and change it with aggregate.` : `${name} is a table; read it with findMany and change it with create, update, or delete.`);
2485
+ const op = row === null ? {
2486
+ type: "insert",
2487
+ table,
2488
+ row: next
2489
+ } : {
2490
+ type: "patch",
2491
+ table,
2492
+ key,
2493
+ set: { value },
2494
+ guard,
2495
+ previous: row
2496
+ };
2497
+ return (await adapter.write([op])).ok ? next : null;
2251
2498
  };
2252
- const plan = ({ table }, input) => {
2253
- const index = table.indexes.find(({ name }) => name === input.index);
2254
- if (index === void 0) throw new DatabaseQueryError(`${table.name} has no index ${input.index}.`);
2255
- const where = input.where ?? {};
2256
- if (!sameFields(Object.keys(where), index.eq)) throw new DatabaseQueryError(`${table.name}.${index.name} reads need where on exactly: ${index.eq.join(", ") || "(none)"}.`);
2257
- const { limit, order = "asc", range } = input;
2258
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxPageSize) throw new DatabaseQueryError(`limit must be 1–${maxPageSize}.`);
2259
- const columns = indexOrderColumns(table, index);
2260
- const eq = index.eq.map((field) => keyValue(where[field], `${table.name}.${field}`));
2261
- const scope = cursorScope([
2262
- table.name,
2263
- index.name,
2264
- eq,
2265
- order,
2266
- range
2267
- ]);
2268
- let lower = bound(range?.gt, range?.gte, columns);
2269
- let upper = bound(range?.lt, range?.lte, columns);
2270
- if (input.cursor !== void 0) {
2271
- const values = decodeCursor(scope, input.cursor);
2272
- if (order === "asc") lower = {
2273
- values,
2274
- inclusive: false
2275
- };
2276
- else upper = {
2277
- values,
2278
- inclusive: false
2279
- };
2499
+ const pass = async () => {
2500
+ const at = now();
2501
+ if (at < checkAt) return;
2502
+ checkAt = at + MINUTE_MS;
2503
+ const [row = null] = await adapter.get(table, [key]);
2504
+ checkAt = row === null ? at : Number(row.value);
2505
+ if (checkAt > at) return;
2506
+ const held = await lease(row, at + HOUR_MS);
2507
+ if (held === null) return;
2508
+ checkAt = at + HOUR_MS;
2509
+ const deleted = [];
2510
+ try {
2511
+ for (const t of expiring) deleted.push(await adapter.prune(t, at - t.retention.ms, PASS_LIMIT));
2512
+ } catch (error) {
2513
+ checkAt = at + MINUTE_MS;
2514
+ await lease(held, at).catch(() => null);
2515
+ throw error;
2280
2516
  }
2281
- return {
2282
- request: {
2283
- index: index.name,
2284
- eq,
2285
- order,
2286
- limit,
2287
- ...lower === void 0 ? {} : { lower },
2288
- ...upper === void 0 ? {} : { upper }
2289
- },
2290
- index,
2291
- scope
2292
- };
2517
+ if (deleted.includes(PASS_LIMIT) && await lease(held, at + MINUTE_MS) !== null) checkAt = at + MINUTE_MS;
2518
+ };
2519
+ const warn = (error) => console.warn("[hot-updater] Pruning expired rows failed.", error);
2520
+ return Object.assign(Object.create(adapter), { async write(ops) {
2521
+ await pass().catch(warn);
2522
+ return adapter.write(ops);
2523
+ } });
2524
+ };
2525
+ var SHARD_COLUMN = "_shard";
2526
+ var NAME = /^[a-z][a-z0-9_]*$/u;
2527
+ var INDEX_NAME = /^[A-Za-z][A-Za-z0-9_]*$/u;
2528
+ var tableName = (module, model) => module.namespace ? `${module.namespace}_${model}` : model;
2529
+ var integer = (name, defaulted = false) => ({
2530
+ name,
2531
+ type: "integer",
2532
+ nullable: false,
2533
+ ...defaulted ? { default: 0 } : {}
2534
+ });
2535
+ /** A declared or derived field's type, and whether it holds several values. */
2536
+ var fieldType = (model, name) => {
2537
+ const field = model.fields[name] ?? (model.kind === "table" ? model.derived[name] : void 0);
2538
+ return field && {
2539
+ type: field.type,
2540
+ multi: "multi" in field && !!field.multi
2293
2541
  };
2294
- /** Reads rows by key in one batch; the results follow the keys' order. */
2295
- const findByKeys = async (name, lookups) => {
2296
- const { table } = modelOf(name, "table");
2297
- const keys = lookups.map((lookup) => {
2298
- if (!sameFields(Object.keys(lookup), table.key)) throw new DatabaseQueryError(`findByKeys on ${name} takes its key: ${table.key.join(", ")}.`);
2299
- return table.key.map((field) => keyValue(lookup[field], `${name}.${field}`));
2300
- });
2301
- const rows = keys.length === 0 ? [] : await adapter.get(table, keys);
2302
- returned(rows.filter((row) => row !== null));
2303
- return rows.map((row) => row ?? null);
2542
+ };
2543
+ var metricsOf = (model) => [
2544
+ ...model.counters,
2545
+ ...model.gauges,
2546
+ ...model.distinct
2547
+ ];
2548
+ /** Field names, the key, and each field's options. */
2549
+ var fieldProblems = (definition, add) => {
2550
+ const names = [...Object.keys(definition.fields), ...definition.kind === "table" ? Object.keys(definition.derived) : metricsOf(definition)];
2551
+ for (const name of new Set(names)) {
2552
+ if (!NAME.test(name)) add(`field "${name}" must match ${NAME.source}`);
2553
+ if (names.indexOf(name) !== names.lastIndexOf(name)) add(`field "${name}" is declared twice`);
2554
+ }
2555
+ const { key } = definition;
2556
+ if (key.length === 0) add("key is empty");
2557
+ if (new Set(key).size !== key.length) add("key repeats a field");
2558
+ for (const name of key) {
2559
+ const field = definition.fields[name];
2560
+ if (field === void 0) add(`key field "${name}" is not a declared field`);
2561
+ else if (field.required === false) add(`key field "${name}" is nullable`);
2562
+ else if (field.type === "json") add(`key field "${name}" is json`);
2563
+ }
2564
+ for (const [name, { type, maxLength, ascii }] of Object.entries(definition.fields)) {
2565
+ if (maxLength !== void 0 && (type !== "string" || !Number.isSafeInteger(maxLength) || maxLength < 1)) add(`field "${name}" has an invalid maxLength`);
2566
+ if (ascii && type !== "string") add(`field "${name}" is ascii but not a string`);
2567
+ }
2568
+ };
2569
+ /** A table's derived fields and references. */
2570
+ var tableProblems = (definition, add, table) => {
2571
+ for (const [name, derived] of Object.entries(definition.derived)) {
2572
+ if (typeof derived.compute !== "function") add(`derived field "${name}" has no compute function`);
2573
+ if (derived.multi && derived.type === "json") add(`derived field "${name}" cannot be multi-valued json`);
2574
+ }
2575
+ for (const [name, { type, references }] of Object.entries(definition.fields)) {
2576
+ if (references === void 0) continue;
2577
+ const target = table(references.model);
2578
+ const targetKey = target?.key.length === 1 ? target.fields[target.key[0]] : void 0;
2579
+ if (targetKey === void 0) add(`field "${name}" references "${references.model}", which is not a single-key table of this module`);
2580
+ else if (targetKey.type !== type) add(`field "${name}" and the key of "${references.model}" differ in type`);
2581
+ if (references.onDelete === "cascade" && !Object.values(definition.indexes).some(({ eq }) => eq.length === 1 && eq[0] === name)) add(`cascade on "${name}" needs an index whose eq is exactly [${name}]`);
2582
+ }
2583
+ };
2584
+ /** An aggregate's identity, metrics, and shards. */
2585
+ var aggregateProblems = (definition, add) => {
2586
+ const identity = Object.keys(definition.fields);
2587
+ if (definition.key.join("\0") !== identity.join("\0")) add(`key must list the identity fields in declaration order: ${identity.join(", ")}`);
2588
+ if (metricsOf(definition).length === 0) add("aggregate has no metrics");
2589
+ if (definition.distinct.length > 0 && definition.counters.length + definition.gauges.length > 0) add("distinct sketches need an aggregate of their own");
2590
+ const { shards } = definition;
2591
+ if (!Number.isSafeInteger(shards) || shards < 1 || shards > 64) add(`shards must be 1–64`);
2592
+ };
2593
+ /** Every problem in one model's declaration, its roots and references included. */
2594
+ var problemsOf = (module, model, definition) => {
2595
+ const problems = [];
2596
+ const add = (message) => problems.push(`${module.id}.${model}: ${message}`);
2597
+ const table = (name) => {
2598
+ const target = module.schema[name];
2599
+ return target?.kind === "table" ? target : void 0;
2304
2600
  };
2305
- return {
2306
- maxPageSize,
2307
- modelOf,
2308
- reads: {
2309
- total: () => count,
2310
- reset: () => {
2311
- count = {
2312
- calls: 0,
2313
- rows: 0
2314
- };
2315
- }
2316
- },
2317
- findByKeys,
2318
- async findOne(name, lookup) {
2319
- const { table } = modelOf(name, "table");
2320
- const fields = Object.keys(lookup);
2321
- if (sameFields(fields, table.key)) return (await findByKeys(name, [lookup]))[0];
2322
- const index = table.indexes.find(({ unique, eq }) => unique && sameFields(fields, eq));
2323
- if (index === void 0) throw new DatabaseQueryError(`findOne on ${name} needs its key or a unique field, not ${fields.join(", ")}.`);
2324
- const [row] = await adapter.query(table, {
2325
- index: index.name,
2326
- eq: index.eq.map((field) => keyValue(lookup[field], `${name}.${field}`)),
2327
- order: "asc",
2328
- limit: 1
2329
- });
2330
- return returned(row ?? null);
2331
- },
2332
- async findMany(name, input) {
2333
- const model = modelOf(name, "table");
2334
- const { request, index, scope } = plan(model, input);
2335
- const rows = returned(await adapter.query(model.table, request));
2336
- const last = rows.at(-1);
2337
- return rows.length === input.limit && last !== void 0 ? {
2338
- rows,
2339
- next: encodeCursor(scope, indexOrderTuple(model.table, index, last))
2340
- } : { rows };
2341
- },
2342
- async findAggregates(name, input) {
2343
- const model = modelOf(name, "aggregate");
2344
- const { table, definition } = model;
2345
- if (definition.kind !== "aggregate") throw new DatabaseQueryError(name);
2346
- const { request, index, scope } = plan(model, input);
2347
- const rows = await adapter.query(table, request);
2348
- const logicalKey = (row) => definition.key.map((column) => keyValue(row[column], column));
2349
- const groups = /* @__PURE__ */ new Map();
2350
- for (const row of rows) {
2351
- const key = JSON.stringify(logicalKey(row));
2352
- groups.set(key, [...groups.get(key) ?? [], row]);
2353
- }
2354
- const full = rows.length === input.limit;
2355
- const last = [...groups.values()].at(-1);
2356
- if (full && last !== void 0) {
2357
- const seen = new Set(last.map((row) => row[SHARD_COLUMN]));
2358
- const missing = Array.from({ length: definition.shards }, (_, shard) => shard).filter((shard) => !seen.has(shard));
2359
- if (missing.length > 0) {
2360
- const key = logicalKey(last[0]);
2361
- const rest = await adapter.get(table, missing.map((shard) => [...key, shard]));
2362
- last.push(...rest.filter((row) => row !== null));
2363
- }
2364
- }
2365
- const merged = [...groups.values()].map((shards) => {
2366
- const row = Object.fromEntries(definition.key.map((field) => [field, shards[0][field]]));
2367
- for (const metric of [...definition.counters, ...definition.gauges]) row[metric] = shards.reduce((sum, shard) => sum + Number(shard[metric] ?? 0), 0);
2368
- for (const metric of definition.distinct) row[metric] = mergeDistinct(shards.map((shard) => shard[metric]));
2369
- return row;
2601
+ if (!NAME.test(model)) add("model names use lowercase letters, digits, and _");
2602
+ fieldProblems(definition, add);
2603
+ for (const [name, index] of Object.entries(definition.indexes)) {
2604
+ const columns = [...index.eq, ...index.sort];
2605
+ if (!INDEX_NAME.test(name)) add(`index "${name}" must match ${INDEX_NAME.source}`);
2606
+ if (columns.length === 0) add(`index "${name}" has no fields`);
2607
+ if (index.unique && index.sort.length > 0) add(`unique index "${name}" cannot sort`);
2608
+ if (columns.filter((column) => {
2609
+ const type = fieldType(definition, column);
2610
+ if (type === void 0) add(`index "${name}" names undeclared field "${column}"`);
2611
+ else if (type.type === "json") add(`index "${name}" names json field "${column}"`);
2612
+ else if (type.multi && index.sort.includes(column)) add(`index "${name}" sorts by multi-valued field "${column}"`);
2613
+ return type?.type !== "json" && type?.multi;
2614
+ }).length > 1) add(`index "${name}" has more than one multi-valued field`);
2615
+ if (index.root === void 0) continue;
2616
+ const root = table(index.root.model);
2617
+ if (root === void 0) add(`index "${name}" is rooted at unknown table "${index.root.model}"`);
2618
+ else if (root.key.length > index.eq.length || root.key.some((field, position) => fieldType(definition, index.eq[position])?.type !== root.fields[field].type)) add(`index "${name}" eq must start with the key of "${index.root.model}"`);
2619
+ }
2620
+ if (definition.kind === "table") tableProblems(definition, add, table);
2621
+ else aggregateProblems(definition, add);
2622
+ for (const problem of retentionProblems(module.schema, model)) add(problem);
2623
+ return problems;
2624
+ };
2625
+ /** Rejects invalid module schemas at startup, listing every problem. */
2626
+ var validateSchema = (modules) => {
2627
+ const problems = [];
2628
+ const owners = /* @__PURE__ */ new Map();
2629
+ for (const module of modules) for (const [model, definition] of Object.entries(module.schema)) {
2630
+ const name = tableName(module, model);
2631
+ const owner = owners.get(name);
2632
+ if (owner !== void 0) problems.push(`${module.id}.${model}: table "${name}" is also declared by ${owner}`);
2633
+ owners.set(name, `${module.id}.${model}`);
2634
+ problems.push(...problemsOf(module, model, definition));
2635
+ }
2636
+ if (problems.length > 0) throw new DatabaseSchemaError(`Invalid database schema:\n- ${problems.join("\n- ")}`);
2637
+ };
2638
+ var indexesOf = (definition) => [...Object.entries(definition.indexes).map(([name, index]) => ({
2639
+ name,
2640
+ eq: [...index.eq],
2641
+ sort: [...index.sort],
2642
+ ...index.unique ? { unique: true } : {}
2643
+ })), ...Object.entries(definition.fields).flatMap(([name, field]) => field.unique ? [{
2644
+ name,
2645
+ eq: [name],
2646
+ sort: [],
2647
+ unique: true
2648
+ }] : [])];
2649
+ /** Validates the modules, then resolves them into physical tables with engine columns. */
2650
+ var resolveSchema = (modules) => {
2651
+ validateSchema(modules);
2652
+ const references = [];
2653
+ for (const module of modules) for (const [model, definition] of Object.entries(module.schema)) {
2654
+ if (definition.kind !== "table") continue;
2655
+ const table = tableName(module, model);
2656
+ for (const [field, { references: to }] of Object.entries(definition.fields)) {
2657
+ if (to === void 0) continue;
2658
+ const { onDelete } = to;
2659
+ const target = tableName(module, to.model);
2660
+ const counter = onDelete === "none" ? {} : { counter: `_refs_${table}_${field}` };
2661
+ references.push({
2662
+ table,
2663
+ field,
2664
+ target,
2665
+ onDelete,
2666
+ ...counter
2370
2667
  });
2371
- returned(merged);
2372
- if (!full || last === void 0) return { rows: merged };
2373
- return {
2374
- rows: merged,
2375
- next: encodeCursor(scope, indexOrderTuple(table, index, last[0]).slice(0, -1))
2376
- };
2377
2668
  }
2669
+ }
2670
+ const models = /* @__PURE__ */ new Map();
2671
+ for (const module of modules) for (const [model, definition] of Object.entries(module.schema)) {
2672
+ const name = tableName(module, model);
2673
+ const referencedBy = references.filter(({ target }) => target === name);
2674
+ const columns = Object.entries(definition.fields).map(([field, { type, required, maxLength, ascii }]) => ({
2675
+ name: field,
2676
+ type,
2677
+ nullable: required === false,
2678
+ ...maxLength === void 0 ? {} : { maxLength },
2679
+ ...ascii ? { ascii } : {}
2680
+ }));
2681
+ if (definition.kind === "table") for (const [field, { type, multi }] of Object.entries(definition.derived)) columns.push({
2682
+ name: field,
2683
+ type,
2684
+ nullable: true,
2685
+ ...multi ? { multi } : {}
2686
+ });
2687
+ else columns.push(integer(SHARD_COLUMN), ...[...definition.counters, ...definition.gauges].map((metric) => integer(metric)), ...definition.distinct.map((metric) => ({
2688
+ name: metric,
2689
+ type: "string",
2690
+ nullable: true
2691
+ })));
2692
+ for (const { counter } of referencedBy) if (counter !== void 0) columns.push(integer(counter, true));
2693
+ columns.push(integer("_v", true));
2694
+ models.set(name, {
2695
+ module: module.id,
2696
+ model,
2697
+ definition,
2698
+ table: {
2699
+ name,
2700
+ columns,
2701
+ key: definition.kind === "aggregate" ? [...definition.key, SHARD_COLUMN] : [...definition.key],
2702
+ indexes: indexesOf(definition),
2703
+ ...physicalRetention(definition)
2704
+ },
2705
+ references: references.filter(({ table }) => table === name),
2706
+ referencedBy,
2707
+ roots: new Map(Object.entries(definition.indexes).flatMap(([index, { root }]) => root === void 0 ? [] : [[index, tableName(module, root.model)]]))
2708
+ });
2709
+ }
2710
+ return {
2711
+ models,
2712
+ tables: [...models.values()].map(({ table }) => table)
2378
2713
  };
2379
2714
  };
2715
+ resolveSchema([coreModule]);
2716
+ resolveSchema([aggregateBatchingModule]).tables;
2717
+ /** Each plugin's `schema.<id>` settings row. */
2718
+ var pluginSettings = (plugins) => Object.fromEntries(plugins.map(({ id, schemaVersion }) => [`schema.${id}`, schemaVersion]));
2719
+ /** A plugin's schema as the engine resolves it: prefixed by its id unless it keeps its names. */
2720
+ var pluginModule = ({ id, schema, namespace }) => ({
2721
+ id,
2722
+ schema,
2723
+ ...namespace === false ? {} : { namespace: id }
2724
+ });
2380
2725
  /** A row this attempt read changed while it ran; the attempt reruns. */
2381
2726
  var StaleReadError = class extends Error {};
2382
2727
  var idOf = (table, key) => JSON.stringify([table, key]);
@@ -2766,8 +3111,172 @@ var createTransactions = ({ adapter, schema, reads, retry = {} }) => {
2766
3111
  throw negative ?? new DatabaseConflictError(`The transaction conflicted ${attempts} times.`);
2767
3112
  } };
2768
3113
  };
3114
+ var sameFields = (left, right) => left.length === right.length && left.every((field) => right.includes(field));
3115
+ var keyValue = (value, field) => {
3116
+ if (isKeyValue(value)) return value;
3117
+ throw new DatabaseQueryError(`${field} needs a string, number, or boolean.`);
3118
+ };
3119
+ var bound = (exclusive, inclusive, columns) => {
3120
+ if (exclusive !== void 0 && inclusive !== void 0) throw new DatabaseQueryError(`range on ${columns[0]} sets both bounds of one side.`);
3121
+ const value = exclusive ?? inclusive;
3122
+ if (value === void 0) return void 0;
3123
+ const values = Array.isArray(value) ? value : [value];
3124
+ if (values.length === 0 || values.length > columns.length) throw new DatabaseQueryError(`range on ${columns[0]} takes 1–${columns.length} values of ${columns.join(", ")}.`);
3125
+ return {
3126
+ values: values.map((item, position) => keyValue(item, columns[position])),
3127
+ inclusive: inclusive !== void 0
3128
+ };
3129
+ };
3130
+ var createEngineReads = ({ adapter, schema, maxPageSize: requested = 500 }) => {
3131
+ const maxPageSize = Math.min(requested, 500);
3132
+ let count = {
3133
+ calls: 0,
3134
+ rows: 0
3135
+ };
3136
+ const returned = (rows) => {
3137
+ const size = Array.isArray(rows) ? rows.length : rows === null ? 0 : 1;
3138
+ count = {
3139
+ calls: count.calls + 1,
3140
+ rows: count.rows + size
3141
+ };
3142
+ return rows;
3143
+ };
3144
+ /** A model of `kind` by physical table name, for reads and transactions. */
3145
+ const modelOf = (name, kind = "table") => {
3146
+ const model = schema.models.get(name);
3147
+ if (model?.definition.kind === kind) return model;
3148
+ throw new DatabaseQueryError(model === void 0 ? `Unknown model ${name}.` : kind === "table" ? `${name} is an aggregate; read it with findAggregates and change it with aggregate.` : `${name} is a table; read it with findMany and change it with create, update, or delete.`);
3149
+ };
3150
+ const plan = ({ table }, input) => {
3151
+ const index = table.indexes.find(({ name }) => name === input.index);
3152
+ if (index === void 0) throw new DatabaseQueryError(`${table.name} has no index ${input.index}.`);
3153
+ const where = input.where ?? {};
3154
+ if (!sameFields(Object.keys(where), index.eq)) throw new DatabaseQueryError(`${table.name}.${index.name} reads need where on exactly: ${index.eq.join(", ") || "(none)"}.`);
3155
+ const { limit, order = "asc", range } = input;
3156
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxPageSize) throw new DatabaseQueryError(`limit must be 1–${maxPageSize}.`);
3157
+ const columns = indexOrderColumns(table, index);
3158
+ const eq = index.eq.map((field) => keyValue(where[field], `${table.name}.${field}`));
3159
+ const scope = cursorScope([
3160
+ table.name,
3161
+ index.name,
3162
+ eq,
3163
+ order,
3164
+ range
3165
+ ]);
3166
+ let lower = bound(range?.gt, range?.gte, columns);
3167
+ let upper = bound(range?.lt, range?.lte, columns);
3168
+ if (input.cursor !== void 0) {
3169
+ const values = decodeCursor(scope, input.cursor);
3170
+ if (order === "asc") lower = {
3171
+ values,
3172
+ inclusive: false
3173
+ };
3174
+ else upper = {
3175
+ values,
3176
+ inclusive: false
3177
+ };
3178
+ }
3179
+ return {
3180
+ request: {
3181
+ index: index.name,
3182
+ eq,
3183
+ order,
3184
+ limit,
3185
+ ...lower === void 0 ? {} : { lower },
3186
+ ...upper === void 0 ? {} : { upper }
3187
+ },
3188
+ index,
3189
+ scope
3190
+ };
3191
+ };
3192
+ /** Reads rows by key in one batch; the results follow the keys' order. */
3193
+ const findByKeys = async (name, lookups) => {
3194
+ const { table } = modelOf(name, "table");
3195
+ const keys = lookups.map((lookup) => {
3196
+ if (!sameFields(Object.keys(lookup), table.key)) throw new DatabaseQueryError(`findByKeys on ${name} takes its key: ${table.key.join(", ")}.`);
3197
+ return table.key.map((field) => keyValue(lookup[field], `${name}.${field}`));
3198
+ });
3199
+ const rows = keys.length === 0 ? [] : await adapter.get(table, keys);
3200
+ returned(rows.filter((row) => row !== null));
3201
+ return rows.map((row) => row ?? null);
3202
+ };
3203
+ return {
3204
+ maxPageSize,
3205
+ modelOf,
3206
+ reads: {
3207
+ total: () => count,
3208
+ reset: () => {
3209
+ count = {
3210
+ calls: 0,
3211
+ rows: 0
3212
+ };
3213
+ }
3214
+ },
3215
+ findByKeys,
3216
+ async findOne(name, lookup) {
3217
+ const { table } = modelOf(name, "table");
3218
+ const fields = Object.keys(lookup);
3219
+ if (sameFields(fields, table.key)) return (await findByKeys(name, [lookup]))[0];
3220
+ const index = table.indexes.find(({ unique, eq }) => unique && sameFields(fields, eq));
3221
+ if (index === void 0) throw new DatabaseQueryError(`findOne on ${name} needs its key or a unique field, not ${fields.join(", ")}.`);
3222
+ const [row] = await adapter.query(table, {
3223
+ index: index.name,
3224
+ eq: index.eq.map((field) => keyValue(lookup[field], `${name}.${field}`)),
3225
+ order: "asc",
3226
+ limit: 1
3227
+ });
3228
+ return returned(row ?? null);
3229
+ },
3230
+ async findMany(name, input) {
3231
+ const model = modelOf(name, "table");
3232
+ const { request, index, scope } = plan(model, input);
3233
+ const rows = returned(await adapter.query(model.table, request));
3234
+ const last = rows.at(-1);
3235
+ return rows.length === input.limit && last !== void 0 ? {
3236
+ rows,
3237
+ next: encodeCursor(scope, indexOrderTuple(model.table, index, last))
3238
+ } : { rows };
3239
+ },
3240
+ async findAggregates(name, input) {
3241
+ const model = modelOf(name, "aggregate");
3242
+ const { table, definition } = model;
3243
+ if (definition.kind !== "aggregate") throw new DatabaseQueryError(name);
3244
+ const { request, index, scope } = plan(model, input);
3245
+ const rows = await adapter.query(table, request);
3246
+ const logicalKey = (row) => definition.key.map((column) => keyValue(row[column], column));
3247
+ const groups = /* @__PURE__ */ new Map();
3248
+ for (const row of rows) {
3249
+ const key = JSON.stringify(logicalKey(row));
3250
+ groups.set(key, [...groups.get(key) ?? [], row]);
3251
+ }
3252
+ const full = rows.length === input.limit;
3253
+ const last = [...groups.values()].at(-1);
3254
+ if (full && last !== void 0) {
3255
+ const seen = new Set(last.map((row) => row[SHARD_COLUMN]));
3256
+ const missing = Array.from({ length: definition.shards }, (_, shard) => shard).filter((shard) => !seen.has(shard));
3257
+ if (missing.length > 0) {
3258
+ const key = logicalKey(last[0]);
3259
+ const rest = await adapter.get(table, missing.map((shard) => [...key, shard]));
3260
+ last.push(...rest.filter((row) => row !== null));
3261
+ }
3262
+ }
3263
+ const merged = [...groups.values()].map((shards) => {
3264
+ const row = Object.fromEntries(definition.key.map((field) => [field, shards[0][field]]));
3265
+ for (const metric of [...definition.counters, ...definition.gauges]) row[metric] = shards.reduce((sum, shard) => sum + Number(shard[metric] ?? 0), 0);
3266
+ for (const metric of definition.distinct) row[metric] = mergeDistinct(shards.map((shard) => shard[metric]));
3267
+ return row;
3268
+ });
3269
+ returned(merged);
3270
+ if (!full || last === void 0) return { rows: merged };
3271
+ return {
3272
+ rows: merged,
3273
+ next: encodeCursor(scope, indexOrderTuple(table, index, last[0]).slice(0, -1))
3274
+ };
3275
+ }
3276
+ };
3277
+ };
2769
3278
  /** Storage semantics over one adapter, addressed by physical table name. */
2770
- var createEngine = ({ adapter: given, schema, maxPageSize, verify, retry }) => {
3279
+ var createStorageEngine = ({ adapter: given, schema, maxPageSize, verify, retry }) => {
2771
3280
  const verified = verify ? verifyAdapter(given) : void 0;
2772
3281
  const adapter = verified ?? given;
2773
3282
  const reads = createEngineReads({
@@ -2797,12 +3306,17 @@ var createEngine = ({ adapter: given, schema, maxPageSize, verify, retry }) => {
2797
3306
  };
2798
3307
  };
2799
3308
  /** The engine plus `database(module)`, which hands a module its typed handle. */
2800
- var createDatabaseEngine = ({ batching, now = Date.now, ...options }) => {
2801
- const engine = createEngine(options);
3309
+ var createDatabaseEngine = ({ batching, now = Date.now, meter, ...options }) => {
3310
+ const adapter = meter ? meter.wrap(options.adapter) : options.adapter;
3311
+ const engine = createStorageEngine({
3312
+ ...options,
3313
+ adapter
3314
+ });
2802
3315
  const { reads } = engine;
3316
+ meter?.attach(reads.reads);
2803
3317
  const batches = batching && createAggregateBatches({
2804
3318
  engine,
2805
- adapter: options.adapter,
3319
+ adapter,
2806
3320
  schema: options.schema,
2807
3321
  batching,
2808
3322
  now
@@ -2851,6 +3365,230 @@ var createDatabaseEngine = ({ batching, now = Date.now, ...options }) => {
2851
3365
  }
2852
3366
  };
2853
3367
  };
3368
+ /**
3369
+ * How to create plugins' tables on a database, by the tooling it offers:
3370
+ * what the plugins' schema fence names when their settings rows are missing.
3371
+ */
3372
+ var pluginTablesFix = (database) => {
3373
+ const { createMigrator, generateSchema } = database;
3374
+ const managed = "A managed server runs only its provider's plugins, and rerunning `hot-updater init --provider <provider>` applies the provider's migrations.";
3375
+ const serverProject = "Run `hot-updater db` commands in the server's project: they load the server file named on the command line, or src/hotUpdater.* or src/db.*, and don't read hot-updater.config.ts.";
3376
+ return createMigrator !== void 0 ? `Run \`hot-updater db migrate\`. ${managed} ${serverProject}` : generateSchema !== void 0 ? `Run \`hot-updater db generate\` and apply the file it writes. ${managed} ${serverProject}` : managed;
3377
+ };
3378
+ /**
3379
+ * The storage engine over a database, as a server runs it: core's tables
3380
+ * and the plugins', with the plugins' settings rows fenced on a fenced
3381
+ * database, expired rows pruned during writes, aggregates batched when the
3382
+ * database batches them, and reads metered when it came from `meterReads`.
3383
+ * `createHotUpdater` runs core and its plugins on one; tests and tools run
3384
+ * a plugin on one without a server. Wrap the adapter with `verifyAdapter`
3385
+ * to check every call against the adapter contract.
3386
+ */
3387
+ var createEngine = (database, { plugins = [], now = Date.now } = {}) => {
3388
+ const modules = /* @__PURE__ */ new Map();
3389
+ for (const plugin of plugins) {
3390
+ if (plugin.id === coreModule.id) throw new HotUpdaterConfigError(`Plugin id "${plugin.id}" is core's own.`);
3391
+ if (modules.has(plugin.id)) throw new HotUpdaterConfigError(`Plugin "${plugin.id}" is registered twice.`);
3392
+ modules.set(plugin.id, pluginModule(plugin));
3393
+ }
3394
+ if (modules.size > 0) validateSchema([coreModule, ...modules.values()]);
3395
+ const name = fencedName(database.adapter);
3396
+ const fenced = name === void 0 || plugins.length === 0 ? database.adapter : withSchemaFence(database.adapter, name, pluginSettings(plugins), pluginTablesFix(database));
3397
+ const batching = database.aggregateBatching;
3398
+ const schema = resolveSchema([
3399
+ coreModule,
3400
+ ...modules.values(),
3401
+ ...batching === void 0 ? [] : [aggregateBatchingModule]
3402
+ ]);
3403
+ const meter = readMeterOf(database);
3404
+ const engine = createDatabaseEngine({
3405
+ adapter: pruneDuringWrites(fenced, schema.tables, {
3406
+ leaseTable: SETTINGS_TABLE,
3407
+ now
3408
+ }),
3409
+ schema,
3410
+ ...database.retry === void 0 ? {} : { retry: database.retry },
3411
+ ...batching === void 0 ? {} : {
3412
+ batching,
3413
+ now
3414
+ },
3415
+ ...meter === void 0 ? {} : { meter }
3416
+ });
3417
+ return {
3418
+ core: engine.database(coreModule),
3419
+ database: (plugin) => {
3420
+ const module = modules.get(plugin.id);
3421
+ if (module === void 0) throw new HotUpdaterConfigError(`Plugin "${plugin.id}" is not one of this engine's plugins.`);
3422
+ return engine.database(module);
3423
+ },
3424
+ flush: () => engine.flush(),
3425
+ dispose: () => engine.dispose()
3426
+ };
3427
+ };
3428
+ var HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.21";
3429
+ /**
3430
+ * `/version` on both mounts. The admin mount also lists the ids of the
3431
+ * plugins the server runs, sorted; apps never learn which plugins a server
3432
+ * runs.
3433
+ */
3434
+ var createVersionRouteHandlers = (plugins = []) => {
3435
+ const version = {
3436
+ adminProtocol: 2,
3437
+ infrastructureGeneration: 1,
3438
+ version: HOT_UPDATER_SERVER_VERSION
3439
+ };
3440
+ const adminVersion = {
3441
+ ...version,
3442
+ plugins: [...plugins].sort()
3443
+ };
3444
+ return {
3445
+ version: async () => Response.json(version),
3446
+ adminVersion: async () => Response.json(adminVersion)
3447
+ };
3448
+ };
3449
+ /**
3450
+ * The brand Hot Updater's own plugin factories set. It is a registered
3451
+ * symbol, not a module-private one, so a plugin made by one package's ESM
3452
+ * build still carries it when another's CommonJS build reads it, as a config
3453
+ * loaded through `require` may; no plugin sets it by accident. Each official
3454
+ * plugin package sets it with a copy of its own, so no package imports
3455
+ * another's internals for it. It guards Hot Updater's ids against a
3456
+ * clash, not against a determined impostor.
3457
+ */
3458
+ var OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
3459
+ /**
3460
+ * The ids of Hot Updater's own plugins. The console and tooling tell
3461
+ * features apart by plugin id, so no other plugin may take one.
3462
+ */
3463
+ var OFFICIAL_IDS = /* @__PURE__ */ new Set(["insights", "apiKeys"]);
3464
+ /** Whether one of Hot Updater's own factories made `plugin`. */
3465
+ var isOfficialPlugin = (plugin) => typeof plugin === "object" && plugin !== null && Reflect.get(plugin, OFFICIAL) === true;
3466
+ /**
3467
+ * Refuses a plugin that takes one of Hot Updater's own ids without being
3468
+ * that plugin: `at` names it in the error, such as `plugins[0]`.
3469
+ */
3470
+ var checkReservedId = (plugin, at) => {
3471
+ const id = typeof plugin === "object" && plugin !== null ? Reflect.get(plugin, "id") : void 0;
3472
+ if (typeof id === "string" && OFFICIAL_IDS.has(id) && !isOfficialPlugin(plugin)) throw new HotUpdaterConfigError(`${at} takes the id "${id}", which is reserved for Hot Updater's ${id}() plugin; give this plugin another id.`);
3473
+ };
3474
+ var EXPORT_NAME = /^[A-Za-z_$][\w$]*$/u;
3475
+ /**
3476
+ * Names app code cannot import as they are: reserved words, and the names
3477
+ * the printed app code declares itself.
3478
+ */
3479
+ var UNAVAILABLE_NAMES = /* @__PURE__ */ new Set([
3480
+ "App",
3481
+ "HotUpdater",
3482
+ "arguments",
3483
+ "await",
3484
+ "break",
3485
+ "case",
3486
+ "catch",
3487
+ "class",
3488
+ "const",
3489
+ "continue",
3490
+ "debugger",
3491
+ "default",
3492
+ "delete",
3493
+ "do",
3494
+ "else",
3495
+ "enum",
3496
+ "eval",
3497
+ "export",
3498
+ "extends",
3499
+ "false",
3500
+ "finally",
3501
+ "for",
3502
+ "function",
3503
+ "if",
3504
+ "implements",
3505
+ "import",
3506
+ "in",
3507
+ "instanceof",
3508
+ "interface",
3509
+ "let",
3510
+ "new",
3511
+ "null",
3512
+ "package",
3513
+ "private",
3514
+ "protected",
3515
+ "public",
3516
+ "return",
3517
+ "static",
3518
+ "super",
3519
+ "switch",
3520
+ "this",
3521
+ "throw",
3522
+ "true",
3523
+ "try",
3524
+ "typeof",
3525
+ "var",
3526
+ "void",
3527
+ "while",
3528
+ "with",
3529
+ "yield"
3530
+ ]);
3531
+ var isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3532
+ /**
3533
+ * The client plugins an app adds for the plugins, each once, in plugin
3534
+ * order: what init prints in `HotUpdater.init`'s `plugins`. A malformed one,
3535
+ * or one name from two modules, throws `HotUpdaterConfigError`.
3536
+ */
3537
+ var clientPluginsOf = (plugins) => {
3538
+ if (!Array.isArray(plugins)) throw new HotUpdaterConfigError("plugins must be an array of plugins.");
3539
+ const found = [];
3540
+ plugins.forEach((plugin, position) => checkReservedId(plugin, `plugins[${position}]`));
3541
+ for (const plugin of plugins) {
3542
+ if (!isRecord$3(plugin) || !isRecord$3(plugin.cli)) continue;
3543
+ const value = plugin.cli.clientPlugin;
3544
+ if (value === void 0) continue;
3545
+ const id = String(plugin.id);
3546
+ if (!isRecord$3(value) || typeof value.module !== "string" || value.module === "" || typeof value.name !== "string" || !EXPORT_NAME.test(value.name) || UNAVAILABLE_NAMES.has(value.name)) throw new HotUpdaterConfigError(`Plugin "${id}" cli.clientPlugin needs a module and an export name the app can import: an identifier other than a reserved word, App, or HotUpdater.`);
3547
+ const { module, name } = value;
3548
+ const named = found.find((entry) => entry.name === name);
3549
+ if (named === void 0) found.push({
3550
+ module,
3551
+ name
3552
+ });
3553
+ else if (named.module !== module) throw new HotUpdaterConfigError(`Plugin "${id}" names the client plugin "${name}" from ${module}, but another plugin names it from ${named.module}.`);
3554
+ }
3555
+ return found;
3556
+ };
3557
+ /** A stored catalog payload, checked against its strategy's shape. */
3558
+ var parseCompiledCatalog = (payload, strategy) => {
3559
+ const parsed = JSON.parse(payload);
3560
+ if (typeof parsed !== "object" || parsed === null || Reflect.get(parsed, "schemaVersion") !== 1 || Reflect.get(parsed, "strategy") !== strategy || Reflect.get(parsed, "fallbackPolicy") !== "BUILTIN_IF_ACTIVE_INELIGIBLE" || !Array.isArray(Reflect.get(parsed, "releaseDescriptors"))) throw new Error("Stored Release catalog payload is invalid");
3561
+ if (strategy === "APP_VERSION" && !Array.isArray(Reflect.get(parsed, "segments"))) throw new Error("Stored app-version Release catalog is invalid");
3562
+ if (strategy === "FINGERPRINT" && !Array.isArray(Reflect.get(parsed, "releaseIndexes"))) throw new Error("Stored fingerprint Release catalog is invalid");
3563
+ return parsed;
3564
+ };
3565
+ /** The scope key an update check reads. */
3566
+ var releaseCatalogScopeKeyOf = (input) => input.strategy === "APP_VERSION" ? createReleaseCatalogScopeKey({
3567
+ channelKey: input.channelKey,
3568
+ platform: input.platform,
3569
+ strategy: "APP_VERSION"
3570
+ }) : createReleaseCatalogScopeKey({
3571
+ channelKey: input.channelKey,
3572
+ fingerprintHash: input.fingerprintHash,
3573
+ platform: input.platform,
3574
+ strategy: "FINGERPRINT"
3575
+ });
3576
+ /** Projects a stored catalog row for one update check; null when the row is not this scope's. */
3577
+ var projectReleaseCatalogRow = (row, input, scopeKey = releaseCatalogScopeKeyOf(input)) => {
3578
+ if (row === null || row.platform !== input.platform || row.strategy !== input.strategy || row.channel_key !== input.channelKey || row.scope_key !== scopeKey || row.fingerprint_hash !== (input.strategy === "FINGERPRINT" ? input.fingerprintHash : null)) return null;
3579
+ const compiled = parseCompiledCatalog(row.payload, input.strategy);
3580
+ const appVersion = input.strategy === "APP_VERSION" ? input.appVersion : void 0;
3581
+ return {
3582
+ catalogId: row.catalog_id,
3583
+ catalogHash: row.catalog_hash,
3584
+ fallbackPolicy: RELEASE_CATALOG_FALLBACK_POLICY,
3585
+ generation: row.generation,
3586
+ releases: projectCompiledCatalog(compiled, appVersion),
3587
+ rollbackReleases: projectCompiledRollbackCatalog(compiled, appVersion),
3588
+ schemaVersion: 1,
3589
+ scopeKey
3590
+ };
3591
+ };
2854
3592
  var HBC_ASSET_PATH_RE = /\.bundle$/;
2855
3593
  var asByteSize = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
2856
3594
  var resolveUniqueHbcAssetPath = (manifest) => {
@@ -2997,6 +3735,29 @@ async function resolveManifestArtifacts({ currentBundle, resolveFileUrl, readSto
2997
3735
  ...archiveUrl ? { archiveUrl } : {}
2998
3736
  };
2999
3737
  }
3738
+ /** A compiled segment as a range: its version when it holds exactly one. */
3739
+ var segmentRange = ({ lower, upper }) => lower?.inclusive && upper?.inclusive && lower.version === upper.version ? lower.version : [lower === null ? "" : `${lower.inclusive ? ">=" : ">"}${lower.version}`, upper === null ? "" : `${upper.inclusive ? "<=" : "<"}${upper.version}`].join(" ").trim() || "*";
3740
+ /**
3741
+ * Auto-patch bases from a scope's compiled Release Catalog, which holds
3742
+ * every enabled bundle release of the scope: those whose range intersects
3743
+ * `range` (all of them in a fingerprint scope), newest release first, each
3744
+ * bundle once and older than `bundleId`, at most `limit`.
3745
+ */
3746
+ var baseBundleIdsOf = (row, range, bundleId, limit) => {
3747
+ const catalog = parseCompiledCatalog(row.payload, row.strategy);
3748
+ const serving = new Set(catalog.strategy === "FINGERPRINT" ? catalog.rollbackReleaseIndexes : catalog.segments.flatMap((segment) => range !== null && rangesIntersect(range, segmentRange(segment)) ? segment.rollbackReleaseIndexes : []));
3749
+ const newestFirst = catalog.releaseDescriptors.map((descriptor, index) => ({
3750
+ descriptor,
3751
+ index
3752
+ })).sort((left, right) => left.descriptor.releaseId < right.descriptor.releaseId ? 1 : -1);
3753
+ const ids = [];
3754
+ for (const { descriptor, index } of newestFirst) {
3755
+ if (ids.length >= limit) break;
3756
+ const id = descriptor.bundleId;
3757
+ if (serving.has(index) && descriptor.kind === "BUNDLE" && id !== null && id < bundleId && !ids.includes(id)) ids.push(id);
3758
+ }
3759
+ return ids;
3760
+ };
3000
3761
  var PAGE$2 = 500;
3001
3762
  var pick = (fields) => (row) => Object.fromEntries(fields.map((field) => [field, row[field]]));
3002
3763
  var toBundleRow = pick([
@@ -3332,7 +4093,7 @@ var deleteChannel = (db, id) => db.transaction(async (tx) => {
3332
4093
  return { deleted: true };
3333
4094
  });
3334
4095
  var PAGE = 500;
3335
- var fail = (code, message) => {
4096
+ var fail$1 = (code, message) => {
3336
4097
  throw new ReleaseCatalogMutationError(code, message);
3337
4098
  };
3338
4099
  var checkScope = (scope) => {
@@ -3347,7 +4108,7 @@ var checkScope = (scope) => {
3347
4108
  platform: scope.platform,
3348
4109
  strategy: "FINGERPRINT"
3349
4110
  });
3350
- if (scope.strategy === "FINGERPRINT" !== (scope.fingerprintHash !== null) || scope.scopeKey !== expected) fail("INVALID_SCOPE", "Release catalog scope metadata is inconsistent");
4111
+ if (scope.strategy === "FINGERPRINT" !== (scope.fingerprintHash !== null) || scope.scopeKey !== expected) fail$1("INVALID_SCOPE", "Release catalog scope metadata is inconsistent");
3351
4112
  };
3352
4113
  /** A scope's enabled releases, all of them, under the catalog's guard. */
3353
4114
  var enabledReleases = async (tx, scopeKey) => {
@@ -3385,7 +4146,7 @@ var writeCatalog = async (tx, scope, stored, enabled, updatedAtMs) => {
3385
4146
  releases: enabled.map(releaseRowToRelease)
3386
4147
  });
3387
4148
  const generation = (stored.generation ?? 0) + 1;
3388
- if (!Number.isSafeInteger(generation)) fail("CATALOG_GENERATION_EXHAUSTED", `Catalog "${scope.scopeKey}" exhausted its generation counter`);
4149
+ if (!Number.isSafeInteger(generation)) fail$1("CATALOG_GENERATION_EXHAUSTED", `Catalog "${scope.scopeKey}" exhausted its generation counter`);
3389
4150
  const catalog = {
3390
4151
  scope_key: scope.scopeKey,
3391
4152
  catalog_id: stored.catalog?.catalog_id ?? createUUIDv7(),
@@ -3420,7 +4181,7 @@ var readCatalog = async (tx, scopeKey) => {
3420
4181
  };
3421
4182
  /** A scope with releases and no compiled catalog lost its history. */
3422
4183
  var assertHistory = (stored, hasReleases) => {
3423
- if (stored.catalog === null && hasReleases) fail("CATALOG_IDENTITY_MISSING", "Catalog history is missing. Restore its catalog row from backup before rebuilding or deploying.");
4184
+ if (stored.catalog === null && hasReleases) fail$1("CATALOG_IDENTITY_MISSING", "Catalog history is missing. Restore its catalog row from backup before rebuilding or deploying.");
3424
4185
  };
3425
4186
  /** One release change and its scope's next catalog, inside `tx`. */
3426
4187
  var changeRelease = async (tx, { scope, change, bundle, updatedAtMs }) => {
@@ -3438,690 +4199,423 @@ var changeRelease = async (tx, { scope, change, bundle, updatedAtMs }) => {
3438
4199
  assertHistory(stored, latest !== null);
3439
4200
  const floor = [latest, bundle && isUUIDv7(bundle.row.id) ? bundle.row.id : null].filter((id) => id !== null).sort().at(-1);
3440
4201
  const id = change.row.id ?? createUUIDv7After(floor ?? null);
3441
- if (latest !== null && id <= latest) fail("NON_MONOTONIC_RELEASE_ID", `Release "${id}" must sort after "${latest}"`);
3442
- release = {
3443
- ...change.row,
3444
- id
3445
- };
3446
- if (release.scope_key !== scope.scopeKey || release.channel_id !== scope.channelId || release.platform !== scope.platform || release.strategy !== scope.strategy || release.fingerprint_hash !== scope.fingerprintHash || release.revision !== 1) fail("INVALID_SCOPE", "Inserted Release does not match its catalog scope");
3447
- tx.create("releases", release);
3448
- } else {
3449
- const row = await tx.findOne("releases", { id: change.id });
3450
- if (row === null || row.scope_key !== scope.scopeKey) return fail("RELEASE_NOT_FOUND", `Release "${change.id}" was not found in scope "${scope.scopeKey}"`);
3451
- assertHistory(stored, true);
3452
- before = toReleaseRow(row);
3453
- if (change.operation === "delete") {
3454
- await tx.delete("releases", row);
3455
- release = null;
3456
- } else {
3457
- const set = {
3458
- ...change.update,
3459
- revision: before.revision + 1
3460
- };
3461
- tx.update("releases", row, set);
3462
- release = {
3463
- ...before,
3464
- ...set
3465
- };
3466
- }
3467
- }
3468
- const changedId = before?.id ?? release?.id;
3469
- const { catalog, compilation } = await writeCatalog(tx, scope, stored, [...enabled.filter(({ id }) => id !== changedId), ...release?.enabled ? [release] : []].sort((left, right) => left.id < right.id ? -1 : 1), updatedAtMs);
3470
- return {
3471
- catalog,
3472
- release,
3473
- diagnostics: compilation.diagnostics
3474
- };
3475
- };
3476
- /**
3477
- * Release changes rooted at their catalogs: each reads its scope's catalog
3478
- * and enabled releases, applies the change, and writes the next catalog
3479
- * generation, all in one transaction. A concurrent change in a scope bumps
3480
- * its catalog row, so this transaction reruns instead of publishing a stale
3481
- * catalog.
3482
- */
3483
- var changeReleases = (db, inputs) => {
3484
- if (new Set(inputs.map(({ scope }) => scope.scopeKey)).size !== inputs.length) fail("INVALID_SCOPE", "A batch cannot change one catalog scope twice");
3485
- return db.transaction(async (tx) => {
3486
- const results = [];
3487
- for (const input of inputs) results.push(await changeRelease(tx, input));
3488
- return results;
3489
- });
3490
- };
3491
- /** Recompiles a scope's catalog from its enabled releases inside `tx`; an unchanged catalog is not rewritten. */
3492
- var rebuildScope = async (tx, scope, updatedAtMs) => {
3493
- checkScope(scope);
3494
- const stored = await readCatalog(tx, scope.scopeKey);
3495
- assertHistory(stored, await latestReleaseId(tx, scope.scopeKey) !== null);
3496
- const enabled = await enabledReleases(tx, scope.scopeKey);
3497
- const compilation = await compileReleaseCatalog({
3498
- strategy: scope.strategy,
3499
- releases: enabled.map(releaseRowToRelease)
3500
- });
3501
- const current = stored.catalog;
3502
- if (current !== null && current.strategy === scope.strategy && current.channel_id === scope.channelId && current.channel_key === encodeChannelKey(scope.channelName) && current.platform === scope.platform && current.fingerprint_hash === scope.fingerprintHash && current.catalog_hash === compilation.catalogHash) return {
3503
- catalog: current,
3504
- current,
3505
- changed: false,
3506
- diagnostics: compilation.diagnostics
3507
- };
3508
- const written = await writeCatalog(tx, scope, stored, enabled, updatedAtMs);
3509
- return {
3510
- catalog: written.catalog,
3511
- current,
3512
- changed: true,
3513
- diagnostics: written.compilation.diagnostics
3514
- };
3515
- };
3516
- /** Carries a preflight's result out of the transaction it stops, so nothing is written. */
3517
- var Preflight = class extends Error {
3518
- result;
3519
- constructor(result) {
3520
- super("preflight");
3521
- this.result = result;
3522
- }
3523
- };
3524
- var dryRun = async (db, read) => {
3525
- try {
3526
- await db.transaction(async (tx) => {
3527
- throw new Preflight(await read(tx));
3528
- });
3529
- } catch (error) {
3530
- if (error instanceof Preflight) return error.result;
3531
- throw error;
3532
- }
3533
- throw new Error("A preflight transaction returned without its result.");
3534
- };
3535
- /** A channel's catalog scope for a platform, and a fingerprint when it has one. */
3536
- var scopeOf = (channel, platform, fingerprintHash) => {
3537
- const channelKey = encodeChannelKey(channel.name);
3538
- return fingerprintHash === null ? {
3539
- channelId: channel.id,
3540
- channelName: channel.name,
3541
- fingerprintHash,
3542
- platform,
3543
- scopeKey: createReleaseCatalogScopeKey({
3544
- channelKey,
3545
- platform,
3546
- strategy: "APP_VERSION"
3547
- }),
3548
- strategy: "APP_VERSION"
3549
- } : {
3550
- channelId: channel.id,
3551
- channelName: channel.name,
3552
- fingerprintHash,
3553
- platform,
3554
- scopeKey: createReleaseCatalogScopeKey({
3555
- channelKey,
3556
- fingerprintHash,
3557
- platform,
3558
- strategy: "FINGERPRINT"
3559
- }),
3560
- strategy: "FINGERPRINT"
3561
- };
3562
- };
3563
- /** A release and its scope, checked against the revision the caller saw. */
3564
- var loadTarget = async (tx, { releaseId, expectedRevision }) => {
3565
- const row = await tx.findOne("releases", { id: releaseId });
3566
- if (row === null) throw new ReleaseManagementError("RELEASE_NOT_FOUND", `Release "${releaseId}" was not found.`);
3567
- const release = toReleaseRow(row);
3568
- if (expectedRevision !== void 0 && expectedRevision !== release.revision) throw new ReleaseManagementError("VERSION_CONFLICT", `Release "${release.id}" is revision ${release.revision}; expected ${expectedRevision}.`);
3569
- const catalog = await tx.findOne("release_catalogs", { scope_key: release.scope_key });
3570
- if (catalog === null) throw new ReleaseCatalogMutationError("INVALID_SCOPE", `Release "${release.id}" has no catalog projection.`);
3571
- return {
3572
- release,
3573
- scope: {
3574
- channelId: release.channel_id,
3575
- channelName: decodeChannelKey(catalog.channel_key),
3576
- fingerprintHash: release.fingerprint_hash,
3577
- platform: release.platform,
3578
- scopeKey: release.scope_key,
3579
- strategy: release.strategy
3580
- }
3581
- };
3582
- };
3583
- var policyUpdate = (release, patch, updatedAtMs) => {
3584
- if (patch.fingerprintHash !== void 0 && patch.fingerprintHash !== release.fingerprint_hash) throw new ReleaseManagementError("SCOPE_MOVE_UNSUPPORTED", "Changing a fingerprint target requires an atomic Release scope move.");
3585
- return {
3586
- ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
3587
- ...patch.message === void 0 ? {} : { message: patch.message },
3588
- ...patch.rolloutCohortCount === void 0 ? {} : { rollout_cohort_count: patch.rolloutCohortCount },
3589
- ...patch.shouldForceUpdate === void 0 ? {} : { should_force_update: patch.shouldForceUpdate },
3590
- ...patch.targetAppVersion === void 0 ? {} : { target_app_version: patch.targetAppVersion },
3591
- ...patch.targetCohorts === void 0 ? {} : { target_cohorts: [...patch.targetCohorts] },
3592
- updated_at_ms: updatedAtMs
3593
- };
3594
- };
3595
- var mutationResult = ({ catalog, release }) => ({
3596
- attempts: 1,
3597
- catalog,
3598
- release
3599
- });
3600
- /** The scope a catalog row names, for a rebuild. */
3601
- var catalogScope = async (tx, scopeKey) => {
3602
- const row = await tx.findOne("release_catalogs", { scope_key: scopeKey });
3603
- if (row === null) throw new Error(`Release catalog "${scopeKey}" was not found.`);
3604
- return {
3605
- channelId: row.channel_id,
3606
- channelName: decodeChannelKey(row.channel_key),
3607
- fingerprintHash: row.fingerprint_hash,
3608
- platform: row.platform,
3609
- scopeKey: row.scope_key,
3610
- strategy: row.strategy
3611
- };
3612
- };
3613
- /**
3614
- * Core's typed writes: each runs in one engine transaction rooted at the
3615
- * catalogs it changes, so a concurrent change reruns it instead of
3616
- * publishing a stale catalog.
3617
- */
3618
- var createCoreOperations = (db, { now = Date.now } = {}) => {
3619
- const ensureChannel = async (name) => (await insertChannel(db, {
3620
- id: `channel:${encodeChannelKey(name)}`,
3621
- name
3622
- })).row;
3623
- /**
3624
- * Runs a write that ensured its channels first. A channel deleted between
3625
- * that and the write refuses the write, so it runs again and ensures it anew.
3626
- */
3627
- const withChannels = async (write) => {
3628
- for (let attempt = 1;; attempt += 1) try {
3629
- return await write();
3630
- } catch (error) {
3631
- if (attempt < 3 && error instanceof DatabaseConstraintError && error.reason === "not_found" && error.model === "channels") continue;
3632
- throw error;
3633
- }
3634
- };
3635
- const deployOnce = async (deployments) => {
3636
- const channels = /* @__PURE__ */ new Map();
3637
- for (const { release } of deployments) if (!channels.has(release.channel)) channels.set(release.channel, await ensureChannel(release.channel));
3638
- const platforms = /* @__PURE__ */ new Map();
3639
- for (const deployment of deployments) {
3640
- if (!("bundleId" in deployment) || platforms.has(deployment.bundleId)) continue;
3641
- const row = await db.findOne("bundles", { id: deployment.bundleId });
3642
- if (row === null) throw new DatabaseBundleNotFoundError(deployment.bundleId);
3643
- platforms.set(deployment.bundleId, toBundleRow(row).platform);
3644
- }
3645
- const updatedAtMs = now();
3646
- return (await changeReleases(db, deployments.map((deployment) => {
3647
- const { release: policy } = deployment;
3648
- const stored = "bundleId" in deployment;
3649
- const bundleId = stored ? deployment.bundleId : deployment.bundle.id;
3650
- const platform = stored ? platforms.get(deployment.bundleId) : deployment.bundle.platform;
3651
- const scope = scopeOf(channels.get(policy.channel), platform, policy.fingerprintHash);
3652
- return {
3653
- scope,
3654
- ...stored ? {} : { bundle: {
3655
- row: bundleToRow(deployment.bundle),
3656
- patches: bundleToPatchRows(deployment.bundle)
3657
- } },
3658
- change: {
3659
- operation: "insert",
3660
- row: {
3661
- bundle_id: bundleId,
3662
- channel_id: scope.channelId,
3663
- created_at_ms: updatedAtMs,
3664
- enabled: policy.enabled,
3665
- fingerprint_hash: policy.fingerprintHash,
3666
- kind: "BUNDLE",
3667
- message: policy.message,
3668
- operation: "DEPLOY",
3669
- platform,
3670
- revision: 1,
3671
- rollout_cohort_count: policy.rolloutCohortCount ?? 1e3,
3672
- scope_key: scope.scopeKey,
3673
- should_force_update: policy.shouldForceUpdate,
3674
- source_release_id: null,
3675
- strategy: scope.strategy,
3676
- target_app_version: policy.targetAppVersion,
3677
- target_cohorts: policy.targetCohorts ?? [],
3678
- updated_at_ms: updatedAtMs
3679
- }
3680
- },
3681
- updatedAtMs
4202
+ if (latest !== null && id <= latest) fail$1("NON_MONOTONIC_RELEASE_ID", `Release "${id}" must sort after "${latest}"`);
4203
+ release = {
4204
+ ...change.row,
4205
+ id
4206
+ };
4207
+ if (release.scope_key !== scope.scopeKey || release.channel_id !== scope.channelId || release.platform !== scope.platform || release.strategy !== scope.strategy || release.fingerprint_hash !== scope.fingerprintHash || release.revision !== 1) fail$1("INVALID_SCOPE", "Inserted Release does not match its catalog scope");
4208
+ tx.create("releases", release);
4209
+ } else {
4210
+ const row = await tx.findOne("releases", { id: change.id });
4211
+ if (row === null || row.scope_key !== scope.scopeKey) return fail$1("RELEASE_NOT_FOUND", `Release "${change.id}" was not found in scope "${scope.scopeKey}"`);
4212
+ assertHistory(stored, true);
4213
+ before = toReleaseRow(row);
4214
+ if (change.operation === "delete") {
4215
+ await tx.delete("releases", row);
4216
+ release = null;
4217
+ } else {
4218
+ const set = {
4219
+ ...change.update,
4220
+ revision: before.revision + 1
3682
4221
  };
3683
- }))).map(mutationResult);
4222
+ tx.update("releases", row, set);
4223
+ release = {
4224
+ ...before,
4225
+ ...set
4226
+ };
4227
+ }
4228
+ }
4229
+ const changedId = before?.id ?? release?.id;
4230
+ const { catalog, compilation } = await writeCatalog(tx, scope, stored, [...enabled.filter(({ id }) => id !== changedId), ...release?.enabled ? [release] : []].sort((left, right) => left.id < right.id ? -1 : 1), updatedAtMs);
4231
+ return {
4232
+ catalog,
4233
+ release,
4234
+ diagnostics: compilation.diagnostics
4235
+ };
4236
+ };
4237
+ /**
4238
+ * Release changes rooted at their catalogs: each reads its scope's catalog
4239
+ * and enabled releases, applies the change, and writes the next catalog
4240
+ * generation, all in one transaction. A concurrent change in a scope bumps
4241
+ * its catalog row, so this transaction reruns instead of publishing a stale
4242
+ * catalog.
4243
+ */
4244
+ var changeReleases = (db, inputs) => {
4245
+ if (new Set(inputs.map(({ scope }) => scope.scopeKey)).size !== inputs.length) fail$1("INVALID_SCOPE", "A batch cannot change one catalog scope twice");
4246
+ return db.transaction(async (tx) => {
4247
+ const results = [];
4248
+ for (const input of inputs) results.push(await changeRelease(tx, input));
4249
+ return results;
4250
+ });
4251
+ };
4252
+ /** Recompiles a scope's catalog from its enabled releases inside `tx`; an unchanged catalog is not rewritten. */
4253
+ var rebuildScope = async (tx, scope, updatedAtMs) => {
4254
+ checkScope(scope);
4255
+ const stored = await readCatalog(tx, scope.scopeKey);
4256
+ assertHistory(stored, await latestReleaseId(tx, scope.scopeKey) !== null);
4257
+ const enabled = await enabledReleases(tx, scope.scopeKey);
4258
+ const compilation = await compileReleaseCatalog({
4259
+ strategy: scope.strategy,
4260
+ releases: enabled.map(releaseRowToRelease)
4261
+ });
4262
+ const current = stored.catalog;
4263
+ if (current !== null && current.strategy === scope.strategy && current.channel_id === scope.channelId && current.channel_key === encodeChannelKey(scope.channelName) && current.platform === scope.platform && current.fingerprint_hash === scope.fingerprintHash && current.catalog_hash === compilation.catalogHash) return {
4264
+ catalog: current,
4265
+ current,
4266
+ changed: false,
4267
+ diagnostics: compilation.diagnostics
3684
4268
  };
4269
+ const written = await writeCatalog(tx, scope, stored, enabled, updatedAtMs);
3685
4270
  return {
3686
- deploy: (deployments) => withChannels(() => deployOnce(deployments)),
3687
- updateReleasePolicy: (input) => {
3688
- const updatedAtMs = now();
3689
- return db.transaction(async (tx) => {
3690
- const { release, scope } = await loadTarget(tx, input);
3691
- return mutationResult(await changeRelease(tx, {
3692
- scope,
3693
- change: {
3694
- operation: "update",
3695
- id: release.id,
3696
- update: policyUpdate(release, input.patch, updatedAtMs)
3697
- },
3698
- updatedAtMs
3699
- }));
3700
- });
3701
- },
3702
- preflightReleasePolicy: (input) => {
3703
- const updatedAtMs = now();
3704
- return dryRun(db, async (tx) => {
3705
- const { release, scope } = await loadTarget(tx, input);
3706
- const stored = await readCatalog(tx, scope.scopeKey);
3707
- const changed = await changeRelease(tx, {
3708
- scope,
3709
- change: {
3710
- operation: "update",
3711
- id: release.id,
3712
- update: policyUpdate(release, input.patch, updatedAtMs)
3713
- },
3714
- updatedAtMs
3715
- });
3716
- return {
3717
- catalog: changed.catalog,
3718
- currentCatalog: stored.catalog,
3719
- diagnostics: changed.diagnostics,
3720
- expectedReleaseRevision: release.revision,
3721
- release: changed.release
3722
- };
3723
- });
3724
- },
3725
- deleteRelease: (input) => db.transaction(async (tx) => {
3726
- const { release, scope } = await loadTarget(tx, input);
3727
- if (release.enabled) throw new ReleaseManagementError("ENABLED_RELEASE", `Disable Release "${release.id}" before hard deletion.`);
3728
- return mutationResult(await changeRelease(tx, {
3729
- scope,
3730
- change: {
3731
- operation: "delete",
3732
- id: release.id
3733
- },
3734
- updatedAtMs: now()
3735
- }));
3736
- }),
3737
- promoteRelease: (input) => withChannels(async () => {
3738
- const checkSource = (source) => {
3739
- if (source.kind !== "BUNDLE" || source.bundle_id === null) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", "Only a Bundle Release can be promoted.");
3740
- };
3741
- checkSource((await db.transaction((tx) => loadTarget(tx, input))).release);
3742
- const targetChannel = input.targetChannel.trim();
3743
- if (targetChannel.length === 0) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", "Promotion requires a target channel.");
3744
- const channel = await ensureChannel(targetChannel);
3745
- const updatedAtMs = now();
3746
- return db.transaction(async (tx) => {
3747
- const { release: source, scope: sourceScope } = await loadTarget(tx, input);
3748
- checkSource(source);
3749
- const targetScope = scopeOf(channel, source.platform, source.fingerprint_hash);
3750
- if (targetScope.scopeKey === sourceScope.scopeKey) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", "Source and target Release scopes are the same.");
3751
- const { rows } = await tx.findMany("releases", {
3752
- index: "byScope",
3753
- where: { scope_key: targetScope.scopeKey },
3754
- order: "desc",
3755
- limit: 1
3756
- });
3757
- const floor = [
3758
- rows[0]?.id ?? null,
3759
- source.id,
3760
- source.bundle_id
3761
- ].filter((id) => id !== null && isUUIDv7(id)).sort().at(-1);
3762
- const moved = input.action === "move" ? await changeRelease(tx, {
3763
- scope: sourceScope,
3764
- change: {
3765
- operation: "update",
3766
- id: source.id,
3767
- update: {
3768
- enabled: false,
3769
- updated_at_ms: updatedAtMs
3770
- }
3771
- },
3772
- updatedAtMs
3773
- }) : null;
3774
- const target = await changeRelease(tx, {
3775
- scope: targetScope,
3776
- change: {
3777
- operation: "insert",
3778
- row: {
3779
- ...source,
3780
- id: createUUIDv7After(floor ?? null, updatedAtMs),
3781
- revision: 1,
3782
- scope_key: targetScope.scopeKey,
3783
- channel_id: targetScope.channelId,
3784
- enabled: true,
3785
- rollout_cohort_count: 1e3,
3786
- target_cohorts: [],
3787
- operation: "PROMOTE",
3788
- source_release_id: source.id,
3789
- created_at_ms: updatedAtMs,
3790
- updated_at_ms: updatedAtMs
3791
- }
3792
- },
3793
- updatedAtMs
3794
- });
3795
- return {
3796
- source: moved === null ? null : mutationResult(moved),
3797
- target: mutationResult(target)
3798
- };
3799
- });
4271
+ catalog: written.catalog,
4272
+ current,
4273
+ changed: true,
4274
+ diagnostics: written.compilation.diagnostics
4275
+ };
4276
+ };
4277
+ /** Carries a preflight's result out of the transaction it stops, so nothing is written. */
4278
+ var Preflight = class extends Error {
4279
+ result;
4280
+ constructor(result) {
4281
+ super("preflight");
4282
+ this.result = result;
4283
+ }
4284
+ };
4285
+ var dryRun = async (db, read) => {
4286
+ try {
4287
+ await db.transaction(async (tx) => {
4288
+ throw new Preflight(await read(tx));
4289
+ });
4290
+ } catch (error) {
4291
+ if (error instanceof Preflight) return error.result;
4292
+ throw error;
4293
+ }
4294
+ throw new Error("A preflight transaction returned without its result.");
4295
+ };
4296
+ /** A channel's catalog scope for a platform, and a fingerprint when it has one. */
4297
+ var scopeOf = (channel, platform, fingerprintHash) => {
4298
+ const channelKey = encodeChannelKey(channel.name);
4299
+ return fingerprintHash === null ? {
4300
+ channelId: channel.id,
4301
+ channelName: channel.name,
4302
+ fingerprintHash,
4303
+ platform,
4304
+ scopeKey: createReleaseCatalogScopeKey({
4305
+ channelKey,
4306
+ platform,
4307
+ strategy: "APP_VERSION"
3800
4308
  }),
3801
- rebuildReleaseCatalog: (scopeKey) => {
3802
- const updatedAtMs = now();
3803
- return db.transaction(async (tx) => {
3804
- const { current: _current, ...rebuilt } = await rebuildScope(tx, await catalogScope(tx, scopeKey), updatedAtMs);
3805
- return {
3806
- attempts: 1,
3807
- ...rebuilt
3808
- };
3809
- });
3810
- },
3811
- preflightReleaseCatalogRebuild: (scopeKey) => {
3812
- const updatedAtMs = now();
3813
- return dryRun(db, async (tx) => {
3814
- const rebuilt = await rebuildScope(tx, await catalogScope(tx, scopeKey), updatedAtMs);
3815
- return {
3816
- changed: rebuilt.changed,
3817
- currentCatalog: rebuilt.current,
3818
- diagnostics: rebuilt.diagnostics,
3819
- projectedCatalog: rebuilt.catalog
3820
- };
3821
- });
3822
- },
3823
- ensureChannel,
3824
- deleteChannel: (id) => deleteChannel(db, id),
3825
- updateBundle: (id, update) => db.transaction(async (tx) => {
3826
- const current = await tx.findOne("bundles", { id });
3827
- if (current === null) throw new DatabaseBundleNotFoundError(id);
3828
- const { patches, ...fields } = update;
3829
- const next = {
3830
- ...rowToBundle(toBundleRow(current)),
3831
- ...fields,
3832
- id
3833
- };
3834
- const { id: _id, ...set } = bundleToRow(next);
3835
- updateBundle(tx, current, set);
3836
- if (patches !== void 0) await replaceBundlePatches(tx, current, bundleToPatchRows({
3837
- ...next,
3838
- patches
3839
- }));
4309
+ strategy: "APP_VERSION"
4310
+ } : {
4311
+ channelId: channel.id,
4312
+ channelName: channel.name,
4313
+ fingerprintHash,
4314
+ platform,
4315
+ scopeKey: createReleaseCatalogScopeKey({
4316
+ channelKey,
4317
+ fingerprintHash,
4318
+ platform,
4319
+ strategy: "FINGERPRINT"
3840
4320
  }),
3841
- deleteBundles: async (ids) => {
3842
- try {
3843
- await db.transaction(async (tx) => {
3844
- for (const id of new Set(ids)) {
3845
- const current = await tx.findOne("bundles", { id });
3846
- if (current !== null) await deleteBundle(tx, current);
3847
- }
3848
- });
3849
- } catch (error) {
3850
- if (error instanceof DatabaseConstraintError && error.reason === "referenced") throw new DatabaseRowReferencedError();
3851
- throw error;
3852
- }
4321
+ strategy: "FINGERPRINT"
4322
+ };
4323
+ };
4324
+ /** A release and its scope, checked against the revision the caller saw. */
4325
+ var loadTarget = async (tx, { releaseId, expectedRevision }) => {
4326
+ const row = await tx.findOne("releases", { id: releaseId });
4327
+ if (row === null) throw new ReleaseManagementError("RELEASE_NOT_FOUND", `Release "${releaseId}" was not found.`);
4328
+ const release = toReleaseRow(row);
4329
+ if (expectedRevision !== void 0 && expectedRevision !== release.revision) throw new ReleaseManagementError("VERSION_CONFLICT", `Release "${release.id}" is revision ${release.revision}; expected ${expectedRevision}.`);
4330
+ const catalog = await tx.findOne("release_catalogs", { scope_key: release.scope_key });
4331
+ if (catalog === null) throw new ReleaseCatalogMutationError("INVALID_SCOPE", `Release "${release.id}" has no catalog projection.`);
4332
+ return {
4333
+ release,
4334
+ scope: {
4335
+ channelId: release.channel_id,
4336
+ channelName: decodeChannelKey(catalog.channel_key),
4337
+ fingerprintHash: release.fingerprint_hash,
4338
+ platform: release.platform,
4339
+ scopeKey: release.scope_key,
4340
+ strategy: release.strategy
3853
4341
  }
3854
4342
  };
3855
4343
  };
3856
- /** A channel's id is `channel:` and its name's channel key, so ids share channel_key's bound. */
3857
- var CHANNEL_ID = {
3858
- type: "string",
3859
- maxLength: 1408,
3860
- ascii: true
4344
+ var policyUpdate = (release, patch, updatedAtMs) => {
4345
+ if (patch.fingerprintHash !== void 0 && patch.fingerprintHash !== release.fingerprint_hash) throw new ReleaseManagementError("SCOPE_MOVE_UNSUPPORTED", "Changing a fingerprint target requires an atomic Release scope move.");
4346
+ return {
4347
+ ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
4348
+ ...patch.message === void 0 ? {} : { message: patch.message },
4349
+ ...patch.rolloutCohortCount === void 0 ? {} : { rollout_cohort_count: patch.rolloutCohortCount },
4350
+ ...patch.shouldForceUpdate === void 0 ? {} : { should_force_update: patch.shouldForceUpdate },
4351
+ ...patch.targetAppVersion === void 0 ? {} : { target_app_version: patch.targetAppVersion },
4352
+ ...patch.targetCohorts === void 0 ? {} : { target_cohorts: [...patch.targetCohorts] },
4353
+ updated_at_ms: updatedAtMs
4354
+ };
3861
4355
  };
3862
- /** Core's module: always installed, never a plugin. */
3863
- var coreModule = {
3864
- id: "core",
3865
- schema: {
3866
- bundles: defineTable({
3867
- id: {
3868
- type: "string",
3869
- maxLength: 36
3870
- },
3871
- platform: {
3872
- type: "string",
3873
- maxLength: 16
3874
- },
3875
- git_commit_hash: {
3876
- type: "string",
3877
- maxLength: 255,
3878
- required: false
3879
- },
3880
- metadata: { type: "json" },
3881
- manifest_storage_uri: { type: "string" },
3882
- manifest_file_hash: { type: "string" },
3883
- asset_base_storage_uri: { type: "string" }
3884
- }, {
3885
- key: ["id"],
3886
- indexes: {
3887
- byPlatform: {
3888
- eq: ["platform"],
3889
- sort: ["id"]
3890
- },
3891
- all: {
3892
- eq: [],
3893
- sort: ["id"]
3894
- }
3895
- }
3896
- }),
3897
- bundle_patches: defineTable({
3898
- id: {
3899
- type: "string",
3900
- maxLength: 255
3901
- },
3902
- bundle_id: {
3903
- type: "string",
3904
- maxLength: 36,
3905
- references: {
3906
- model: "bundles",
3907
- onDelete: "cascade"
3908
- }
3909
- },
3910
- base_bundle_id: {
3911
- type: "string",
3912
- maxLength: 36,
3913
- references: {
3914
- model: "bundles",
3915
- onDelete: "cascade"
3916
- }
3917
- },
3918
- base_file_hash: { type: "string" },
3919
- patch_file_hash: { type: "string" },
3920
- patch_storage_uri: { type: "string" },
3921
- byte_size: { type: "integer" },
3922
- order_index: { type: "integer" }
3923
- }, {
3924
- key: ["id"],
3925
- indexes: {
3926
- pair: {
3927
- eq: ["bundle_id", "base_bundle_id"],
3928
- sort: [],
3929
- unique: true
3930
- },
3931
- byBundle: {
3932
- eq: ["bundle_id"],
3933
- sort: ["order_index"],
3934
- root: { model: "bundles" }
3935
- },
3936
- byBase: {
3937
- eq: ["base_bundle_id"],
3938
- sort: ["bundle_id"]
3939
- }
3940
- }
3941
- }),
3942
- releases: defineTable({
3943
- id: {
3944
- type: "string",
3945
- maxLength: 36
3946
- },
3947
- revision: { type: "integer" },
3948
- scope_key: {
3949
- type: "string",
3950
- maxLength: 2048,
3951
- ascii: true
3952
- },
3953
- channel_id: {
3954
- ...CHANNEL_ID,
3955
- references: {
3956
- model: "channels",
3957
- onDelete: "restrict"
3958
- }
3959
- },
3960
- platform: {
3961
- type: "string",
3962
- maxLength: 16
3963
- },
3964
- kind: {
3965
- type: "string",
3966
- maxLength: 16
3967
- },
3968
- bundle_id: {
3969
- type: "string",
3970
- maxLength: 36,
3971
- required: false,
3972
- references: {
3973
- model: "bundles",
3974
- onDelete: "restrict"
3975
- }
3976
- },
3977
- strategy: {
3978
- type: "string",
3979
- maxLength: 16
3980
- },
3981
- target_app_version: {
3982
- type: "string",
3983
- required: false
3984
- },
3985
- fingerprint_hash: {
3986
- type: "string",
3987
- maxLength: 255,
3988
- required: false
3989
- },
3990
- enabled: { type: "boolean" },
3991
- should_force_update: { type: "boolean" },
3992
- message: {
3993
- type: "string",
3994
- required: false
3995
- },
3996
- rollout_cohort_count: { type: "integer" },
3997
- target_cohorts: { type: "json" },
3998
- operation: {
3999
- type: "string",
4000
- maxLength: 16
4001
- },
4002
- source_release_id: {
4003
- type: "string",
4004
- maxLength: 36,
4005
- required: false,
4006
- references: {
4007
- model: "releases",
4008
- onDelete: "none"
4009
- }
4010
- },
4011
- created_at_ms: { type: "integer" },
4012
- updated_at_ms: { type: "integer" }
4013
- }, {
4014
- key: ["id"],
4015
- indexes: {
4016
- byScope: {
4017
- eq: ["scope_key"],
4018
- sort: ["id"],
4019
- root: { model: "release_catalogs" }
4020
- },
4021
- byScopeEnabled: {
4022
- eq: ["scope_key", "enabled"],
4023
- sort: ["id"],
4024
- root: { model: "release_catalogs" }
4025
- },
4026
- byChannelPlatform: {
4027
- eq: ["channel_id", "platform"],
4028
- sort: ["id"]
4029
- },
4030
- byChannelPlatformEnabled: {
4031
- eq: [
4032
- "channel_id",
4033
- "platform",
4034
- "enabled"
4035
- ],
4036
- sort: ["id"]
4356
+ var mutationResult = ({ catalog, release }) => ({
4357
+ attempts: 1,
4358
+ catalog,
4359
+ release
4360
+ });
4361
+ /** The scope a catalog row names, for a rebuild. */
4362
+ var catalogScope = async (tx, scopeKey) => {
4363
+ const row = await tx.findOne("release_catalogs", { scope_key: scopeKey });
4364
+ if (row === null) throw new Error(`Release catalog "${scopeKey}" was not found.`);
4365
+ return {
4366
+ channelId: row.channel_id,
4367
+ channelName: decodeChannelKey(row.channel_key),
4368
+ fingerprintHash: row.fingerprint_hash,
4369
+ platform: row.platform,
4370
+ scopeKey: row.scope_key,
4371
+ strategy: row.strategy
4372
+ };
4373
+ };
4374
+ /**
4375
+ * Core's typed writes: each runs in one engine transaction rooted at the
4376
+ * catalogs it changes, so a concurrent change reruns it instead of
4377
+ * publishing a stale catalog.
4378
+ */
4379
+ var createCoreOperations = (db, { now = Date.now } = {}) => {
4380
+ const ensureChannel = async (name) => (await insertChannel(db, {
4381
+ id: `channel:${encodeChannelKey(name)}`,
4382
+ name
4383
+ })).row;
4384
+ /**
4385
+ * Runs a write that ensured its channels first. A channel deleted between
4386
+ * that and the write refuses the write, so it runs again and ensures it anew.
4387
+ */
4388
+ const withChannels = async (write) => {
4389
+ for (let attempt = 1;; attempt += 1) try {
4390
+ return await write();
4391
+ } catch (error) {
4392
+ if (attempt < 3 && error instanceof DatabaseConstraintError && error.reason === "not_found" && error.model === "channels") continue;
4393
+ throw error;
4394
+ }
4395
+ };
4396
+ const deployOnce = async (deployments) => {
4397
+ const channels = /* @__PURE__ */ new Map();
4398
+ for (const { release } of deployments) if (!channels.has(release.channel)) channels.set(release.channel, await ensureChannel(release.channel));
4399
+ const platforms = /* @__PURE__ */ new Map();
4400
+ for (const deployment of deployments) {
4401
+ if (!("bundleId" in deployment) || platforms.has(deployment.bundleId)) continue;
4402
+ const row = await db.findOne("bundles", { id: deployment.bundleId });
4403
+ if (row === null) throw new DatabaseBundleNotFoundError(deployment.bundleId);
4404
+ platforms.set(deployment.bundleId, toBundleRow(row).platform);
4405
+ }
4406
+ const updatedAtMs = now();
4407
+ return (await changeReleases(db, deployments.map((deployment) => {
4408
+ const { release: policy } = deployment;
4409
+ const stored = "bundleId" in deployment;
4410
+ const bundleId = stored ? deployment.bundleId : deployment.bundle.id;
4411
+ const platform = stored ? platforms.get(deployment.bundleId) : deployment.bundle.platform;
4412
+ const scope = scopeOf(channels.get(policy.channel), platform, policy.fingerprintHash);
4413
+ return {
4414
+ scope,
4415
+ ...stored ? {} : { bundle: {
4416
+ row: bundleToRow(deployment.bundle),
4417
+ patches: bundleToPatchRows(deployment.bundle)
4418
+ } },
4419
+ change: {
4420
+ operation: "insert",
4421
+ row: {
4422
+ bundle_id: bundleId,
4423
+ channel_id: scope.channelId,
4424
+ created_at_ms: updatedAtMs,
4425
+ enabled: policy.enabled,
4426
+ fingerprint_hash: policy.fingerprintHash,
4427
+ kind: "BUNDLE",
4428
+ message: policy.message,
4429
+ operation: "DEPLOY",
4430
+ platform,
4431
+ revision: 1,
4432
+ rollout_cohort_count: policy.rolloutCohortCount ?? 1e3,
4433
+ scope_key: scope.scopeKey,
4434
+ should_force_update: policy.shouldForceUpdate,
4435
+ source_release_id: null,
4436
+ strategy: scope.strategy,
4437
+ target_app_version: policy.targetAppVersion,
4438
+ target_cohorts: policy.targetCohorts ?? [],
4439
+ updated_at_ms: updatedAtMs
4440
+ }
4037
4441
  },
4038
- byBundle: {
4039
- eq: ["bundle_id"],
4040
- sort: ["id"]
4442
+ updatedAtMs
4443
+ };
4444
+ }))).map(mutationResult);
4445
+ };
4446
+ return {
4447
+ deploy: (deployments) => withChannels(() => deployOnce(deployments)),
4448
+ updateReleasePolicy: (input) => {
4449
+ const updatedAtMs = now();
4450
+ return db.transaction(async (tx) => {
4451
+ const { release, scope } = await loadTarget(tx, input);
4452
+ return mutationResult(await changeRelease(tx, {
4453
+ scope,
4454
+ change: {
4455
+ operation: "update",
4456
+ id: release.id,
4457
+ update: policyUpdate(release, input.patch, updatedAtMs)
4458
+ },
4459
+ updatedAtMs
4460
+ }));
4461
+ });
4462
+ },
4463
+ preflightReleasePolicy: (input) => {
4464
+ const updatedAtMs = now();
4465
+ return dryRun(db, async (tx) => {
4466
+ const { release, scope } = await loadTarget(tx, input);
4467
+ const stored = await readCatalog(tx, scope.scopeKey);
4468
+ const changed = await changeRelease(tx, {
4469
+ scope,
4470
+ change: {
4471
+ operation: "update",
4472
+ id: release.id,
4473
+ update: policyUpdate(release, input.patch, updatedAtMs)
4474
+ },
4475
+ updatedAtMs
4476
+ });
4477
+ return {
4478
+ catalog: changed.catalog,
4479
+ currentCatalog: stored.catalog,
4480
+ diagnostics: changed.diagnostics,
4481
+ expectedReleaseRevision: release.revision,
4482
+ release: changed.release
4483
+ };
4484
+ });
4485
+ },
4486
+ deleteRelease: (input) => db.transaction(async (tx) => {
4487
+ const { release, scope } = await loadTarget(tx, input);
4488
+ if (release.enabled) throw new ReleaseManagementError("ENABLED_RELEASE", `Disable Release "${release.id}" before hard deletion.`);
4489
+ const bundle = release.bundle_id === null ? null : await tx.findOne("bundles", { id: release.bundle_id });
4490
+ const result = mutationResult(await changeRelease(tx, {
4491
+ scope,
4492
+ change: {
4493
+ operation: "delete",
4494
+ id: release.id
4041
4495
  },
4042
- all: {
4043
- eq: [],
4044
- sort: ["id"]
4045
- }
4046
- }
4496
+ updatedAtMs: now()
4497
+ }));
4498
+ if (bundle !== null && (bundle._refs_releases_bundle_id ?? 0) <= 1) await deleteBundle(tx, bundle);
4499
+ return result;
4047
4500
  }),
4048
- release_catalogs: defineTable({
4049
- scope_key: {
4050
- type: "string",
4051
- maxLength: 2048,
4052
- ascii: true
4053
- },
4054
- catalog_id: {
4055
- type: "string",
4056
- maxLength: 255
4057
- },
4058
- strategy: {
4059
- type: "string",
4060
- maxLength: 16
4061
- },
4062
- channel_id: {
4063
- ...CHANNEL_ID,
4064
- references: {
4065
- model: "channels",
4066
- onDelete: "none"
4067
- }
4068
- },
4069
- channel_key: {
4070
- type: "string",
4071
- maxLength: 1400,
4072
- ascii: true
4073
- },
4074
- platform: {
4075
- type: "string",
4076
- maxLength: 16
4077
- },
4078
- fingerprint_hash: {
4079
- type: "string",
4080
- maxLength: 255,
4081
- required: false
4082
- },
4083
- generation: { type: "integer" },
4084
- payload: { type: "string" },
4085
- catalog_hash: {
4086
- type: "string",
4087
- maxLength: 71
4088
- },
4089
- byte_size: { type: "integer" },
4090
- is_tombstone: { type: "boolean" },
4091
- updated_at_ms: { type: "integer" }
4092
- }, {
4093
- key: ["scope_key"],
4094
- indexes: { all: {
4095
- eq: [],
4096
- sort: ["scope_key"]
4097
- } }
4501
+ promoteRelease: (input) => withChannels(async () => {
4502
+ const checkSource = (source) => {
4503
+ if (source.kind !== "BUNDLE" || source.bundle_id === null) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", "Only a Bundle Release can be promoted.");
4504
+ };
4505
+ checkSource((await db.transaction((tx) => loadTarget(tx, input))).release);
4506
+ const targetChannel = input.targetChannel.trim();
4507
+ if (targetChannel.length === 0) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", "Promotion requires a target channel.");
4508
+ const channel = await ensureChannel(targetChannel);
4509
+ const updatedAtMs = now();
4510
+ return db.transaction(async (tx) => {
4511
+ const { release: source, scope: sourceScope } = await loadTarget(tx, input);
4512
+ checkSource(source);
4513
+ const targetScope = scopeOf(channel, source.platform, source.fingerprint_hash);
4514
+ if (targetScope.scopeKey === sourceScope.scopeKey) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", "Source and target Release scopes are the same.");
4515
+ const { rows } = await tx.findMany("releases", {
4516
+ index: "byScope",
4517
+ where: { scope_key: targetScope.scopeKey },
4518
+ order: "desc",
4519
+ limit: 1
4520
+ });
4521
+ const floor = [
4522
+ rows[0]?.id ?? null,
4523
+ source.id,
4524
+ source.bundle_id
4525
+ ].filter((id) => id !== null && isUUIDv7(id)).sort().at(-1);
4526
+ const moved = input.action === "move" ? await changeRelease(tx, {
4527
+ scope: sourceScope,
4528
+ change: {
4529
+ operation: "update",
4530
+ id: source.id,
4531
+ update: {
4532
+ enabled: false,
4533
+ updated_at_ms: updatedAtMs
4534
+ }
4535
+ },
4536
+ updatedAtMs
4537
+ }) : null;
4538
+ const target = await changeRelease(tx, {
4539
+ scope: targetScope,
4540
+ change: {
4541
+ operation: "insert",
4542
+ row: {
4543
+ ...source,
4544
+ id: createUUIDv7After(floor ?? null, updatedAtMs),
4545
+ revision: 1,
4546
+ scope_key: targetScope.scopeKey,
4547
+ channel_id: targetScope.channelId,
4548
+ enabled: true,
4549
+ rollout_cohort_count: 1e3,
4550
+ target_cohorts: [],
4551
+ operation: "PROMOTE",
4552
+ source_release_id: source.id,
4553
+ created_at_ms: updatedAtMs,
4554
+ updated_at_ms: updatedAtMs
4555
+ }
4556
+ },
4557
+ updatedAtMs
4558
+ });
4559
+ return {
4560
+ source: moved === null ? null : mutationResult(moved),
4561
+ target: mutationResult(target)
4562
+ };
4563
+ });
4564
+ }),
4565
+ rebuildReleaseCatalog: (scopeKey) => {
4566
+ const updatedAtMs = now();
4567
+ return db.transaction(async (tx) => {
4568
+ const { current: _current, ...rebuilt } = await rebuildScope(tx, await catalogScope(tx, scopeKey), updatedAtMs);
4569
+ return {
4570
+ attempts: 1,
4571
+ ...rebuilt
4572
+ };
4573
+ });
4574
+ },
4575
+ preflightReleaseCatalogRebuild: (scopeKey) => {
4576
+ const updatedAtMs = now();
4577
+ return dryRun(db, async (tx) => {
4578
+ const rebuilt = await rebuildScope(tx, await catalogScope(tx, scopeKey), updatedAtMs);
4579
+ return {
4580
+ changed: rebuilt.changed,
4581
+ currentCatalog: rebuilt.current,
4582
+ diagnostics: rebuilt.diagnostics,
4583
+ projectedCatalog: rebuilt.catalog
4584
+ };
4585
+ });
4586
+ },
4587
+ ensureChannel,
4588
+ deleteChannel: (id) => deleteChannel(db, id),
4589
+ updateBundle: (id, update) => db.transaction(async (tx) => {
4590
+ const current = await tx.findOne("bundles", { id });
4591
+ if (current === null) throw new DatabaseBundleNotFoundError(id);
4592
+ const { patches, ...fields } = update;
4593
+ const next = {
4594
+ ...rowToBundle(toBundleRow(current)),
4595
+ ...fields,
4596
+ id
4597
+ };
4598
+ const { id: _id, ...set } = bundleToRow(next);
4599
+ updateBundle(tx, current, set);
4600
+ if (patches !== void 0) await replaceBundlePatches(tx, current, bundleToPatchRows({
4601
+ ...next,
4602
+ patches
4603
+ }));
4098
4604
  }),
4099
- channels: defineTable({
4100
- id: CHANNEL_ID,
4101
- name: {
4102
- type: "string",
4103
- maxLength: 255,
4104
- unique: true
4605
+ deleteBundles: async (ids) => {
4606
+ try {
4607
+ await db.transaction(async (tx) => {
4608
+ for (const id of new Set(ids)) {
4609
+ const current = await tx.findOne("bundles", { id });
4610
+ if (current !== null) await deleteBundle(tx, current);
4611
+ }
4612
+ });
4613
+ } catch (error) {
4614
+ if (error instanceof DatabaseConstraintError && error.reason === "referenced") throw new DatabaseRowReferencedError();
4615
+ throw error;
4105
4616
  }
4106
- }, {
4107
- key: ["id"],
4108
- indexes: { all: {
4109
- eq: [],
4110
- sort: ["name"]
4111
- } }
4112
- }),
4113
- bundle_totals: defineAggregate({ platform_key: {
4114
- type: "string",
4115
- maxLength: 16
4116
- } }, {
4117
- key: ["platform_key"],
4118
- counters: ["bundles"],
4119
- indexes: { byPlatform: {
4120
- eq: ["platform_key"],
4121
- sort: []
4122
- } }
4123
- })
4124
- }
4617
+ }
4618
+ };
4125
4619
  };
4126
4620
  /**
4127
4621
  * The update check answers from one Release Catalog row, so a transaction
@@ -4168,14 +4662,911 @@ var createCoreApi = (db, storage, options = {}) => {
4168
4662
  }
4169
4663
  };
4170
4664
  };
4665
+ var PLUGIN_KEYS = /* @__PURE__ */ new Set([
4666
+ "id",
4667
+ "provides",
4668
+ "schemaVersion",
4669
+ "schema",
4670
+ "namespace",
4671
+ "init",
4672
+ "cli"
4673
+ ]);
4674
+ var CLI_KEYS = /* @__PURE__ */ new Set(["clientCredential", "clientPlugin"]);
4675
+ var INSTANCE_KEYS = /* @__PURE__ */ new Set([
4676
+ "api",
4677
+ "endpoints",
4678
+ "clientAuth"
4679
+ ]);
4680
+ var METHODS = /* @__PURE__ */ new Set([
4681
+ "GET",
4682
+ "POST",
4683
+ "PUT",
4684
+ "PATCH",
4685
+ "DELETE"
4686
+ ]);
4687
+ /** An id that prefixes table names is a SQL name; one that does not may use camelCase. */
4688
+ var PREFIX_ID = /^[a-z][a-z0-9_]*$/u;
4689
+ var UNPREFIXED_ID = /^[a-z][A-Za-z0-9_]*$/u;
4690
+ var PATH = /^(\/(:?[A-Za-z0-9_.~-]+))+$/u;
4691
+ var fail = (message) => {
4692
+ throw new HotUpdaterConfigError(message);
4693
+ };
4694
+ var isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4695
+ var checkPlugin = (value, at) => {
4696
+ if (!isRecord$2(value)) return fail(`${at} is not a plugin.`);
4697
+ if ("kind" in value) fail(`${at} has a kind; core is built in and is never passed as a plugin.`);
4698
+ const unknown = Object.keys(value).find((key) => !PLUGIN_KEYS.has(key));
4699
+ if (unknown !== void 0) fail(`${at} has an unknown key "${unknown}".`);
4700
+ const plugin = value;
4701
+ if (plugin.namespace !== void 0 && plugin.namespace !== false) fail(`${at} may only set namespace: false.`);
4702
+ const pattern = plugin.namespace === false ? UNPREFIXED_ID : PREFIX_ID;
4703
+ if (typeof plugin.id !== "string" || !pattern.test(plugin.id)) fail(`${at} needs an id matching ${pattern.source}.`);
4704
+ if (plugin.id === "core") fail(`${at} uses the id "core", which is core's own.`);
4705
+ checkReservedId(value, at);
4706
+ if (typeof plugin.schemaVersion !== "string" || !isRecord$2(plugin.schema) || typeof plugin.init !== "function") fail(`Plugin "${plugin.id}" needs a schemaVersion, a schema, and init.`);
4707
+ const provides = plugin.provides;
4708
+ if (provides !== void 0 && (!isRecord$2(provides) || Object.entries(provides).some(([key, flag]) => key !== "clientAuth" || flag !== true))) fail(`Plugin "${plugin.id}" may only declare provides: { clientAuth: true }.`);
4709
+ const cli = value.cli;
4710
+ if (cli !== void 0) {
4711
+ if (!isRecord$2(cli) || Object.keys(cli).some((key) => !CLI_KEYS.has(key))) fail(`Plugin "${plugin.id}" cli may only hold clientCredential and clientPlugin.`);
4712
+ if (cli.clientCredential !== void 0 && plugin.provides?.clientAuth !== true) fail(`Plugin "${plugin.id}" adds cli.clientCredential without declaring provides: { clientAuth: true }.`);
4713
+ }
4714
+ return plugin;
4715
+ };
4716
+ var checkEndpoint = (id, value) => {
4717
+ const endpoint = isRecord$2(value) ? value : {};
4718
+ if (!METHODS.has(endpoint.method) || typeof endpoint.path !== "string" || !PATH.test(endpoint.path) || endpoint.access !== "client" && endpoint.access !== "admin" || typeof endpoint.handler !== "function") fail(`Plugin "${id}" has an endpoint that needs a method, a path like /name/:param, access "client" or "admin", and a handler.`);
4719
+ return {
4720
+ ...endpoint,
4721
+ plugin: id
4722
+ };
4723
+ };
4724
+ var checkInstance = (plugin, instance) => {
4725
+ const { id } = plugin;
4726
+ if (!isRecord$2(instance)) return fail(`Plugin "${id}" init returned no instance.`);
4727
+ if (typeof instance.then === "function") fail(`Plugin "${id}" init returned a promise; init must be synchronous.`);
4728
+ const unknown = Object.keys(instance).find((key) => !INSTANCE_KEYS.has(key));
4729
+ if (unknown !== void 0 || !("api" in instance)) fail(`Plugin "${id}" init must return { api, endpoints?, clientAuth? }${unknown === void 0 ? "" : `, not "${unknown}"`}.`);
4730
+ const declared = plugin.provides?.clientAuth === true;
4731
+ const clientAuth = instance.clientAuth;
4732
+ if (declared !== (clientAuth !== void 0)) fail(declared ? `Plugin "${id}" declares provides: { clientAuth: true } but its instance has no clientAuth.` : `Plugin "${id}" returns clientAuth without declaring provides: { clientAuth: true }.`);
4733
+ if (clientAuth !== void 0 && (typeof clientAuth.authenticate !== "function" || !Array.isArray(clientAuth.varyHeaders) || !clientAuth.varyHeaders.every((header) => typeof header === "string"))) fail(`Plugin "${id}" clientAuth needs varyHeaders and authenticate.`);
4734
+ const endpoints = instance.endpoints ?? [];
4735
+ if (!Array.isArray(endpoints)) fail(`Plugin "${id}" endpoints must be an array.`);
4736
+ return {
4737
+ api: instance.api,
4738
+ clientAuth,
4739
+ endpoints: endpoints.map((endpoint) => checkEndpoint(id, endpoint))
4740
+ };
4741
+ };
4742
+ /**
4743
+ * Checks every plugin, runs each `init` once against one engine over the
4744
+ * database, and collects APIs, endpoints, and clientAuth. Core's reads run
4745
+ * on the same engine, so plugins read core through `ctx.core`.
4746
+ */
4747
+ var assemblePlugins = (value, database, { now = Date.now, storage = { resolveFileUrl: async () => null } } = {}) => {
4748
+ if (!Array.isArray(value)) return fail("plugins must be an array of plugins.");
4749
+ const plugins = value.map((plugin, position) => checkPlugin(plugin, `plugins[${position}]`));
4750
+ const ids = /* @__PURE__ */ new Set();
4751
+ for (const { id } of plugins) {
4752
+ if (ids.has(id)) fail(`Plugin "${id}" is registered twice.`);
4753
+ ids.add(id);
4754
+ }
4755
+ clientPluginsOf(plugins);
4756
+ const engine = createEngine(database, {
4757
+ plugins,
4758
+ now
4759
+ });
4760
+ const { onCachedRoutesChange } = database;
4761
+ const core = createCoreApi(engine.core, storage, {
4762
+ now,
4763
+ ...onCachedRoutesChange === void 0 ? {} : { onCachedRoutesChange: () => onCachedRoutesChange.call(database) }
4764
+ });
4765
+ const reads = createCoreReads(engine.core, storage);
4766
+ const api = {};
4767
+ const endpoints = [];
4768
+ let clientAuth;
4769
+ for (const plugin of plugins) {
4770
+ const instance = checkInstance(plugin, plugin.init({
4771
+ db: engine.database(plugin),
4772
+ core: reads,
4773
+ now
4774
+ }));
4775
+ if (instance.clientAuth !== void 0) {
4776
+ if (clientAuth !== void 0) fail(`Plugins "${clientAuth.plugin}" and "${plugin.id}" both provide clientAuth; keep one.`);
4777
+ const provided = instance.clientAuth;
4778
+ clientAuth = {
4779
+ plugin: plugin.id,
4780
+ varyHeaders: provided.varyHeaders,
4781
+ authenticate: (headers) => provided.authenticate(headers)
4782
+ };
4783
+ }
4784
+ endpoints.push(...instance.endpoints);
4785
+ api[plugin.id] = instance.api;
4786
+ }
4787
+ return {
4788
+ core,
4789
+ api,
4790
+ endpoints,
4791
+ ...clientAuth === void 0 ? {} : { clientAuth },
4792
+ flush: engine.flush
4793
+ };
4794
+ };
4795
+ var HandlerBadRequestError = class extends Error {
4796
+ name = "HandlerBadRequestError";
4797
+ constructor(message) {
4798
+ super(message);
4799
+ }
4800
+ };
4801
+ var decodeMaybe = (value) => {
4802
+ if (value === void 0) return void 0;
4803
+ try {
4804
+ return decodeURIComponent(value);
4805
+ } catch {
4806
+ return value;
4807
+ }
4808
+ };
4809
+ var isPlatform = (value) => value === "ios" || value === "android";
4810
+ var requireRouteParam = (params, key) => {
4811
+ const value = params[key];
4812
+ if (!value) throw new HandlerBadRequestError(`Missing route parameter: ${key}`);
4813
+ return value;
4814
+ };
4815
+ var requirePlatformParam = (params) => {
4816
+ const platform = requireRouteParam(params, "platform");
4817
+ if (!isPlatform(platform)) throw new HandlerBadRequestError(`Invalid platform: ${platform}. Expected 'ios' or 'android'.`);
4818
+ return platform;
4819
+ };
4820
+ var parseBooleanSearchParam = (url, key) => {
4821
+ const value = url.searchParams.get(key);
4822
+ if (value === null) return void 0;
4823
+ if (value === "true") return true;
4824
+ if (value === "false") return false;
4825
+ throw new HandlerBadRequestError(`The '${key}' query parameter must be 'true' or 'false'.`);
4826
+ };
4827
+ var json = (body, status = 200) => Response.json(body, { status });
4828
+ var notFound = () => json({ error: "Not found" }, 404);
4829
+ var noContent = () => new Response(null, { status: 204 });
4830
+ /** Runs `route` on core, answering its refusals. */
4831
+ var onCore = (route) => async (params, request, api) => answer(() => route(api.core, request, params));
4832
+ /** Core's refusals as HTTP answers; anything else is a server error. */
4833
+ var answer = async (operation) => {
4834
+ try {
4835
+ return await operation();
4836
+ } catch (error) {
4837
+ if (error instanceof ReleaseManagementError || error instanceof ReleaseCatalogMutationError) {
4838
+ const status = error.code === "RELEASE_NOT_FOUND" ? 404 : error.code === "VERSION_CONFLICT" ? 409 : 400;
4839
+ return json({
4840
+ code: error.code,
4841
+ error: error.message
4842
+ }, status);
4843
+ }
4844
+ if (error instanceof DatabaseBundleNotFoundError) return json({
4845
+ code: "BUNDLE_NOT_FOUND",
4846
+ error: error.message
4847
+ }, 404);
4848
+ if (error instanceof DatabaseRowReferencedError) return json({
4849
+ code: "REFERENCED",
4850
+ error: error.message
4851
+ }, 409);
4852
+ throw error;
4853
+ }
4854
+ };
4855
+ var LIMITS = {
4856
+ defaultValue: 50,
4857
+ maxValue: 500
4858
+ };
4859
+ /** `order`, `cursor`, and `limit`: one keyset page. */
4860
+ var keyset = (url, maxValue = LIMITS.maxValue) => {
4861
+ const order = url.searchParams.get("order") ?? "desc";
4862
+ if (order !== "asc" && order !== "desc") throw new HandlerBadRequestError("order must be 'asc' or 'desc'.");
4863
+ const limitParam = url.searchParams.get("limit");
4864
+ const limit = limitParam === null ? Math.min(LIMITS.defaultValue, maxValue) : Number(limitParam);
4865
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxValue) throw new HandlerBadRequestError(`limit must be between 1 and ${maxValue}.`);
4866
+ const cursor = url.searchParams.get("cursor");
4867
+ return {
4868
+ order,
4869
+ limit,
4870
+ ...cursor === null ? {} : { after: cursor }
4871
+ };
4872
+ };
4873
+ /** A page and the cursor of the next one, when the page is full. */
4874
+ var page = (rows, { limit }, keyOf, extra = {}) => json({
4875
+ data: rows,
4876
+ ...rows.length === limit && rows.length > 0 ? { next: keyOf(rows.at(-1)) } : {},
4877
+ ...extra
4878
+ });
4879
+ var platformOf = (url) => {
4880
+ const platform = url.searchParams.get("platform");
4881
+ if (platform === null) return void 0;
4882
+ if (!isPlatform(platform)) throw new HandlerBadRequestError(`Invalid platform: ${platform}. Expected 'ios' or 'android'.`);
4883
+ return platform;
4884
+ };
4885
+ /** The one filter set a release list names; any other mix of filters is refused. */
4886
+ var releaseFilter = (url) => {
4887
+ const params = url.searchParams;
4888
+ const named = [
4889
+ "channelId",
4890
+ "platform",
4891
+ "bundleId",
4892
+ "scopeKey"
4893
+ ].filter((name) => params.has(name));
4894
+ const enabled = parseBooleanSearchParam(url, "enabled");
4895
+ const withEnabled = enabled === void 0 ? {} : { enabled };
4896
+ const set = named.join(",");
4897
+ if (set === "" && enabled === void 0) return { kind: "all" };
4898
+ if (set === "channelId,platform") {
4899
+ const platform = platformOf(url);
4900
+ return {
4901
+ kind: "channelPlatform",
4902
+ channelId: params.get("channelId"),
4903
+ platform,
4904
+ ...withEnabled
4905
+ };
4906
+ }
4907
+ if (set === "bundleId" && enabled === void 0) return {
4908
+ kind: "bundle",
4909
+ bundleId: params.get("bundleId")
4910
+ };
4911
+ if (set === "scopeKey") return {
4912
+ kind: "scope",
4913
+ scopeKey: params.get("scopeKey"),
4914
+ ...withEnabled
4915
+ };
4916
+ throw new HandlerBadRequestError("Releases filter by none, channelId and platform (with enabled), bundleId, or scopeKey (with enabled).");
4917
+ };
4918
+ var isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4919
+ var body = async (request) => {
4920
+ const value = await request.json();
4921
+ if (!isRecord$1(value)) throw new HandlerBadRequestError("Invalid body");
4922
+ return value;
4923
+ };
4924
+ var isText = (value) => typeof value === "string" && value.length > 0;
4925
+ var isTextOrNull = (value) => value === null || isText(value);
4926
+ var isBundle = (bundle) => isRecord$1(bundle) && isText(bundle.id) && typeof bundle.platform === "string" && isPlatform(bundle.platform) && isTextOrNull(bundle.gitCommitHash) && isText(bundle.manifestStorageUri) && isText(bundle.manifestFileHash) && isText(bundle.assetBaseStorageUri) && (bundle.metadata === void 0 || isRecord$1(bundle.metadata)) && (bundle.patches === void 0 || bundle.patches === null || Array.isArray(bundle.patches));
4927
+ var isReleasePolicy = (release) => isRecord$1(release) && isText(release.channel) && typeof release.enabled === "boolean" && typeof release.shouldForceUpdate === "boolean" && isTextOrNull(release.fingerprintHash) && isTextOrNull(release.targetAppVersion) && (release.message === null || typeof release.message === "string") && (release.rolloutCohortCount === void 0 || Number.isSafeInteger(release.rolloutCohortCount) && release.rolloutCohortCount >= 0 && release.rolloutCohortCount <= 1e3) && (release.targetCohorts === void 0 || Array.isArray(release.targetCohorts) && release.targetCohorts.every(isText));
4928
+ /** A deployment's shape: a new bundle or a stored bundle's id, and its release policy. */
4929
+ var isDeployment = (value) => isRecord$1(value) && isReleasePolicy(value.release) && ("bundleId" in value ? isText(value.bundleId) && !("bundle" in value) : isBundle(value.bundle));
4930
+ /** An expected release revision from a body number or a query string. */
4931
+ var revisionOf = (value) => {
4932
+ if (value === void 0 || value === null || value === "") return void 0;
4933
+ const revision = typeof value === "string" ? Number(value) : value;
4934
+ if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) throw new HandlerBadRequestError("Invalid expected Release revision");
4935
+ return revision;
4936
+ };
4937
+ /** A policy change's body: `{ expectedRevision?, patch }`. */
4938
+ var policyInput = async (request) => {
4939
+ const input = await body(request);
4940
+ if (!isRecord$1(input.patch)) throw new HandlerBadRequestError("Invalid Release policy mutation");
4941
+ const expectedRevision = revisionOf(input.expectedRevision);
4942
+ return {
4943
+ patch: input.patch,
4944
+ ...expectedRevision === void 0 ? {} : { expectedRevision }
4945
+ };
4946
+ };
4947
+ /** A bundle update's body: the fields it changes, never another bundle's id. */
4948
+ var bundleUpdateOf = (value, bundleId) => {
4949
+ if (!isRecord$1(value)) throw new HandlerBadRequestError("Invalid bundle payload");
4950
+ const { id, ...update } = value;
4951
+ if (id !== void 0 && id !== bundleId) throw new HandlerBadRequestError("Bundle id mismatch");
4952
+ return update;
4953
+ };
4954
+ var scopeKeyOf = (params) => decodeMaybe(requireRouteParam(params, "scopeKey")) ?? "";
4955
+ /** Every admin route's handler, by the name `ADMIN_ROUTES` mounts it under. */
4956
+ var createAdminRouteHandlers = () => ({
4957
+ listBundles: onCore(async (core, request) => {
4958
+ const url = new URL(request.url);
4959
+ const input = keyset(url, 100);
4960
+ const platform = platformOf(url);
4961
+ const withTotal = parseBooleanSearchParam(url, "total") === true;
4962
+ const [rows, total] = await Promise.all([core.listBundles({
4963
+ ...input,
4964
+ ...platform ? { platform } : {}
4965
+ }), withTotal ? core.countBundles(platform) : void 0]);
4966
+ return page(rows, input, (row) => row.bundle.id, total === void 0 ? {} : { total });
4967
+ }),
4968
+ getBundle: onCore(async (core, _request, params) => {
4969
+ const detail = await core.getBundle(requireRouteParam(params, "id"));
4970
+ return detail === null ? notFound() : json({ data: detail });
4971
+ }),
4972
+ listReleases: onCore(async (core, request) => {
4973
+ const url = new URL(request.url);
4974
+ const input = keyset(url);
4975
+ return page(await core.listReleases({
4976
+ ...input,
4977
+ filter: releaseFilter(url)
4978
+ }), input, (row) => row.id);
4979
+ }),
4980
+ listReleaseCatalogs: onCore(async (core, request) => {
4981
+ const input = keyset(new URL(request.url));
4982
+ return page(await core.listReleaseCatalogs(input), input, (row) => row.scope_key);
4983
+ }),
4984
+ getChannels: onCore(async (core, request) => {
4985
+ const name = new URL(request.url).searchParams.get("name");
4986
+ if (name === null) return json({ data: await core.listChannels() });
4987
+ const channel = await core.findChannelByName(name);
4988
+ return channel === null ? notFound() : json({ data: channel });
4989
+ }),
4990
+ createChannel: onCore(async (core, request) => {
4991
+ const { name } = await body(request);
4992
+ if (typeof name !== "string" || name.trim().length === 0) throw new HandlerBadRequestError("Invalid channel name");
4993
+ return json({ data: await core.ensureChannel(name) });
4994
+ }),
4995
+ deleteChannel: onCore(async (core, _request, params) => {
4996
+ const result = await core.deleteChannel(decodeMaybe(requireRouteParam(params, "id")) ?? "");
4997
+ if (result.deleted) return noContent();
4998
+ return json({ data: result }, result.reason === "not_found" ? 404 : 409);
4999
+ }),
5000
+ getRelease: onCore(async (core, _request, params) => {
5001
+ const row = await core.getRelease(requireRouteParam(params, "id"));
5002
+ return row === null ? notFound() : json({ data: row });
5003
+ }),
5004
+ updateRelease: onCore(async (core, request, params) => json({ data: await core.updateReleasePolicy({
5005
+ ...await policyInput(request),
5006
+ releaseId: requireRouteParam(params, "id")
5007
+ }) })),
5008
+ preflightRelease: onCore(async (core, request, params) => json({ data: await core.preflightReleasePolicy({
5009
+ ...await policyInput(request),
5010
+ releaseId: requireRouteParam(params, "id")
5011
+ }) })),
5012
+ deleteRelease: onCore(async (core, request, params) => {
5013
+ const releaseId = requireRouteParam(params, "id");
5014
+ const url = new URL(request.url);
5015
+ if (url.searchParams.get("confirm") !== releaseId) throw new HandlerBadRequestError("Release hard deletion requires confirm=<release-id>");
5016
+ const expectedRevision = revisionOf(url.searchParams.get("expectedRevision"));
5017
+ return json({ data: await core.deleteRelease({
5018
+ releaseId,
5019
+ ...expectedRevision === void 0 ? {} : { expectedRevision }
5020
+ }) });
5021
+ }),
5022
+ getReleaseCatalogRow: onCore(async (core, _request, params) => {
5023
+ const row = await core.getReleaseCatalogRow(scopeKeyOf(params));
5024
+ return row === null ? notFound() : json({ data: row });
5025
+ }),
5026
+ rebuildReleaseCatalog: onCore(async (core, _request, params) => json({ data: await core.rebuildReleaseCatalog(scopeKeyOf(params)) })),
5027
+ updateBundle: onCore(async (core, request, params) => {
5028
+ const bundleId = requireRouteParam(params, "id");
5029
+ await core.updateBundle(bundleId, bundleUpdateOf(await request.json(), bundleId));
5030
+ return noContent();
5031
+ }),
5032
+ countBundles: onCore(async (core, request) => json({ data: await core.countBundles(platformOf(new URL(request.url))) })),
5033
+ countBundleChildren: onCore(async (core, request) => {
5034
+ const ids = (new URL(request.url).searchParams.get("ids") ?? "").split(",").filter((id) => id.length > 0);
5035
+ if (ids.length === 0 || ids.length > 100) throw new HandlerBadRequestError("Bundle child counts take ids=<bundle id>,... with 1 to 100 ids.");
5036
+ return json({ data: await core.countBundleChildren(ids) });
5037
+ }),
5038
+ listBundleChildren: onCore(async (core, request, params) => {
5039
+ const input = keyset(new URL(request.url));
5040
+ return page(await core.listPatchesFromBase(requireRouteParam(params, "id"), input), input, (row) => row.bundle_id);
5041
+ }),
5042
+ findBaseCandidates: onCore(async (core, request, params) => {
5043
+ const url = new URL(request.url);
5044
+ const before = url.searchParams.get("before");
5045
+ const limit = Number(url.searchParams.get("limit") ?? "3");
5046
+ if (before === null || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new HandlerBadRequestError("Base candidates need before=<bundle id> and a limit between 1 and 100.");
5047
+ return json({ data: await core.findBaseBundleIds(decodeMaybe(requireRouteParam(params, "candidateKey")) ?? "", before, limit) });
5048
+ }),
5049
+ deployReleases: onCore(async (core, request) => {
5050
+ const { deployments } = await body(request);
5051
+ if (!Array.isArray(deployments) || deployments.length === 0 || !deployments.every(isDeployment)) throw new HandlerBadRequestError("Deploy needs deployments, each a bundle and its release policy.");
5052
+ return json({ data: await core.deploy(deployments) }, 201);
5053
+ }),
5054
+ promoteRelease: onCore(async (core, request, params) => {
5055
+ const input = await body(request);
5056
+ if (typeof input.targetChannel !== "string" || input.action !== void 0 && input.action !== "copy" && input.action !== "move") throw new HandlerBadRequestError("Invalid Release promotion");
5057
+ const expectedRevision = revisionOf(input.expectedRevision);
5058
+ return json({ data: await core.promoteRelease({
5059
+ releaseId: requireRouteParam(params, "id"),
5060
+ targetChannel: input.targetChannel,
5061
+ ...input.action === void 0 ? {} : { action: input.action },
5062
+ ...expectedRevision === void 0 ? {} : { expectedRevision }
5063
+ }) });
5064
+ }),
5065
+ preflightReleaseCatalogRebuild: onCore(async (core, _request, params) => json({ data: await core.preflightReleaseCatalogRebuild(scopeKeyOf(params)) })),
5066
+ deleteBundles: onCore(async (core, request) => {
5067
+ const { ids } = await body(request);
5068
+ if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) throw new HandlerBadRequestError("Bundle deletion needs ids");
5069
+ await core.deleteBundles(ids);
5070
+ return noContent();
5071
+ })
5072
+ });
5073
+ /** The admin mount's routes, by the handler that serves each. */
5074
+ var ADMIN_ROUTES = [
5075
+ {
5076
+ method: "POST",
5077
+ path: "/releases",
5078
+ handler: "deployReleases"
5079
+ },
5080
+ {
5081
+ method: "POST",
5082
+ path: "/releases/:id/promote",
5083
+ handler: "promoteRelease"
5084
+ },
5085
+ {
5086
+ method: "GET",
5087
+ path: "/releases/:id",
5088
+ handler: "getRelease"
5089
+ },
5090
+ {
5091
+ method: "GET",
5092
+ path: "/releases",
5093
+ handler: "listReleases"
5094
+ },
5095
+ {
5096
+ method: "PATCH",
5097
+ path: "/releases/:id",
5098
+ handler: "updateRelease"
5099
+ },
5100
+ {
5101
+ method: "POST",
5102
+ path: "/releases/:id/preflight",
5103
+ handler: "preflightRelease"
5104
+ },
5105
+ {
5106
+ method: "DELETE",
5107
+ path: "/releases/:id",
5108
+ handler: "deleteRelease"
5109
+ },
5110
+ {
5111
+ method: "GET",
5112
+ path: "/release-catalogs/:scopeKey",
5113
+ handler: "getReleaseCatalogRow"
5114
+ },
5115
+ {
5116
+ method: "GET",
5117
+ path: "/release-catalogs",
5118
+ handler: "listReleaseCatalogs"
5119
+ },
5120
+ {
5121
+ method: "POST",
5122
+ path: "/release-catalogs/:scopeKey/rebuild",
5123
+ handler: "rebuildReleaseCatalog"
5124
+ },
5125
+ {
5126
+ method: "POST",
5127
+ path: "/release-catalogs/:scopeKey/preflight",
5128
+ handler: "preflightReleaseCatalogRebuild"
5129
+ },
5130
+ {
5131
+ method: "GET",
5132
+ path: "/channels",
5133
+ handler: "getChannels"
5134
+ },
5135
+ {
5136
+ method: "POST",
5137
+ path: "/channels",
5138
+ handler: "createChannel"
5139
+ },
5140
+ {
5141
+ method: "DELETE",
5142
+ path: "/channels/:id",
5143
+ handler: "deleteChannel"
5144
+ },
5145
+ {
5146
+ method: "GET",
5147
+ path: "/bundles/count",
5148
+ handler: "countBundles"
5149
+ },
5150
+ {
5151
+ method: "GET",
5152
+ path: "/bundles/child-counts",
5153
+ handler: "countBundleChildren"
5154
+ },
5155
+ {
5156
+ method: "GET",
5157
+ path: "/bundles/:id",
5158
+ handler: "getBundle"
5159
+ },
5160
+ {
5161
+ method: "GET",
5162
+ path: "/bundles",
5163
+ handler: "listBundles"
5164
+ },
5165
+ {
5166
+ method: "PATCH",
5167
+ path: "/bundles/:id",
5168
+ handler: "updateBundle"
5169
+ },
5170
+ {
5171
+ method: "POST",
5172
+ path: "/bundles/delete",
5173
+ handler: "deleteBundles"
5174
+ },
5175
+ {
5176
+ method: "GET",
5177
+ path: "/bundles/:id/children",
5178
+ handler: "listBundleChildren"
5179
+ },
5180
+ {
5181
+ method: "GET",
5182
+ path: "/base-candidates/:candidateKey",
5183
+ handler: "findBaseCandidates"
5184
+ }
5185
+ ];
5186
+ var CATALOG_CONTENT_TYPE = "application/vnd.hot-updater.release-catalog+json; version=1";
5187
+ var ARTIFACT_CONTENT_TYPE = "application/vnd.hot-updater.artifact+json; version=1";
5188
+ var ORIGIN_CACHE_TTL_MS = 5e3;
5189
+ var ORIGIN_CACHE_MAX_ENTRIES = 128;
5190
+ /** Marks the 404 of a scope that has no catalog. */
5191
+ var NO_CATALOG_HEADER = "x-hot-updater-catalog";
5192
+ /** A shared cache keeps a catalog, or a scope's lack of one, five seconds. */
5193
+ var CATALOG_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
5194
+ var privateNotFound = () => Response.json({ error: "Not found" }, {
5195
+ status: 404,
5196
+ headers: { "cache-control": "private, no-store" }
5197
+ });
4171
5198
  /**
4172
- * Core's API in process, on a database's storage adapter: how the CLI reads
4173
- * and writes a database that runs on the storage engine.
5199
+ * A scope with no catalog yet, such as a store version before its first
5200
+ * OTA release: cached like a catalog, so its update checks don't all reach
5201
+ * the origin, and it holds nothing an API key protects. The header tells it
5202
+ * apart from a 404 for a wrong URL: clients read it as "no update", and
5203
+ * doctor as a live catalog route.
4174
5204
  */
4175
- var createInProcessCoreApi = (adapter, options = {}) => createCoreApi(createDatabaseEngine({
4176
- adapter,
4177
- schema: resolveSchema([coreModule])
4178
- }).database(coreModule), { resolveFileUrl: async () => null }, options);
5205
+ var missingCatalog = () => Response.json({ error: "Not found" }, {
5206
+ status: 404,
5207
+ headers: {
5208
+ "cache-control": CATALOG_CACHE_CONTROL,
5209
+ vary: "Accept-Encoding",
5210
+ [NO_CATALOG_HEADER]: "none"
5211
+ }
5212
+ });
5213
+ var responseHash = async (body) => {
5214
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
5215
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
5216
+ };
5217
+ var catalogResponse = async (catalog, request) => {
5218
+ if (catalog === null) return missingCatalog();
5219
+ const body = JSON.stringify(catalog);
5220
+ const etag = `"sha256:${await responseHash(body)}"`;
5221
+ const headers = {
5222
+ "cache-control": CATALOG_CACHE_CONTROL,
5223
+ "content-type": CATALOG_CONTENT_TYPE,
5224
+ etag,
5225
+ vary: "Accept-Encoding"
5226
+ };
5227
+ if (request.headers.get("if-none-match") === etag) return new Response(null, {
5228
+ headers,
5229
+ status: 304
5230
+ });
5231
+ return new Response(body, {
5232
+ headers,
5233
+ status: 200
5234
+ });
5235
+ };
5236
+ var createReleaseCatalogRouteHandlers = () => {
5237
+ const cache = /* @__PURE__ */ new Map();
5238
+ const inFlight = /* @__PURE__ */ new Map();
5239
+ const loadCatalog = async (key, load) => {
5240
+ const cached = cache.get(key);
5241
+ if (cached !== void 0 && cached.expiresAt > Date.now()) {
5242
+ cache.delete(key);
5243
+ cache.set(key, cached);
5244
+ return cached.catalog;
5245
+ }
5246
+ if (cached !== void 0) cache.delete(key);
5247
+ const pending = inFlight.get(key);
5248
+ if (pending !== void 0) return pending;
5249
+ const next = load().then((catalog) => {
5250
+ if (catalog !== null) {
5251
+ cache.set(key, {
5252
+ catalog,
5253
+ expiresAt: Date.now() + ORIGIN_CACHE_TTL_MS
5254
+ });
5255
+ while (cache.size > ORIGIN_CACHE_MAX_ENTRIES) {
5256
+ const oldestKey = cache.keys().next().value;
5257
+ if (oldestKey === void 0) break;
5258
+ cache.delete(oldestKey);
5259
+ }
5260
+ }
5261
+ return catalog;
5262
+ });
5263
+ inFlight.set(key, next);
5264
+ try {
5265
+ return await next;
5266
+ } finally {
5267
+ inFlight.delete(key);
5268
+ }
5269
+ };
5270
+ return {
5271
+ appVersionReleaseCatalog: async (params, request, { core }) => {
5272
+ const rawAppVersion = requireRouteParam(params, "appVersion");
5273
+ const appVersion = canonicalizeAppVersion(rawAppVersion);
5274
+ if (appVersion === null || appVersion !== rawAppVersion) return Response.json({ error: "Invalid app version" }, {
5275
+ status: 400,
5276
+ headers: { "cache-control": "private, no-store" }
5277
+ });
5278
+ const input = {
5279
+ appVersion,
5280
+ channelKey: requireRouteParam(params, "channelKey"),
5281
+ platform: requirePlatformParam(params),
5282
+ strategy: "APP_VERSION"
5283
+ };
5284
+ return catalogResponse(await loadCatalog(`app-version:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
5285
+ },
5286
+ fingerprintReleaseCatalog: async (params, request, { core }) => {
5287
+ const input = {
5288
+ channelKey: requireRouteParam(params, "channelKey"),
5289
+ fingerprintHash: requireRouteParam(params, "fingerprintHash"),
5290
+ platform: requirePlatformParam(params),
5291
+ strategy: "FINGERPRINT"
5292
+ };
5293
+ return catalogResponse(await loadCatalog(`fingerprint:${JSON.stringify(input)}`, () => core.getReleaseCatalog(input)), request);
5294
+ },
5295
+ artifactV1: async (params, _request, { core }) => {
5296
+ const info = await core.getArtifactInfo(requireRouteParam(params, "targetBundleId"), requireRouteParam(params, "currentBundleId"), 1);
5297
+ if (info === null) return privateNotFound();
5298
+ return new Response(JSON.stringify(info), {
5299
+ status: 200,
5300
+ headers: {
5301
+ "cache-control": "private, no-store",
5302
+ "content-type": ARTIFACT_CONTENT_TYPE
5303
+ }
5304
+ });
5305
+ }
5306
+ };
5307
+ };
5308
+ var normalizePath = (path) => {
5309
+ if (!path) return "/";
5310
+ if (path === "/") return path;
5311
+ const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
5312
+ return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
5313
+ };
5314
+ var toSegments = (path) => {
5315
+ const normalized = normalizePath(path);
5316
+ return normalized === "/" ? [] : normalized.slice(1).split("/");
5317
+ };
5318
+ function createRouter() {
5319
+ return { routes: [] };
5320
+ }
5321
+ function addRoute(router, method, path, data) {
5322
+ const segments = toSegments(path);
5323
+ const paramNames = segments.filter((segment) => segment.startsWith(":")).map((segment) => segment.slice(1));
5324
+ router.routes.push({
5325
+ data,
5326
+ method: method.toUpperCase(),
5327
+ paramNames,
5328
+ segments
5329
+ });
5330
+ }
5331
+ function findRoute(router, method, path) {
5332
+ const normalizedMethod = method.toUpperCase();
5333
+ const pathSegments = toSegments(path);
5334
+ for (const route of router.routes) {
5335
+ if (route.method !== normalizedMethod) continue;
5336
+ if (route.segments.length !== pathSegments.length) continue;
5337
+ const params = {};
5338
+ let matched = true;
5339
+ for (let index = 0; index < route.segments.length; index += 1) {
5340
+ const routeSegment = route.segments[index];
5341
+ const pathSegment = pathSegments[index];
5342
+ if (routeSegment.startsWith(":")) {
5343
+ params[routeSegment.slice(1)] = pathSegment;
5344
+ continue;
5345
+ }
5346
+ if (routeSegment !== pathSegment) {
5347
+ matched = false;
5348
+ break;
5349
+ }
5350
+ }
5351
+ if (matched) return {
5352
+ data: route.data,
5353
+ params
5354
+ };
5355
+ }
5356
+ }
5357
+ var withPrivateNoStore = (response) => {
5358
+ const headers = new Headers(response.headers);
5359
+ headers.set("cache-control", "private, no-store");
5360
+ return new Response(response.body, {
5361
+ headers,
5362
+ status: response.status,
5363
+ statusText: response.statusText
5364
+ });
5365
+ };
5366
+ var errorResponse = (error, status) => Response.json({ error }, {
5367
+ headers: { "cache-control": "private, no-store" },
5368
+ status
5369
+ });
5370
+ /** `/version` and storage downloads never pass through the client policy. */
5371
+ var PUBLIC_CLIENT_ROUTES = /* @__PURE__ */ new Set(["version", "downloadStorageObject"]);
5372
+ var routesOf = Symbol.for("@hot-updater/server/routes");
5373
+ var withVary = (response, varyHeaders) => {
5374
+ const cacheControl = response.headers.get("cache-control") ?? "";
5375
+ if (varyHeaders.length === 0 || cacheControl === "" || /private|no-store/u.test(cacheControl)) return response;
5376
+ const headers = new Headers(response.headers);
5377
+ headers.set("vary", [response.headers.get("vary"), ...varyHeaders].filter(Boolean).join(", "));
5378
+ return new Response(response.body, {
5379
+ headers,
5380
+ status: response.status,
5381
+ statusText: response.statusText
5382
+ });
5383
+ };
5384
+ var createRequestHandler = ({ api, clientPolicy, privateResponses = false, routeHandlers, router }) => async (request) => {
5385
+ try {
5386
+ const match = findRoute(router, request.method, new URL(request.url).pathname);
5387
+ if (!match) return errorResponse("Not found", 404);
5388
+ const guarded = clientPolicy !== void 0 && !PUBLIC_CLIENT_ROUTES.has(match.data);
5389
+ if (guarded) {
5390
+ let authenticated;
5391
+ try {
5392
+ authenticated = await clientPolicy.authenticate(request);
5393
+ } catch {
5394
+ return errorResponse("Service unavailable", 503);
5395
+ }
5396
+ if (!authenticated) return errorResponse("Unauthorized", 401);
5397
+ }
5398
+ const handler = routeHandlers[match.data];
5399
+ if (!handler) return errorResponse("Handler not found", 500);
5400
+ const response = await handler(match.params, request, api);
5401
+ if (privateResponses) return withPrivateNoStore(response);
5402
+ return guarded ? withVary(response, clientPolicy.varyHeaders) : response;
5403
+ } catch (error) {
5404
+ if (error instanceof HandlerBadRequestError) return errorResponse(error.message, 400);
5405
+ if (error instanceof HotUpdaterSchemaMigrationRequiredError) {
5406
+ console.error(error.message);
5407
+ return errorResponse("Service unavailable", 503);
5408
+ }
5409
+ console.error("Hot Updater handler error:", error);
5410
+ return Response.json({ error: "Internal server error" }, {
5411
+ headers: { "cache-control": "private, no-store" },
5412
+ status: 500
5413
+ });
5414
+ }
5415
+ };
5416
+ var createDownloadStorageRouteHandler = (downloadStorageObject) => async (params) => {
5417
+ const token = params.token;
5418
+ const signature = params.signature;
5419
+ if (!token || !signature) return errorResponse("Not found", 404);
5420
+ const response = await downloadStorageObject(token, signature);
5421
+ if (!response) return errorResponse("Not found", 404);
5422
+ const headers = new Headers(response.headers);
5423
+ if (!headers.has("cache-control")) headers.set("cache-control", "public, max-age=31536000, immutable");
5424
+ return new Response(response.body, {
5425
+ headers,
5426
+ status: response.status,
5427
+ statusText: response.statusText
5428
+ });
5429
+ };
5430
+ function createHotUpdaterHandlers({ api, clientPolicy, downloadStorageObject, endpoints = [], plugins = [] }) {
5431
+ const routeHandlers = {
5432
+ ...createVersionRouteHandlers(plugins),
5433
+ ...createReleaseCatalogRouteHandlers(),
5434
+ ...createAdminRouteHandlers(),
5435
+ ...downloadStorageObject === void 0 ? {} : { downloadStorageObject: createDownloadStorageRouteHandler(downloadStorageObject) }
5436
+ };
5437
+ const routes = [];
5438
+ const shapes = /* @__PURE__ */ new Map();
5439
+ const mount = (router, admin) => (method, path, handler) => {
5440
+ const shape = `${admin ? "admin" : "client"} ${method} ${path.replaceAll(/:[^/]+/gu, ":")}`;
5441
+ const taken = shapes.get(shape);
5442
+ if (taken !== void 0) throw new HotUpdaterConfigError(`${handler} (${method} ${path}) collides with ${taken} on handlers.${admin ? "admin" : "client"}.`);
5443
+ shapes.set(shape, handler);
5444
+ addRoute(router, method, path, handler);
5445
+ routes.push({
5446
+ method,
5447
+ path,
5448
+ access: admin ? "admin" : PUBLIC_CLIENT_ROUTES.has(handler) ? "public" : "client"
5449
+ });
5450
+ };
5451
+ const clientRouter = createRouter();
5452
+ const addClientRoute = mount(clientRouter, false);
5453
+ addClientRoute("GET", "/version", "version");
5454
+ if (downloadStorageObject !== void 0) addClientRoute("GET", "/storage/:token/:signature", "downloadStorageObject");
5455
+ addClientRoute("GET", "/release-catalogs/app-version/:platform/:channelKey/:appVersion", "appVersionReleaseCatalog");
5456
+ addClientRoute("GET", "/release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash", "fingerprintReleaseCatalog");
5457
+ addClientRoute("GET", "/artifacts/v1/:targetBundleId/from/:currentBundleId", "artifactV1");
5458
+ const adminRouter = createRouter();
5459
+ const addAdminRoute = mount(adminRouter, true);
5460
+ addAdminRoute("GET", "/version", "adminVersion");
5461
+ for (const route of ADMIN_ROUTES) addAdminRoute(route.method, route.path, route.handler);
5462
+ for (const endpoint of endpoints) {
5463
+ const name = `plugin ${endpoint.plugin}: ${endpoint.method} ${endpoint.path}`;
5464
+ routeHandlers[name] = (params, request) => endpoint.handler(request, params);
5465
+ (endpoint.access === "admin" ? addAdminRoute : addClientRoute)(endpoint.method, endpoint.path, name);
5466
+ }
5467
+ const handlers = {
5468
+ client: createRequestHandler({
5469
+ api,
5470
+ ...clientPolicy === void 0 ? {} : { clientPolicy },
5471
+ routeHandlers,
5472
+ router: clientRouter
5473
+ }),
5474
+ admin: createRequestHandler({
5475
+ api,
5476
+ privateResponses: true,
5477
+ routeHandlers,
5478
+ router: adminRouter
5479
+ })
5480
+ };
5481
+ Object.defineProperty(handlers, routesOf, { value: Object.freeze(routes) });
5482
+ return Object.freeze(handlers);
5483
+ }
5484
+ var assertRemoteUrl = (value) => {
5485
+ const protocol = new URL(value).protocol;
5486
+ if (protocol !== "http:" && protocol !== "https:") throw new Error("Storage getDownloadUrl must resolve to an HTTP(S) URL.");
5487
+ return value;
5488
+ };
5489
+ var getStorageProtocol = (storageUri) => new URL(storageUri).protocol.replace(":", "");
5490
+ var isRemoteUrlProtocol = (protocol) => protocol === "http" || protocol === "https";
5491
+ var resolveDownloadPath = (value, storageUri) => {
5492
+ const parsed = parseStorageDownloadPath(value);
5493
+ if (!parsed || parsed.storageUri !== storageUri) throw new Error("Storage getDownloadUrl must return an HTTP(S) URL or a valid storage download path.");
5494
+ return value;
5495
+ };
5496
+ var tokensEqual = (left, right) => {
5497
+ const leftBytes = new TextEncoder().encode(left);
5498
+ const rightBytes = new TextEncoder().encode(right);
5499
+ let difference = leftBytes.length ^ rightBytes.length;
5500
+ const length = Math.max(leftBytes.length, rightBytes.length);
5501
+ for (let index = 0; index < length; index += 1) difference |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
5502
+ return difference === 0;
5503
+ };
5504
+ var createStorageAccess = (storageAdapters) => {
5505
+ const protocols = /* @__PURE__ */ new Set();
5506
+ for (const storage of storageAdapters) {
5507
+ if (protocols.has(storage.protocol)) throw new Error(`Multiple storage adapters handle protocol: ${storage.protocol}`);
5508
+ protocols.add(storage.protocol);
5509
+ }
5510
+ const findStorage = (protocol) => storageAdapters.find((item) => item.protocol === protocol);
5511
+ const readStorageResponse = async (storageUri) => {
5512
+ const protocol = getStorageProtocol(storageUri);
5513
+ const storage = findStorage(protocol);
5514
+ if (storage) {
5515
+ if (!storage.get) return null;
5516
+ return (await storage.get({ storageUri })).response;
5517
+ }
5518
+ if (isRemoteUrlProtocol(protocol)) {
5519
+ const response = await fetch(storageUri);
5520
+ return response.ok ? response : null;
5521
+ }
5522
+ throw new Error(`No storage adapter for protocol: ${protocol}`);
5523
+ };
5524
+ const resolveFileUrl = async (storageUri) => {
5525
+ if (!storageUri) return null;
5526
+ const protocol = getStorageProtocol(storageUri);
5527
+ const storage = findStorage(protocol);
5528
+ if (!storage) {
5529
+ if (isRemoteUrlProtocol(protocol)) return storageUri;
5530
+ throw new Error(`No storage adapter for protocol: ${protocol}`);
5531
+ }
5532
+ if (!storage.getDownloadUrl) return null;
5533
+ const { url: downloadUrl } = await storage.getDownloadUrl({ storageUri });
5534
+ try {
5535
+ return assertRemoteUrl(downloadUrl);
5536
+ } catch (error) {
5537
+ if (/^[a-z][a-z\d+.-]*:/i.test(downloadUrl)) throw error;
5538
+ }
5539
+ return resolveDownloadPath(downloadUrl, storageUri);
5540
+ };
5541
+ const readStorageText = async (storageUri) => {
5542
+ return (await readStorageResponse(storageUri))?.text() ?? null;
5543
+ };
5544
+ return {
5545
+ downloadStorageObject: storageAdapters.some((storage) => storage.getDownloadUrl !== void 0) ? async (storageUriToken, encodedSignature) => {
5546
+ const requestedPath = `/storage/${storageUriToken}/${encodedSignature}`;
5547
+ const requested = parseStorageDownloadPath(requestedPath);
5548
+ if (!requested) return null;
5549
+ let storage;
5550
+ try {
5551
+ storage = findStorage(getStorageProtocol(requested.storageUri));
5552
+ } catch {
5553
+ return null;
5554
+ }
5555
+ if (!storage?.getDownloadUrl || !storage.get) return null;
5556
+ const { url: downloadUrl } = await storage.getDownloadUrl({ storageUri: requested.storageUri });
5557
+ try {
5558
+ new URL(downloadUrl);
5559
+ return null;
5560
+ } catch {
5561
+ if (/^[a-z][a-z\d+.-]*:/i.test(downloadUrl)) return null;
5562
+ }
5563
+ if (!tokensEqual(downloadUrl, requestedPath)) return null;
5564
+ return (await storage.get({ storageUri: requested.storageUri })).response;
5565
+ } : void 0,
5566
+ readStorageText,
5567
+ resolveFileUrl
5568
+ };
5569
+ };
4179
5570
  var isRecord = (value) => typeof value === "object" && value !== null;
4180
5571
  var isAdapter = (value) => isRecord(value) && [
4181
5572
  "fits",
@@ -4183,23 +5574,139 @@ var isAdapter = (value) => isRecord(value) && [
4183
5574
  "query",
4184
5575
  "write"
4185
5576
  ].every((method) => typeof value[method] === "function");
4186
- /** The storage adapter a configured database runs on, if it is on the engine. */
4187
- var engineAdapterOf = (database) => isRecord(database) && isAdapter(database.adapter) ? database.adapter : isAdapter(database) ? database : void 0;
4188
- var OFF_ENGINE_DATABASE = "This database does not run on Hot Updater's storage engine. Upgrade its provider package to 1.0.";
5577
+ /** The configured database, when it runs on the storage engine. */
5578
+ var databaseOf = (value) => {
5579
+ if (isRecord(value) && typeof value.name === "string") {
5580
+ if (isAdapter(value.adapter)) return value;
5581
+ if ("core" in value && "fetchAdmin" in value) throw new HotUpdaterConfigError("standaloneRepository reaches a server's admin API from the CLI and console. createHotUpdater needs the server's own database, such as kyselyAdapter(...).");
5582
+ }
5583
+ throw new HotUpdaterConfigError("database must be a Hot Updater 1.0 database, such as kyselyAdapter(...) or postgres(...). Upgrade the provider package that created it.");
5584
+ };
5585
+ /** `"public"`, or nothing; a `clientAccess` object says what replaced it. */
5586
+ var isPublic = (value) => {
5587
+ if (value === void 0) return false;
5588
+ if (value === "public") return true;
5589
+ throw new HotUpdaterConfigError(isRecord(value) ? "clientAccess objects were removed in 1.0. Set clientAccess: \"public\", or add a plugin that provides clientAuth to plugins." : "clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
5590
+ };
5591
+ function createHotUpdater(options) {
5592
+ for (const key of ["authorityId", "catalogId"]) if (Object.hasOwn(options, key)) throw new TypeError(`Remove ${key} from createHotUpdater options. Catalog identity is managed internally.`);
5593
+ const database = databaseOf(options.database);
5594
+ const storage = Object.freeze([...options.storage ?? []]);
5595
+ const { downloadStorageObject, readStorageText, resolveFileUrl } = createStorageAccess(storage);
5596
+ const publicClients = isPublic(options.clientAccess);
5597
+ const configured = Object.freeze([...options.plugins ?? []]);
5598
+ const plugins = assemblePlugins(configured, database, { storage: {
5599
+ readStorageText,
5600
+ resolveFileUrl
5601
+ } });
5602
+ const clientPlugins = Object.freeze(clientPluginsOf(configured));
5603
+ const clientAuth = plugins.clientAuth;
5604
+ if (clientAuth !== void 0 && publicClients) throw new HotUpdaterConfigError(`Plugin "${clientAuth.plugin}" provides clientAuth, so remove clientAccess.`);
5605
+ if (clientAuth === void 0 && !publicClients) throw new HotUpdaterConfigError("Set clientAccess to \"public\", or add a plugin that provides clientAuth.");
5606
+ const varyHeaders = Object.freeze((clientAuth?.varyHeaders ?? []).map((header) => header.toLowerCase()));
5607
+ const clientPolicy = clientAuth === void 0 ? void 0 : {
5608
+ varyHeaders,
5609
+ authenticate: (request) => clientAuth.authenticate(request.headers)
5610
+ };
5611
+ const handlers = createHotUpdaterHandlers({
5612
+ api: { core: plugins.core },
5613
+ ...clientPolicy === void 0 ? {} : { clientPolicy },
5614
+ downloadStorageObject,
5615
+ endpoints: plugins.endpoints,
5616
+ plugins: Object.keys(plugins.api)
5617
+ });
5618
+ let serving = false;
5619
+ return {
5620
+ adapterName: database.name,
5621
+ get handlers() {
5622
+ if (!serving) {
5623
+ for (const adapter of storage) assertStorageOperations(adapter, ["get", "getDownloadUrl"]);
5624
+ serving = true;
5625
+ }
5626
+ return handlers;
5627
+ },
5628
+ core: plugins.core,
5629
+ api: plugins.api,
5630
+ flush: plugins.flush,
5631
+ database,
5632
+ storage,
5633
+ plugins: configured,
5634
+ clientPlugins,
5635
+ clientEndpoints: Object.freeze(plugins.endpoints.filter((endpoint) => endpoint.access === "client").map(({ plugin, method, path }) => Object.freeze({
5636
+ plugin,
5637
+ method,
5638
+ path
5639
+ }))),
5640
+ ...clientAuth === void 0 ? {} : { clientAuth: Object.freeze({
5641
+ plugin: clientAuth.plugin,
5642
+ varyHeaders
5643
+ }) }
5644
+ };
5645
+ }
4189
5646
  /**
4190
- * Core's API for a configured database, as the CLI uses it: a self-hosted
4191
- * server's admin API when the database is `standaloneRepository`, or the
4192
- * database's own storage adapter, in process.
5647
+ * Core and the plugins over `database`, assembled by `createHotUpdater` as
5648
+ * the server assembles them, with public client routes unless a plugin
5649
+ * provides clientAuth: the console mounts no handlers. cli-tools'
5650
+ * `assembleServer` does the same for the CLI; the console keeps its own
5651
+ * copy, since a hosted console never loads cli-tools.
4193
5652
  */
4194
- var createDatabaseCoreApi = (database, options = {}) => {
4195
- if (isRecord(database) && isRecord(database.core)) return database.core;
4196
- const adapter = engineAdapterOf(database);
4197
- if (adapter === void 0) throw new Error(OFF_ENGINE_DATABASE);
4198
- const engineDatabase = database;
4199
- return createInProcessCoreApi(adapter, {
4200
- ...options,
4201
- ...typeof engineDatabase.onCachedRoutesChange === "function" ? { onCachedRoutesChange: () => engineDatabase.onCachedRoutesChange() } : {}
4202
- });
5653
+ var assemble = (database, storage, plugins) => createHotUpdater({
5654
+ database,
5655
+ storage: [storage],
5656
+ plugins,
5657
+ ...plugins.some((plugin) => plugin.provides?.clientAuth) ? {} : { clientAccess: "public" }
5658
+ });
5659
+ var getConsoleAuthAdapter = async () => {
5660
+ return (await import("./auth-BF8mbAgF.mjs")).default;
5661
+ };
5662
+ var resolveConsoleConfig = async (request) => {
5663
+ const { default: source } = await import("./config-nsIcsHh8.mjs");
5664
+ const config = typeof source === "function" ? await source(request) : source;
5665
+ const { database, storage, plugins = [] } = config;
5666
+ const gitUrl = config.console?.gitUrl === void 0 ? {} : { gitUrl: config.console.gitUrl };
5667
+ if (isRemoteDatabase(database)) {
5668
+ const checked = assemble({
5669
+ name: "memory",
5670
+ adapter: createMemoryAdapter()
5671
+ }, storage, plugins);
5672
+ return {
5673
+ ...gitUrl,
5674
+ database,
5675
+ core: database.core,
5676
+ storage: [storage],
5677
+ plugins: checked.plugins
5678
+ };
5679
+ }
5680
+ const server = assemble(database, storage, plugins);
5681
+ return {
5682
+ ...gitUrl,
5683
+ database,
5684
+ core: server.core,
5685
+ storage: server.storage,
5686
+ plugins: server.plugins,
5687
+ api: server.api
5688
+ };
5689
+ };
5690
+ var accessError = (access) => new Response(access.status === "unauthenticated" ? "Unauthorized" : "Forbidden", { status: access.status === "unauthenticated" ? 401 : 403 });
5691
+ var accessByRequest = /* @__PURE__ */ new WeakMap();
5692
+ var getConsoleAccess = (request) => {
5693
+ let access = accessByRequest.get(request);
5694
+ if (!access) {
5695
+ access = getConsoleAuthAdapter().then((adapter) => adapter.getAccess(request));
5696
+ accessByRequest.set(request, access);
5697
+ }
5698
+ return access;
5699
+ };
5700
+ var getConsoleAuthProviders = async (request) => {
5701
+ return (await getConsoleAuthAdapter()).getProviders(request);
5702
+ };
5703
+ var handleConsoleAuth = async (request) => {
5704
+ return (await getConsoleAuthAdapter()).handle(request);
5705
+ };
5706
+ var requireConsoleAccess = async (request) => {
5707
+ const access = await getConsoleAccess(request);
5708
+ if (access.status !== "authorized") throw accessError(access);
5709
+ return access.principal;
4203
5710
  };
4204
5711
  //#endregion
4205
- export { createCoreReads as a, engineAdapterOf as c, validateSchema as d, createCoreApi as i, pruneDuringWrites as l, aggregateBatchingModule as n, createDatabaseCoreApi as o, coreModule as r, createDatabaseEngine as s, OFF_ENGINE_DATABASE as t, resolveSchema as u };
5712
+ export { getConsoleAccess, getConsoleAuthProviders, handleConsoleAuth, requireConsoleAccess, resolveConsoleConfig as t };