@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{ConsoleFeatureUnavailable-B9VYGA3N.js → ConsoleFeatureUnavailable-D0LPL1Gy.js} +1 -1
- package/.output/public/assets/{HashValueDisplay-D880zntR.js → HashValueDisplay-BmuB4FW-.js} +1 -1
- package/.output/public/assets/InsightsInfo--v4SC07c.js +1 -0
- package/.output/public/assets/InsightsPageHeader-dU83CAQY.js +1 -0
- package/.output/public/assets/{NotFoundPage-Dys8C0cW.js → NotFoundPage-B5mhGG2a.js} +1 -1
- package/.output/public/assets/api-D-vOVlQ4.js +1 -0
- package/.output/public/assets/api-keys-Csh8ARKU.js +1 -0
- package/.output/public/assets/api-keys-OMPUoA_A.js +1 -0
- package/.output/public/assets/arrow-left-Bwq9X1eS.js +1 -0
- package/.output/public/assets/arrow-up-right-BkOVn4if.js +1 -0
- package/.output/public/assets/{card-DzCPSRsh.js → card-C_7d6O9a.js} +1 -1
- package/.output/public/assets/console-features-api-C1jRZTGw.js +1 -0
- package/.output/public/assets/dialog-DE8wFN_P.js +1 -0
- package/.output/public/assets/{dist-Clb6cQ9l.js → dist-C7ENGzpS.js} +1 -1
- package/.output/public/assets/empty-CVd5NlQD.js +1 -0
- package/.output/public/assets/{field-Bou7shoO.js → field-C06q1iC9.js} +1 -1
- package/.output/public/assets/index-BeWlQtO6.js +11 -0
- package/.output/public/assets/{input-group-DVtWFUUF.js → input-group-Bi61biIA.js} +1 -1
- package/.output/public/assets/insights-BjnAtC0f.js +2 -0
- package/.output/public/assets/insights-D86m11yH.js +42 -0
- package/.output/public/assets/insights-DpYstMoY.js +1 -0
- package/.output/public/assets/{insights-recovery-rpc-wvdyM4LD.js → insights-recovery-rpc-BIm5_ALc.js} +1 -1
- package/.output/public/assets/{insights-usage-rpc-qUkNtrWr.js → insights-usage-rpc-VHj8vyBt.js} +1 -1
- package/.output/public/assets/insights_.distribution-ADcrX48c.js +2 -0
- package/.output/public/assets/insights_.distribution-CuuJUN6Y.js +1 -0
- package/.output/public/assets/{insights_.distribution-CItZw3-8.js → insights_.distribution-DJRjuXsO.js} +1 -1
- package/.output/public/assets/installations-BSBBzqWs.js +2 -0
- package/.output/public/assets/installations-DqIE6WcO.js +1 -0
- package/.output/public/assets/installations-UsELbDvw.js +1 -0
- package/.output/public/assets/{loader-circle-Ca7C9YY1.js → loader-circle-CnCBxzHp.js} +1 -1
- package/.output/public/assets/mutation-Csp7xUNh.js +1 -0
- package/.output/public/assets/preload-helper-B-cOmpLH.js +1 -0
- package/.output/public/assets/{refresh-cw-CVGqvy8Z.js → refresh-cw-FF1NtzlQ.js} +1 -1
- package/.output/public/assets/{routes-veKtDW39.js → routes-CoN9XHP_.js} +2 -2
- package/.output/public/assets/routes-DT1jB0Zi.js +1 -0
- package/.output/public/assets/sidebar-D8mJRF15.js +3 -0
- package/.output/public/assets/styles-Bwt7v6U5.css +2 -0
- package/.output/public/assets/table-WBgfvgXg.js +1 -0
- package/.output/public/assets/{useCompositeListItem-CF7vsfcA.js → useCompositeListItem-CloBmDwn.js} +1 -1
- package/.output/public/assets/useMutation-DO8cvsTx.js +1 -0
- package/.output/public/assets/{useOpenInteractionType-AnAMWYIY.js → useOpenInteractionType-ReGr9GzJ.js} +1 -1
- package/.output/public/assets/valueToPercent-BvPAi4bP.js +1 -0
- package/.output/server/{__23tanstack-start-server-fn-resolver-CUQLz7hs.mjs → __23tanstack-start-server-fn-resolver-DMRD4e4f.mjs} +34 -42
- package/.output/server/_libs/@tanstack/react-router+[...].mjs +3756 -4022
- package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +138 -95
- package/.output/server/_libs/tanstack__history.mjs +68 -41
- package/.output/server/_libs/tanstack__query-core.mjs +1618 -309
- package/.output/server/_libs/tanstack__react-query.mjs +255 -20
- package/.output/server/_ssr/{ConsoleFeatureUnavailable-DoxET2AT.mjs → ConsoleFeatureUnavailable-Du7jNPNS.mjs} +7 -7
- package/.output/server/_ssr/{HashValueDisplay-BplboHEb.mjs → HashValueDisplay-GQvFkXoi.mjs} +2 -2
- package/.output/server/_ssr/{InsightsInfo-o2YPd5Qc.mjs → InsightsInfo-Do7QSaFR.mjs} +2 -2
- package/.output/server/_ssr/{InsightsPageHeader-NExCJL1I.mjs → InsightsPageHeader-DSUWUWRc.mjs} +6 -6
- package/.output/server/_ssr/{NotFoundPage-Cd-A8ock.mjs → NotFoundPage-DsF4Vrhg.mjs} +2 -2
- package/.output/server/_ssr/{api-B1Ro0uys.mjs → api-BEiVs0H7.mjs} +9 -8
- package/.output/server/_ssr/{api-keys-Dvvy1sem.mjs → api-keys-BrvBWGRf.mjs} +2 -2
- package/.output/server/_ssr/{api-keys-CT3HG7KT.mjs → api-keys-K5v32nf7.mjs} +8 -8
- package/.output/server/_ssr/{api-keys-rpc-b1qTTFfG.mjs → api-keys-rpc-B96bBllm.mjs} +5 -5
- package/.output/server/_ssr/{api-rpc-Cbns2MGx.mjs → api-rpc-DcUe280B.mjs} +13 -55
- package/.output/server/_ssr/{auth-rpc-DChS7m9y.mjs → auth-rpc-C68W56DP.mjs} +6 -6
- package/.output/server/_ssr/{api-C0G9MTMI.mjs → auth.server-DM1J6NwR.mjs} +2815 -1308
- package/.output/server/_ssr/{bundleActivity-D17Pb_h5.mjs → bundleActivity-CpuY9CTP.mjs} +1 -1
- package/.output/server/_ssr/{card-DMbcvlPz.mjs → card-CPAEKx0w.mjs} +1 -1
- package/.output/server/_ssr/config-nsIcsHh8.mjs +15 -0
- package/.output/server/_ssr/config.server-DDCZk3Sq.mjs +36 -0
- package/.output/server/_ssr/{console-access-KpPv5lin.mjs → console-access-Da-2Qd7q.mjs} +3 -3
- package/.output/server/_ssr/{console-features-CsbyHYfK.mjs → console-features-Dzu2iUGD.mjs} +6 -6
- package/.output/server/_ssr/{console-features-api-B-KVuo4p.mjs → console-features-api-xAsSgFa_.mjs} +6 -6
- package/.output/server/_ssr/{console-features-rpc-Djyo5740.mjs → console-features-rpc-qARGpB24.mjs} +5 -5
- package/.output/server/_ssr/{createMiddleware-B_I3Ll4M.mjs → createMiddleware-BdBf_ezN.mjs} +41 -56
- package/.output/server/_ssr/{createServerRpc-DuNm5mmo.mjs → createServerRpc-B_y2lrwA.mjs} +2 -2
- package/.output/server/_ssr/databaseJsonValue-Bhxciy7Z.mjs +68 -0
- package/.output/server/_ssr/databaseRows-DwMAjaF8.mjs +74 -0
- package/.output/server/_ssr/{dialog-cOmLTUV2.mjs → dialog-q__BJ7mQ.mjs} +1 -1
- package/.output/server/_ssr/{dist-B-r4u3Lg.mjs → dist-Cxo9ghYv.mjs} +114 -3
- package/.output/server/_ssr/{downloadBundle-CexOFoZ4.mjs → downloadBundle-BW9I-pMQ.mjs} +6 -5
- package/.output/server/_ssr/{empty-BHoY_5jt.mjs → empty-DcBTAooP.mjs} +2 -2
- package/.output/server/_ssr/{empty-plugin-adapters-B5QvGpEl.mjs → empty-plugin-adapters-CYdinE-i.mjs} +1 -1
- package/.output/server/_ssr/{field-mjhcNEXD.mjs → field-QCDBVpXA.mjs} +2 -2
- package/.output/server/_ssr/{getBundleChildren-DbDXe9wB.mjs → getBundleChildren-DIAGCF4a.mjs} +2 -2
- package/.output/server/_ssr/{input-group-96H30HWC.mjs → input-group-hCk2nUoU.mjs} +1 -1
- package/.output/server/_ssr/{insights-DgFv1NsO.mjs → insights-BfBO0RFr.mjs} +18 -18
- package/.output/server/_ssr/{insights-D359GJFL.mjs → insights-DsAU3-TB.mjs} +5 -5
- package/.output/server/_ssr/{insights-D25VDVaz.mjs → insights-RIfg447W.mjs} +2 -2
- package/.output/server/_ssr/{insights-api-ChAKrbhG.mjs → insights-api-CDn-QQHU.mjs} +5 -5
- package/.output/server/_ssr/{insights-failures-WQ-ttjOx.mjs → insights-failures-BBGMhuUr.mjs} +2 -2
- package/.output/server/_ssr/{insights-failures-rpc-BFsqlltN.mjs → insights-failures-rpc-rA2dctK2.mjs} +8 -8
- package/.output/server/_ssr/{insights-recovery-DkL6bxMW.mjs → insights-recovery-Dpk6Mfb9.mjs} +1 -1
- package/.output/server/_ssr/{insights-recovery-rpc-2P5m8F62.mjs → insights-recovery-rpc-BCaF-xSd.mjs} +11 -11
- package/.output/server/_ssr/{insights-recovery-rpc-Tj8vNC19.mjs → insights-recovery-rpc-CcgYyUHk.mjs} +5 -5
- package/.output/server/_ssr/{insights-rpc-DrkVmV9o.mjs → insights-rpc-keycmwzw.mjs} +5 -5
- package/.output/server/_ssr/{insights-search-BsalnNw-.mjs → insights-search-BRg5VsNe.mjs} +1 -1
- package/.output/server/_ssr/{insights-usage-1nVTQrLy.mjs → insights-usage-cbw9n2Fq.mjs} +1 -1
- package/.output/server/_ssr/{insights-usage-rpc-Ct_4xHhZ.mjs → insights-usage-rpc-BKgV1QDf.mjs} +8 -8
- package/.output/server/_ssr/{insights-usage-rpc-DaYIRtFK.mjs → insights-usage-rpc-D-JYachy.mjs} +5 -5
- package/.output/server/_ssr/{insightsRecovery-CpGucWN1.mjs → insightsRecovery-B7oMc8d8.mjs} +2 -2
- package/.output/server/_ssr/{insightsUsage-JYpKZAF7.mjs → insightsUsage-Oa7kk5ek.mjs} +3 -3
- package/.output/server/_ssr/{insights_.distribution-jQZEF07J.mjs → insights_.distribution-BIqOqRW0.mjs} +13 -13
- package/.output/server/_ssr/{insights_.distribution-Bcu6uYs_.mjs → insights_.distribution-CuUrDa7P.mjs} +2 -2
- package/.output/server/_ssr/{insights_.distribution-D3Csuyjw.mjs → insights_.distribution-RT_6x_ud.mjs} +5 -5
- package/.output/server/_ssr/{installations-Cxikllmx.mjs → installations-BO5CjX2z.mjs} +11 -11
- package/.output/server/_ssr/{installations-BI5kelHv.mjs → installations-BjaHiIfh.mjs} +2 -2
- package/.output/server/_ssr/{installations-B8Vh54lH.mjs → installations-Dt5luA-N.mjs} +4 -4
- package/.output/server/_ssr/{listReleases-DqU9t1l3.mjs → listReleases-BitHzOYB.mjs} +1 -1
- package/.output/server/_ssr/{router-CRkm-wHD.mjs → router-DfVAixxe.mjs} +42 -39
- package/.output/server/_ssr/{routes-CNAi0Mmv.mjs → routes-C6LstNp8.mjs} +14 -14
- package/.output/server/_ssr/{routes-B3IJo_jL.mjs → routes-Q3Uhmwwz.mjs} +11 -11
- package/.output/server/_ssr/{runtime.server-NucbYjc7.mjs → runtime.server-fL0o6I2b.mjs} +500 -413
- package/.output/server/_ssr/schema-BKY9SqaY.mjs +25 -0
- package/.output/server/_ssr/{select-BkPAJFL7.mjs → select-CUlInmjk.mjs} +1 -1
- package/.output/server/_ssr/server-C43wq9ft.mjs +8330 -0
- package/.output/server/_ssr/{sidebar-Cw1VuC7d.mjs → sidebar-DKbx2ach.mjs} +3 -3
- package/.output/server/_ssr/ssr.mjs +1 -1
- package/.output/server/_ssr/start-BXUOv_n8.mjs +4 -0
- package/.output/server/_ssr/{table-q0P9wz7x.mjs → table-NGWV8B-S.mjs} +1 -1
- package/.output/server/_ssr/{updateFailures-DMU9f0pK.mjs → updateFailures-Jwe2Baab.mjs} +3 -3
- package/.output/server/_ssr/{uuidv7-BzSO-fr-.mjs → uuidv7-C8bwiZr8.mjs} +2 -2
- package/.output/server/_tanstack-start-manifest_v-WQ5qGn8n.mjs +124 -0
- package/.output/server/index.mjs +218 -218
- package/LICENSE +13 -14
- package/README.md +16 -12
- package/lib/index.d.mts +18 -8
- package/lib/vite-internal.mjs +15 -8
- package/lib/vite.d.mts +2 -0
- package/package.json +14 -14
- package/src/components/AppSidebar.tsx +9 -0
- package/src/components/ConsoleFeatureUnavailable.tsx +1 -3
- package/src/components/features/bundles/BundleMetadata.tsx +1 -1
- package/src/components/features/bundles/RolloutCohortsDialog.tsx +1 -1
- package/src/components/features/releases/ReleaseEditorSheet.tsx +6 -6
- package/src/index.ts +27 -15
- package/src/lib/api-rpc.ts +1 -54
- package/src/lib/api.ts +8 -89
- package/src/lib/console-features.ts +5 -5
- package/src/lib/scroll-restoration.ts +1 -1
- package/src/lib/server/config.server.ts +15 -52
- package/src/lib/server/console-runtime.server.ts +84 -6
- package/src/lib/server/downloadBundle.ts +10 -6
- package/src/lib/server/releaseReachability.ts +4 -4
- package/src/lib/server/runtime.server.ts +34 -46
- package/src/routeTree.gen.ts +31 -31
- package/src/routes/api/bundles/$bundleId/download.ts +2 -2
- package/src/vite-internal.ts +17 -8
- package/src/vite.ts +2 -0
- package/.output/public/assets/InsightsInfo-By_sPjTE.js +0 -1
- package/.output/public/assets/InsightsPageHeader-9_kx_LL7.js +0 -1
- package/.output/public/assets/api-CtarlV0l.js +0 -1
- package/.output/public/assets/api-keys-DFF_QqtN.js +0 -1
- package/.output/public/assets/api-keys-evfsQ4sN.js +0 -1
- package/.output/public/assets/arrow-left-BP9D-ta_.js +0 -1
- package/.output/public/assets/arrow-up-right-CDP9Tevw.js +0 -1
- package/.output/public/assets/console-features-api-yv7rm3DX.js +0 -1
- package/.output/public/assets/dialog-BDvCy_QO.js +0 -1
- package/.output/public/assets/empty-C5x9mn_s.js +0 -1
- package/.output/public/assets/index-0eg3l8aC.js +0 -10
- package/.output/public/assets/insights-2MbdiSNv.js +0 -1
- package/.output/public/assets/insights-C6tmCZHk.js +0 -2
- package/.output/public/assets/insights-CadS_aIA.js +0 -42
- package/.output/public/assets/insights_.distribution-BhBs6PNA.js +0 -1
- package/.output/public/assets/insights_.distribution-CJUH_wAS.js +0 -2
- package/.output/public/assets/installations-CH2H7JQt.js +0 -2
- package/.output/public/assets/installations-DMeMuwsW.js +0 -1
- package/.output/public/assets/installations-DnvxI9Dt.js +0 -1
- package/.output/public/assets/mutation-x9L28_CD.js +0 -1
- package/.output/public/assets/preload-helper-BN-UkjOE.js +0 -1
- package/.output/public/assets/routes-BhK8DuZA.js +0 -1
- package/.output/public/assets/sidebar-B45aMqRm.js +0 -3
- package/.output/public/assets/styles-Dn0pK1EX.css +0 -2
- package/.output/public/assets/table-CwLnN6KX.js +0 -1
- package/.output/public/assets/useMutation-CN1ygYEr.js +0 -1
- package/.output/public/assets/valueToPercent-C-t8FT0y.js +0 -1
- package/.output/server/_ssr/auth.server-Ccqz2iVv.mjs +0 -37
- package/.output/server/_ssr/config-DMEJdhoW.mjs +0 -13
- package/.output/server/_ssr/config.server-DyypN6cQ.mjs +0 -60
- package/.output/server/_ssr/databaseRows-CXxf_VIB.mjs +0 -132
- package/.output/server/_ssr/deleteBundle-Dcc21Lbd.mjs +0 -56
- package/.output/server/_ssr/server-DKJYsaCP.mjs +0 -7442
- package/.output/server/_ssr/start-DpViQGnt.mjs +0 -4
- package/.output/server/_tanstack-start-manifest_v-Bb0gKDbb.mjs +0 -124
- 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
|
|
2
|
-
import {
|
|
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-
|
|
5
|
-
import { n as
|
|
6
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
1091
|
-
|
|
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$
|
|
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$
|
|
1131
|
-
if (request.eq.length !== index.eq.length || !request.eq.every(isKeyValue)) fail$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
1158
|
-
if (op.type === "patch" && Object.keys(op.set).some((column) => column === "_v" || op.table.key.includes(column))) fail$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
1205
|
-
if (!Number.isSafeInteger(limit) || limit < 1) fail$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
/**
|
|
1265
|
-
var
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
/**
|
|
1280
|
-
var
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
const
|
|
1294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
-
|
|
1407
|
-
|
|
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
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
-
|
|
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
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
var
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
};
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
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
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
|
2253
|
-
const
|
|
2254
|
-
if (
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
if (
|
|
2259
|
-
const
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
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
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
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
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
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
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
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
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
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
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
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
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
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
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
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
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
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
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
}
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
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
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
4173
|
-
*
|
|
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
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
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
|
|
4187
|
-
var
|
|
4188
|
-
|
|
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
|
|
4191
|
-
* server
|
|
4192
|
-
*
|
|
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
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
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 {
|
|
5712
|
+
export { getConsoleAccess, getConsoleAuthProviders, handleConsoleAuth, requireConsoleAccess, resolveConsoleConfig as t };
|