@metamask-previews/assets-controller 9.0.2-preview-b2742a2fc → 9.0.2-preview-a8fd340

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/AssetsController.cjs +6 -15
  3. package/dist/AssetsController.cjs.map +1 -1
  4. package/dist/AssetsController.d.cts.map +1 -1
  5. package/dist/AssetsController.d.mts.map +1 -1
  6. package/dist/AssetsController.mjs +6 -15
  7. package/dist/AssetsController.mjs.map +1 -1
  8. package/dist/data-sources/AccountsApiDataSource.cjs +1 -1
  9. package/dist/data-sources/AccountsApiDataSource.cjs.map +1 -1
  10. package/dist/data-sources/AccountsApiDataSource.mjs +1 -1
  11. package/dist/data-sources/AccountsApiDataSource.mjs.map +1 -1
  12. package/dist/data-sources/PriceDataSource.cjs +64 -13
  13. package/dist/data-sources/PriceDataSource.cjs.map +1 -1
  14. package/dist/data-sources/PriceDataSource.d.cts +13 -0
  15. package/dist/data-sources/PriceDataSource.d.cts.map +1 -1
  16. package/dist/data-sources/PriceDataSource.d.mts +13 -0
  17. package/dist/data-sources/PriceDataSource.d.mts.map +1 -1
  18. package/dist/data-sources/PriceDataSource.mjs +64 -13
  19. package/dist/data-sources/PriceDataSource.mjs.map +1 -1
  20. package/dist/data-sources/SnapDataSource.cjs +2 -2
  21. package/dist/data-sources/SnapDataSource.cjs.map +1 -1
  22. package/dist/data-sources/SnapDataSource.mjs +2 -2
  23. package/dist/data-sources/SnapDataSource.mjs.map +1 -1
  24. package/dist/middlewares/DetectionMiddleware.cjs +43 -23
  25. package/dist/middlewares/DetectionMiddleware.cjs.map +1 -1
  26. package/dist/middlewares/DetectionMiddleware.d.cts +12 -13
  27. package/dist/middlewares/DetectionMiddleware.d.cts.map +1 -1
  28. package/dist/middlewares/DetectionMiddleware.d.mts +12 -13
  29. package/dist/middlewares/DetectionMiddleware.d.mts.map +1 -1
  30. package/dist/middlewares/DetectionMiddleware.mjs +43 -23
  31. package/dist/middlewares/DetectionMiddleware.mjs.map +1 -1
  32. package/dist/utils/dedupingBatchFetcher.cjs +157 -0
  33. package/dist/utils/dedupingBatchFetcher.cjs.map +1 -0
  34. package/dist/utils/dedupingBatchFetcher.d.cts +65 -0
  35. package/dist/utils/dedupingBatchFetcher.d.cts.map +1 -0
  36. package/dist/utils/dedupingBatchFetcher.d.mts +65 -0
  37. package/dist/utils/dedupingBatchFetcher.d.mts.map +1 -0
  38. package/dist/utils/dedupingBatchFetcher.mjs +153 -0
  39. package/dist/utils/dedupingBatchFetcher.mjs.map +1 -0
  40. package/package.json +4 -4
@@ -3,16 +3,14 @@ import type { Middleware } from "../types.mjs";
3
3
  * DetectionMiddleware builds the set of assets that downstream sources use for
4
4
  * metadata and price fetching.
5
5
  *
6
- * This middleware:
7
- * - Includes every asset that appears in response.assetsBalance (so prices and
8
- * metadata are fetched for existing assets as well as new ones)
9
- * - Includes each account's custom assets from state (so custom tokens get
10
- * metadata and prices even when they have no balance yet)
11
- * - Fills response.detectedAssets with these asset IDs per account
12
- *
13
- * TokenDataSource and PriceDataSource both key off detectedAssets. TokenDataSource
14
- * then filters to only fetch metadata for assets that lack it; PriceDataSource
15
- * fetches prices for all detected assets.
6
+ * An asset is included in `detectedAssets` only when it is genuinely new:
7
+ * - Assets from `response.assetsBalance` are included only if they are absent
8
+ * from BOTH `state.assetsBalance` (never tracked before) AND `state.assetsInfo`
9
+ * (no metadata yet). Assets already present in either collection are considered
10
+ * known and are intentionally excluded — PriceDataSource's own subscription
11
+ * handles periodic refreshes for those.
12
+ * - Each account's custom assets from state are always included because they
13
+ * may have no balance yet and are explicitly managed by the user.
16
14
  *
17
15
  * Usage:
18
16
  * ```typescript
@@ -27,9 +25,10 @@ export declare class DetectionMiddleware {
27
25
  * Get the middleware that builds detectedAssets for metadata and price fetching.
28
26
  *
29
27
  * This middleware:
30
- * 1. Includes all assets from response.assetsBalance (so prices are fetched for existing assets too)
31
- * 2. Merges each account's custom assets from state
32
- * 3. Fills response.detectedAssets with these asset IDs per account
28
+ * 1. Includes assets from response.assetsBalance that are absent from both
29
+ * state.assetsBalance and state.assetsInfo (brand-new assets only)
30
+ * 2. Always includes each account's custom assets from state
31
+ * 3. Fills response.detectedAssets with the resulting asset IDs per account
33
32
  *
34
33
  * @returns The middleware function for the assets pipeline.
35
34
  */
@@ -1 +1 @@
1
- {"version":3,"file":"DetectionMiddleware.d.mts","sourceRoot":"","sources":["../../src/middlewares/DetectionMiddleware.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA4B,UAAU,EAAE,qBAAiB;AAerE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,mBAAmB;IAC9B,QAAQ,CAAC,IAAI,yBAAmB;IAEhC,OAAO,IAAI,MAAM;IAIjB;;;;;;;;;OASG;IACH,IAAI,gBAAgB,IAAI,UAAU,CAuDjC;CACF"}
1
+ {"version":3,"file":"DetectionMiddleware.d.mts","sourceRoot":"","sources":["../../src/middlewares/DetectionMiddleware.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA4B,UAAU,EAAE,qBAAiB;AAerE;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,mBAAmB;IAC9B,QAAQ,CAAC,IAAI,yBAAmB;IAEhC,OAAO,IAAI,MAAM;IAIjB;;;;;;;;;;OAUG;IACH,IAAI,gBAAgB,IAAI,UAAU,CAqFjC;CACF"}
@@ -13,16 +13,14 @@ createModuleLogger(projectLogger, CONTROLLER_NAME);
13
13
  * DetectionMiddleware builds the set of assets that downstream sources use for
14
14
  * metadata and price fetching.
15
15
  *
16
- * This middleware:
17
- * - Includes every asset that appears in response.assetsBalance (so prices and
18
- * metadata are fetched for existing assets as well as new ones)
19
- * - Includes each account's custom assets from state (so custom tokens get
20
- * metadata and prices even when they have no balance yet)
21
- * - Fills response.detectedAssets with these asset IDs per account
22
- *
23
- * TokenDataSource and PriceDataSource both key off detectedAssets. TokenDataSource
24
- * then filters to only fetch metadata for assets that lack it; PriceDataSource
25
- * fetches prices for all detected assets.
16
+ * An asset is included in `detectedAssets` only when it is genuinely new:
17
+ * - Assets from `response.assetsBalance` are included only if they are absent
18
+ * from BOTH `state.assetsBalance` (never tracked before) AND `state.assetsInfo`
19
+ * (no metadata yet). Assets already present in either collection are considered
20
+ * known and are intentionally excluded — PriceDataSource's own subscription
21
+ * handles periodic refreshes for those.
22
+ * - Each account's custom assets from state are always included because they
23
+ * may have no balance yet and are explicitly managed by the user.
26
24
  *
27
25
  * Usage:
28
26
  * ```typescript
@@ -41,47 +39,69 @@ export class DetectionMiddleware {
41
39
  * Get the middleware that builds detectedAssets for metadata and price fetching.
42
40
  *
43
41
  * This middleware:
44
- * 1. Includes all assets from response.assetsBalance (so prices are fetched for existing assets too)
45
- * 2. Merges each account's custom assets from state
46
- * 3. Fills response.detectedAssets with these asset IDs per account
42
+ * 1. Includes assets from response.assetsBalance that are absent from both
43
+ * state.assetsBalance and state.assetsInfo (brand-new assets only)
44
+ * 2. Always includes each account's custom assets from state
45
+ * 3. Fills response.detectedAssets with the resulting asset IDs per account
47
46
  *
48
47
  * @returns The middleware function for the assets pipeline.
49
48
  */
50
49
  get assetsMiddleware() {
51
50
  return forDataTypes(['balance'], async (ctx, next) => {
52
51
  const { request, response } = ctx;
53
- // Get state for custom assets
52
+ // Get state for custom assets, existing balances, and existing metadata
54
53
  const state = ctx.getAssetsState();
55
- const { customAssets: stateCustomAssets } = state;
54
+ const { customAssets: stateCustomAssets, assetsBalance: stateAssetsBalance, assetsInfo: stateAssetsInfo, } = state;
56
55
  const detectedAssets = {};
57
- // 1. From balance response: include every asset with balance (so prices + metadata path include existing assets)
56
+ // 1. From balance response: only include assets that are genuinely new —
57
+ // not already present in state.assetsBalance or state.assetsInfo.
58
58
  if (response.assetsBalance) {
59
59
  for (const [accountId, accountBalances] of Object.entries(response.assetsBalance)) {
60
60
  const detected = [];
61
+ const stateAccountBalances = stateAssetsBalance[accountId] ?? {};
61
62
  for (const assetId of Object.keys(accountBalances)) {
62
- detected.push(assetId);
63
+ const caipAssetId = assetId;
64
+ // Skip if already tracked in state balances or already has metadata
65
+ if (stateAccountBalances[caipAssetId] !== undefined ||
66
+ stateAssetsInfo[caipAssetId] !== undefined) {
67
+ continue;
68
+ }
69
+ detected.push(caipAssetId);
63
70
  }
64
- // Merge this account's custom assets from state
71
+ // Merge custom assets for this account, applying the same filter:
72
+ // skip if already in state balance or already has metadata.
65
73
  const customForAccount = stateCustomAssets?.[accountId] ?? [];
66
74
  for (const assetId of customForAccount) {
67
- if (!detected.includes(assetId)) {
68
- detected.push(assetId);
75
+ if (detected.includes(assetId)) {
76
+ continue;
69
77
  }
78
+ if (stateAccountBalances[assetId] !== undefined ||
79
+ stateAssetsInfo[assetId] !== undefined) {
80
+ continue;
81
+ }
82
+ detected.push(assetId);
70
83
  }
71
84
  if (detected.length > 0) {
72
85
  detectedAssets[accountId] = detected;
73
86
  }
74
87
  }
75
88
  }
76
- // 2. Accounts in request that weren't in balance response: include their custom assets
89
+ // 2. Accounts in request that weren't in balance response: include their
90
+ // custom assets that are not yet in state.
77
91
  for (const { account } of request.accountsWithSupportedChains) {
78
92
  const accountId = account.id;
79
93
  if (detectedAssets[accountId]) {
80
94
  continue;
81
95
  }
96
+ const stateAccountBalances = stateAssetsBalance[accountId] ?? {};
82
97
  const customForAccount = stateCustomAssets?.[accountId] ?? [];
83
- if (customForAccount.length > 0) {
84
- detectedAssets[accountId] = customForAccount;
98
+ const newCustomAssets = customForAccount.filter((assetId) => {
99
+ const inBalance = stateAccountBalances[assetId] !== undefined;
100
+ const inInfo = stateAssetsInfo[assetId] !== undefined;
101
+ return !inBalance && !inInfo;
102
+ });
103
+ if (newCustomAssets.length > 0) {
104
+ detectedAssets[accountId] = newCustomAssets;
85
105
  }
86
106
  }
87
107
  if (Object.keys(detectedAssets).length > 0) {
@@ -1 +1 @@
1
- {"version":3,"file":"DetectionMiddleware.mjs","sourceRoot":"","sources":["../../src/middlewares/DetectionMiddleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,sBAAkB;AAC9D,OAAO,EAAE,YAAY,EAAE,qBAAiB;AAGxC,+EAA+E;AAC/E,YAAY;AACZ,+EAA+E;AAE/E,MAAM,eAAe,GAAG,qBAAqB,CAAC;AAE9C,uBAAuB;AACvB,kBAAkB,CAAC,aAAa,EAAE,eAAe,CAAC,CAAC;AAEnD,+EAA+E;AAC/E,uBAAuB;AACvB,+EAA+E;AAE/E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,OAAO,mBAAmB;IAAhC;QACW,SAAI,GAAG,eAAe,CAAC;IAwElC,CAAC;IAtEC,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAC;IACnB,CAAC;IAED;;;;;;;;;OASG;IACH,IAAI,gBAAgB;QAClB,OAAO,YAAY,CAAC,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;YACnD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,GAAG,CAAC;YAElC,8BAA8B;YAC9B,MAAM,KAAK,GAAG,GAAG,CAAC,cAAc,EAAE,CAAC;YACnC,MAAM,EAAE,YAAY,EAAE,iBAAiB,EAAE,GAAG,KAAK,CAAC;YAElD,MAAM,cAAc,GAAuC,EAAE,CAAC;YAE9D,iHAAiH;YACjH,IAAI,QAAQ,CAAC,aAAa,EAAE,CAAC;gBAC3B,KAAK,MAAM,CAAC,SAAS,EAAE,eAAe,CAAC,IAAI,MAAM,CAAC,OAAO,CACvD,QAAQ,CAAC,aAAa,CACvB,EAAE,CAAC;oBACF,MAAM,QAAQ,GAAoB,EAAE,CAAC;oBAErC,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,IAAI,CAC/B,eAA0C,CAC3C,EAAE,CAAC;wBACF,QAAQ,CAAC,IAAI,CAAC,OAAwB,CAAC,CAAC;oBAC1C,CAAC;oBAED,gDAAgD;oBAChD,MAAM,gBAAgB,GAAG,iBAAiB,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;oBAC9D,KAAK,MAAM,OAAO,IAAI,gBAAgB,EAAE,CAAC;wBACvC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;4BAChC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;wBACzB,CAAC;oBACH,CAAC;oBAED,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;wBACxB,cAAc,CAAC,SAAS,CAAC,GAAG,QAAQ,CAAC;oBACvC,CAAC;gBACH,CAAC;YACH,CAAC;YAED,uFAAuF;YACvF,KAAK,MAAM,EAAE,OAAO,EAAE,IAAI,OAAO,CAAC,2BAA2B,EAAE,CAAC;gBAC9D,MAAM,SAAS,GAAG,OAAO,CAAC,EAAE,CAAC;gBAC7B,IAAI,cAAc,CAAC,SAAS,CAAC,EAAE,CAAC;oBAC9B,SAAS;gBACX,CAAC;gBACD,MAAM,gBAAgB,GAAG,iBAAiB,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;gBAC9D,IAAI,gBAAgB,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAChC,cAAc,CAAC,SAAS,CAAC,GAAG,gBAAgB,CAAC;gBAC/C,CAAC;YACH,CAAC;YAED,IAAI,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAC3C,QAAQ,CAAC,cAAc,GAAG,cAAc,CAAC;YAC3C,CAAC;YAED,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;IACL,CAAC;CACF","sourcesContent":["import { projectLogger, createModuleLogger } from '../logger';\nimport { forDataTypes } from '../types';\nimport type { AccountId, Caip19AssetId, Middleware } from '../types';\n\n// ============================================================================\n// CONSTANTS\n// ============================================================================\n\nconst CONTROLLER_NAME = 'DetectionMiddleware';\n\n// Logger for debugging\ncreateModuleLogger(projectLogger, CONTROLLER_NAME);\n\n// ============================================================================\n// DETECTION MIDDLEWARE\n// ============================================================================\n\n/**\n * DetectionMiddleware builds the set of assets that downstream sources use for\n * metadata and price fetching.\n *\n * This middleware:\n * - Includes every asset that appears in response.assetsBalance (so prices and\n * metadata are fetched for existing assets as well as new ones)\n * - Includes each account's custom assets from state (so custom tokens get\n * metadata and prices even when they have no balance yet)\n * - Fills response.detectedAssets with these asset IDs per account\n *\n * TokenDataSource and PriceDataSource both key off detectedAssets. TokenDataSource\n * then filters to only fetch metadata for assets that lack it; PriceDataSource\n * fetches prices for all detected assets.\n *\n * Usage:\n * ```typescript\n * const detectionMiddleware = new DetectionMiddleware();\n * const middleware = detectionMiddleware.assetsMiddleware;\n * ```\n */\nexport class DetectionMiddleware {\n readonly name = CONTROLLER_NAME;\n\n getName(): string {\n return this.name;\n }\n\n /**\n * Get the middleware that builds detectedAssets for metadata and price fetching.\n *\n * This middleware:\n * 1. Includes all assets from response.assetsBalance (so prices are fetched for existing assets too)\n * 2. Merges each account's custom assets from state\n * 3. Fills response.detectedAssets with these asset IDs per account\n *\n * @returns The middleware function for the assets pipeline.\n */\n get assetsMiddleware(): Middleware {\n return forDataTypes(['balance'], async (ctx, next) => {\n const { request, response } = ctx;\n\n // Get state for custom assets\n const state = ctx.getAssetsState();\n const { customAssets: stateCustomAssets } = state;\n\n const detectedAssets: Record<AccountId, Caip19AssetId[]> = {};\n\n // 1. From balance response: include every asset with balance (so prices + metadata path include existing assets)\n if (response.assetsBalance) {\n for (const [accountId, accountBalances] of Object.entries(\n response.assetsBalance,\n )) {\n const detected: Caip19AssetId[] = [];\n\n for (const assetId of Object.keys(\n accountBalances as Record<string, unknown>,\n )) {\n detected.push(assetId as Caip19AssetId);\n }\n\n // Merge this account's custom assets from state\n const customForAccount = stateCustomAssets?.[accountId] ?? [];\n for (const assetId of customForAccount) {\n if (!detected.includes(assetId)) {\n detected.push(assetId);\n }\n }\n\n if (detected.length > 0) {\n detectedAssets[accountId] = detected;\n }\n }\n }\n\n // 2. Accounts in request that weren't in balance response: include their custom assets\n for (const { account } of request.accountsWithSupportedChains) {\n const accountId = account.id;\n if (detectedAssets[accountId]) {\n continue;\n }\n const customForAccount = stateCustomAssets?.[accountId] ?? [];\n if (customForAccount.length > 0) {\n detectedAssets[accountId] = customForAccount;\n }\n }\n\n if (Object.keys(detectedAssets).length > 0) {\n response.detectedAssets = detectedAssets;\n }\n\n return next(ctx);\n });\n }\n}\n"]}
1
+ {"version":3,"file":"DetectionMiddleware.mjs","sourceRoot":"","sources":["../../src/middlewares/DetectionMiddleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { projectLogger, createModuleLogger } from '../logger';\nimport { forDataTypes } from '../types';\nimport type { AccountId, Caip19AssetId, Middleware } from '../types';\n\n// ============================================================================\n// CONSTANTS\n// ============================================================================\n\nconst CONTROLLER_NAME = 'DetectionMiddleware';\n\n// Logger for debugging\ncreateModuleLogger(projectLogger, CONTROLLER_NAME);\n\n// ============================================================================\n// DETECTION MIDDLEWARE\n// ============================================================================\n\n/**\n * DetectionMiddleware builds the set of assets that downstream sources use for\n * metadata and price fetching.\n *\n * An asset is included in `detectedAssets` only when it is genuinely new:\n * - Assets from `response.assetsBalance` are included only if they are absent\n * from BOTH `state.assetsBalance` (never tracked before) AND `state.assetsInfo`\n * (no metadata yet). Assets already present in either collection are considered\n * known and are intentionally excluded — PriceDataSource's own subscription\n * handles periodic refreshes for those.\n * - Each account's custom assets from state are always included because they\n * may have no balance yet and are explicitly managed by the user.\n *\n * Usage:\n * ```typescript\n * const detectionMiddleware = new DetectionMiddleware();\n * const middleware = detectionMiddleware.assetsMiddleware;\n * ```\n */\nexport class DetectionMiddleware {\n readonly name = CONTROLLER_NAME;\n\n getName(): string {\n return this.name;\n }\n\n /**\n * Get the middleware that builds detectedAssets for metadata and price fetching.\n *\n * This middleware:\n * 1. Includes assets from response.assetsBalance that are absent from both\n * state.assetsBalance and state.assetsInfo (brand-new assets only)\n * 2. Always includes each account's custom assets from state\n * 3. Fills response.detectedAssets with the resulting asset IDs per account\n *\n * @returns The middleware function for the assets pipeline.\n */\n get assetsMiddleware(): Middleware {\n return forDataTypes(['balance'], async (ctx, next) => {\n const { request, response } = ctx;\n\n // Get state for custom assets, existing balances, and existing metadata\n const state = ctx.getAssetsState();\n const {\n customAssets: stateCustomAssets,\n assetsBalance: stateAssetsBalance,\n assetsInfo: stateAssetsInfo,\n } = state;\n\n const detectedAssets: Record<AccountId, Caip19AssetId[]> = {};\n\n // 1. From balance response: only include assets that are genuinely new —\n // not already present in state.assetsBalance or state.assetsInfo.\n if (response.assetsBalance) {\n for (const [accountId, accountBalances] of Object.entries(\n response.assetsBalance,\n )) {\n const detected: Caip19AssetId[] = [];\n\n const stateAccountBalances = stateAssetsBalance[accountId] ?? {};\n\n for (const assetId of Object.keys(\n accountBalances as Record<string, unknown>,\n )) {\n const caipAssetId = assetId as Caip19AssetId;\n // Skip if already tracked in state balances or already has metadata\n if (\n stateAccountBalances[caipAssetId] !== undefined ||\n stateAssetsInfo[caipAssetId] !== undefined\n ) {\n continue;\n }\n detected.push(caipAssetId);\n }\n\n // Merge custom assets for this account, applying the same filter:\n // skip if already in state balance or already has metadata.\n const customForAccount = stateCustomAssets?.[accountId] ?? [];\n for (const assetId of customForAccount) {\n if (detected.includes(assetId)) {\n continue;\n }\n if (\n stateAccountBalances[assetId] !== undefined ||\n stateAssetsInfo[assetId] !== undefined\n ) {\n continue;\n }\n detected.push(assetId);\n }\n\n if (detected.length > 0) {\n detectedAssets[accountId] = detected;\n }\n }\n }\n\n // 2. Accounts in request that weren't in balance response: include their\n // custom assets that are not yet in state.\n for (const { account } of request.accountsWithSupportedChains) {\n const accountId = account.id;\n if (detectedAssets[accountId]) {\n continue;\n }\n const stateAccountBalances = stateAssetsBalance[accountId] ?? {};\n const customForAccount = stateCustomAssets?.[accountId] ?? [];\n const newCustomAssets = customForAccount.filter((assetId) => {\n const inBalance = stateAccountBalances[assetId] !== undefined;\n const inInfo = stateAssetsInfo[assetId] !== undefined;\n return !inBalance && !inInfo;\n });\n if (newCustomAssets.length > 0) {\n detectedAssets[accountId] = newCustomAssets;\n }\n }\n\n if (Object.keys(detectedAssets).length > 0) {\n response.detectedAssets = detectedAssets;\n }\n\n return next(ctx);\n });\n }\n}\n"]}
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
3
+ if (kind === "m") throw new TypeError("Private method is not writable");
4
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
5
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
6
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
7
+ };
8
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
9
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
10
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
11
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
12
+ };
13
+ var _DedupingBatchFetcher_instances, _DedupingBatchFetcher_fetchBatch, _DedupingBatchFetcher_freshnessTtlMs, _DedupingBatchFetcher_fetchedAt, _DedupingBatchFetcher_inflight, _DedupingBatchFetcher_partition, _DedupingBatchFetcher_isFresh, _DedupingBatchFetcher_startBatchFetch, _DedupingBatchFetcher_joinInflight;
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.DedupingBatchFetcher = void 0;
16
+ /**
17
+ * Deduplicates batched fetches by key across two dimensions:
18
+ *
19
+ * 1. **Freshness TTL** — keys fetched within `freshnessTtlMs` are skipped
20
+ * entirely. Note the freshness window only starts once a fetch *completes*,
21
+ * so it does not cover requests that are still in flight.
22
+ * 2. **Inflight coalescing** — if a fetch is already in progress for a key,
23
+ * concurrent callers join the existing promise instead of issuing a new
24
+ * request. This covers the request-in-flight window the freshness TTL
25
+ * structurally cannot.
26
+ *
27
+ * Both layers are per-key, so a call for a partially-overlapping set of keys
28
+ * reuses the fresh/inflight keys and only fetches the genuinely-missing ones.
29
+ *
30
+ * The fetch is batched: all stale, not-inflight keys from a single `fetch()`
31
+ * call are passed to `fetchBatch` together, then split into per-key results so
32
+ * each key can be joined independently.
33
+ */
34
+ class DedupingBatchFetcher {
35
+ constructor(options) {
36
+ _DedupingBatchFetcher_instances.add(this);
37
+ _DedupingBatchFetcher_fetchBatch.set(this, void 0);
38
+ _DedupingBatchFetcher_freshnessTtlMs.set(this, void 0);
39
+ /** Tracks the last successful fetch time per key (freshness gating). */
40
+ _DedupingBatchFetcher_fetchedAt.set(this, new Map());
41
+ /**
42
+ * Per-key inflight fetch promises. Each resolves to the value, or `undefined`
43
+ * if the batch failed or returned no data for that key.
44
+ */
45
+ _DedupingBatchFetcher_inflight.set(this, new Map());
46
+ __classPrivateFieldSet(this, _DedupingBatchFetcher_fetchBatch, options.fetchBatch, "f");
47
+ __classPrivateFieldSet(this, _DedupingBatchFetcher_freshnessTtlMs, options.freshnessTtlMs, "f");
48
+ }
49
+ /**
50
+ * Minimum age (ms) before a key is re-fetched.
51
+ *
52
+ * @returns The current freshness TTL in milliseconds.
53
+ */
54
+ get freshnessTtlMs() {
55
+ return __classPrivateFieldGet(this, _DedupingBatchFetcher_freshnessTtlMs, "f");
56
+ }
57
+ set freshnessTtlMs(ms) {
58
+ __classPrivateFieldSet(this, _DedupingBatchFetcher_freshnessTtlMs, ms, "f");
59
+ }
60
+ /**
61
+ * Fetch values for the given keys, deduplicating against fresh and inflight
62
+ * fetches.
63
+ *
64
+ * @param keys - The keys to fetch.
65
+ * @returns Values keyed by key. Only contains entries for keys that were
66
+ * actually fetched (or joined from inflight) and had a value.
67
+ */
68
+ async fetch(keys) {
69
+ const { staleKeys, inflightKeys } = __classPrivateFieldGet(this, _DedupingBatchFetcher_instances, "m", _DedupingBatchFetcher_partition).call(this, keys);
70
+ if (staleKeys.length === 0 && inflightKeys.length === 0) {
71
+ return {};
72
+ }
73
+ // Start a fetch for stale keys and join any fetches already in progress.
74
+ const batchPromise = staleKeys.length > 0 ? __classPrivateFieldGet(this, _DedupingBatchFetcher_instances, "m", _DedupingBatchFetcher_startBatchFetch).call(this, staleKeys) : undefined;
75
+ const values = await __classPrivateFieldGet(this, _DedupingBatchFetcher_instances, "m", _DedupingBatchFetcher_joinInflight).call(this, inflightKeys);
76
+ if (batchPromise) {
77
+ Object.assign(values, await batchPromise);
78
+ }
79
+ return values;
80
+ }
81
+ /**
82
+ * Clear the freshness cache, forcing the next fetch to re-request every key
83
+ * regardless of TTL. Does not affect inflight fetches.
84
+ */
85
+ invalidate() {
86
+ __classPrivateFieldGet(this, _DedupingBatchFetcher_fetchedAt, "f").clear();
87
+ }
88
+ /** Clear all freshness and inflight state. */
89
+ destroy() {
90
+ __classPrivateFieldGet(this, _DedupingBatchFetcher_fetchedAt, "f").clear();
91
+ __classPrivateFieldGet(this, _DedupingBatchFetcher_inflight, "f").clear();
92
+ }
93
+ }
94
+ exports.DedupingBatchFetcher = DedupingBatchFetcher;
95
+ _DedupingBatchFetcher_fetchBatch = new WeakMap(), _DedupingBatchFetcher_freshnessTtlMs = new WeakMap(), _DedupingBatchFetcher_fetchedAt = new WeakMap(), _DedupingBatchFetcher_inflight = new WeakMap(), _DedupingBatchFetcher_instances = new WeakSet(), _DedupingBatchFetcher_partition = function _DedupingBatchFetcher_partition(keys) {
96
+ const now = Date.now();
97
+ const staleKeys = [];
98
+ const inflightKeys = [];
99
+ for (const key of keys) {
100
+ if (__classPrivateFieldGet(this, _DedupingBatchFetcher_instances, "m", _DedupingBatchFetcher_isFresh).call(this, key, now)) {
101
+ continue;
102
+ }
103
+ if (__classPrivateFieldGet(this, _DedupingBatchFetcher_inflight, "f").has(key)) {
104
+ inflightKeys.push(key);
105
+ }
106
+ else {
107
+ staleKeys.push(key);
108
+ }
109
+ }
110
+ return { staleKeys, inflightKeys };
111
+ }, _DedupingBatchFetcher_isFresh = function _DedupingBatchFetcher_isFresh(key, now) {
112
+ const fetchedAt = __classPrivateFieldGet(this, _DedupingBatchFetcher_fetchedAt, "f").get(key);
113
+ return fetchedAt !== undefined && now - fetchedAt < __classPrivateFieldGet(this, _DedupingBatchFetcher_freshnessTtlMs, "f");
114
+ }, _DedupingBatchFetcher_startBatchFetch = function _DedupingBatchFetcher_startBatchFetch(staleKeys) {
115
+ const batchPromise = __classPrivateFieldGet(this, _DedupingBatchFetcher_fetchBatch, "f").call(this, staleKeys).then((values) => {
116
+ const fetchedAt = Date.now();
117
+ for (const key of staleKeys) {
118
+ __classPrivateFieldGet(this, _DedupingBatchFetcher_fetchedAt, "f").set(key, fetchedAt);
119
+ }
120
+ return values;
121
+ });
122
+ for (const key of staleKeys) {
123
+ const perKey = batchPromise.then((values) => values[key], () => undefined);
124
+ __classPrivateFieldGet(this, _DedupingBatchFetcher_inflight, "f").set(key, perKey);
125
+ }
126
+ // Clean up inflight entries once the batch settles (success or failure).
127
+ // Rejection is already surfaced to the caller via the returned batchPromise.
128
+ batchPromise
129
+ .finally(() => {
130
+ for (const key of staleKeys) {
131
+ __classPrivateFieldGet(this, _DedupingBatchFetcher_inflight, "f").delete(key);
132
+ }
133
+ })
134
+ .catch(() => undefined);
135
+ return batchPromise;
136
+ }, _DedupingBatchFetcher_joinInflight =
137
+ /**
138
+ * Join the inflight fetches for the given keys and collect their values. Keys
139
+ * whose inflight fetch produced no value are omitted.
140
+ *
141
+ * @param inflightKeys - Keys whose fetches are already in progress.
142
+ * @returns Values keyed by key.
143
+ */
144
+ async function _DedupingBatchFetcher_joinInflight(inflightKeys) {
145
+ const values = {};
146
+ const results = await Promise.all(inflightKeys.map(async (key) => {
147
+ const value = await __classPrivateFieldGet(this, _DedupingBatchFetcher_inflight, "f").get(key);
148
+ return [key, value];
149
+ }));
150
+ for (const [key, value] of results) {
151
+ if (value !== undefined) {
152
+ values[key] = value;
153
+ }
154
+ }
155
+ return values;
156
+ };
157
+ //# sourceMappingURL=dedupingBatchFetcher.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dedupingBatchFetcher.cjs","sourceRoot":"","sources":["../../src/utils/dedupingBatchFetcher.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Executes the underlying batched fetch for a set of keys. Only keys that have\n * a value need to be present in the returned record; keys the source had no\n * data for are simply omitted (they are still marked fresh — see\n * {@link DedupingBatchFetcher.fetch}).\n *\n * @param keys - The keys to fetch (already filtered to stale, not-inflight keys).\n * @returns The fetched values keyed by key.\n */\nexport type BatchFetchFn<Key extends string, Value> = (\n keys: Key[],\n) => Promise<Record<Key, Value>>;\n\nexport type DedupingBatchFetcherOptions<Key extends string, Value> = {\n /** Performs the actual batched fetch for stale, not-inflight keys. */\n fetchBatch: BatchFetchFn<Key, Value>;\n /**\n * Minimum age (ms) before a key is considered stale and re-fetched. Keys\n * fetched more recently than this are skipped entirely.\n */\n freshnessTtlMs: number;\n};\n\n/**\n * Deduplicates batched fetches by key across two dimensions:\n *\n * 1. **Freshness TTL** — keys fetched within `freshnessTtlMs` are skipped\n * entirely. Note the freshness window only starts once a fetch *completes*,\n * so it does not cover requests that are still in flight.\n * 2. **Inflight coalescing** — if a fetch is already in progress for a key,\n * concurrent callers join the existing promise instead of issuing a new\n * request. This covers the request-in-flight window the freshness TTL\n * structurally cannot.\n *\n * Both layers are per-key, so a call for a partially-overlapping set of keys\n * reuses the fresh/inflight keys and only fetches the genuinely-missing ones.\n *\n * The fetch is batched: all stale, not-inflight keys from a single `fetch()`\n * call are passed to `fetchBatch` together, then split into per-key results so\n * each key can be joined independently.\n */\nexport class DedupingBatchFetcher<Key extends string, Value> {\n readonly #fetchBatch: BatchFetchFn<Key, Value>;\n\n #freshnessTtlMs: number;\n\n /** Tracks the last successful fetch time per key (freshness gating). */\n readonly #fetchedAt = new Map<Key, number>();\n\n /**\n * Per-key inflight fetch promises. Each resolves to the value, or `undefined`\n * if the batch failed or returned no data for that key.\n */\n readonly #inflight = new Map<Key, Promise<Value | undefined>>();\n\n constructor(options: DedupingBatchFetcherOptions<Key, Value>) {\n this.#fetchBatch = options.fetchBatch;\n this.#freshnessTtlMs = options.freshnessTtlMs;\n }\n\n /**\n * Minimum age (ms) before a key is re-fetched.\n *\n * @returns The current freshness TTL in milliseconds.\n */\n get freshnessTtlMs(): number {\n return this.#freshnessTtlMs;\n }\n\n set freshnessTtlMs(ms: number) {\n this.#freshnessTtlMs = ms;\n }\n\n /**\n * Fetch values for the given keys, deduplicating against fresh and inflight\n * fetches.\n *\n * @param keys - The keys to fetch.\n * @returns Values keyed by key. Only contains entries for keys that were\n * actually fetched (or joined from inflight) and had a value.\n */\n async fetch(keys: Key[]): Promise<Record<Key, Value>> {\n const { staleKeys, inflightKeys } = this.#partition(keys);\n\n if (staleKeys.length === 0 && inflightKeys.length === 0) {\n return {} as Record<Key, Value>;\n }\n\n // Start a fetch for stale keys and join any fetches already in progress.\n const batchPromise =\n staleKeys.length > 0 ? this.#startBatchFetch(staleKeys) : undefined;\n const values = await this.#joinInflight(inflightKeys);\n\n if (batchPromise) {\n Object.assign(values, await batchPromise);\n }\n\n return values;\n }\n\n /**\n * Clear the freshness cache, forcing the next fetch to re-request every key\n * regardless of TTL. Does not affect inflight fetches.\n */\n invalidate(): void {\n this.#fetchedAt.clear();\n }\n\n /** Clear all freshness and inflight state. */\n destroy(): void {\n this.#fetchedAt.clear();\n this.#inflight.clear();\n }\n\n /**\n * Split keys into those that need a fresh fetch and those already being\n * fetched by another caller. Keys still within the freshness TTL are dropped.\n *\n * @param keys - The keys to classify.\n * @returns `staleKeys` (need fetching) and `inflightKeys` (join existing fetch).\n */\n #partition(keys: Key[]): { staleKeys: Key[]; inflightKeys: Key[] } {\n const now = Date.now();\n const staleKeys: Key[] = [];\n const inflightKeys: Key[] = [];\n\n for (const key of keys) {\n if (this.#isFresh(key, now)) {\n continue;\n }\n if (this.#inflight.has(key)) {\n inflightKeys.push(key);\n } else {\n staleKeys.push(key);\n }\n }\n\n return { staleKeys, inflightKeys };\n }\n\n /**\n * Returns true if the key's last fetch is still within the freshness TTL.\n *\n * @param key - The key to check.\n * @param now - Current timestamp (avoids repeated clock reads).\n * @returns True if the key was fetched within the freshness TTL.\n */\n #isFresh(key: Key, now: number): boolean {\n const fetchedAt = this.#fetchedAt.get(key);\n return fetchedAt !== undefined && now - fetchedAt < this.#freshnessTtlMs;\n }\n\n /**\n * Launch a batch fetch and register a per-key inflight promise for each key\n * so concurrent callers can join. On success, all requested keys are marked\n * fresh — including keys the source returned no value for, since the absence\n * of a value is itself a valid answer that should not be re-asked until the\n * TTL expires. A failed batch leaves keys stale so they are retried.\n *\n * @param staleKeys - Keys to fetch (none of which are already inflight).\n * @returns The batch fetch promise.\n */\n #startBatchFetch(staleKeys: Key[]): Promise<Record<Key, Value>> {\n const batchPromise = this.#fetchBatch(staleKeys).then((values) => {\n const fetchedAt = Date.now();\n for (const key of staleKeys) {\n this.#fetchedAt.set(key, fetchedAt);\n }\n return values;\n });\n\n for (const key of staleKeys) {\n const perKey = batchPromise.then(\n (values) => values[key],\n () => undefined,\n );\n this.#inflight.set(key, perKey);\n }\n\n // Clean up inflight entries once the batch settles (success or failure).\n // Rejection is already surfaced to the caller via the returned batchPromise.\n batchPromise\n .finally(() => {\n for (const key of staleKeys) {\n this.#inflight.delete(key);\n }\n })\n .catch(() => undefined);\n\n return batchPromise;\n }\n\n /**\n * Join the inflight fetches for the given keys and collect their values. Keys\n * whose inflight fetch produced no value are omitted.\n *\n * @param inflightKeys - Keys whose fetches are already in progress.\n * @returns Values keyed by key.\n */\n async #joinInflight(inflightKeys: Key[]): Promise<Record<Key, Value>> {\n const values = {} as Record<Key, Value>;\n\n const results = await Promise.all(\n inflightKeys.map(async (key) => {\n const value = await this.#inflight.get(key);\n return [key, value] as const;\n }),\n );\n\n for (const [key, value] of results) {\n if (value !== undefined) {\n values[key] = value;\n }\n }\n\n return values;\n }\n}\n"]}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Executes the underlying batched fetch for a set of keys. Only keys that have
3
+ * a value need to be present in the returned record; keys the source had no
4
+ * data for are simply omitted (they are still marked fresh — see
5
+ * {@link DedupingBatchFetcher.fetch}).
6
+ *
7
+ * @param keys - The keys to fetch (already filtered to stale, not-inflight keys).
8
+ * @returns The fetched values keyed by key.
9
+ */
10
+ export type BatchFetchFn<Key extends string, Value> = (keys: Key[]) => Promise<Record<Key, Value>>;
11
+ export type DedupingBatchFetcherOptions<Key extends string, Value> = {
12
+ /** Performs the actual batched fetch for stale, not-inflight keys. */
13
+ fetchBatch: BatchFetchFn<Key, Value>;
14
+ /**
15
+ * Minimum age (ms) before a key is considered stale and re-fetched. Keys
16
+ * fetched more recently than this are skipped entirely.
17
+ */
18
+ freshnessTtlMs: number;
19
+ };
20
+ /**
21
+ * Deduplicates batched fetches by key across two dimensions:
22
+ *
23
+ * 1. **Freshness TTL** — keys fetched within `freshnessTtlMs` are skipped
24
+ * entirely. Note the freshness window only starts once a fetch *completes*,
25
+ * so it does not cover requests that are still in flight.
26
+ * 2. **Inflight coalescing** — if a fetch is already in progress for a key,
27
+ * concurrent callers join the existing promise instead of issuing a new
28
+ * request. This covers the request-in-flight window the freshness TTL
29
+ * structurally cannot.
30
+ *
31
+ * Both layers are per-key, so a call for a partially-overlapping set of keys
32
+ * reuses the fresh/inflight keys and only fetches the genuinely-missing ones.
33
+ *
34
+ * The fetch is batched: all stale, not-inflight keys from a single `fetch()`
35
+ * call are passed to `fetchBatch` together, then split into per-key results so
36
+ * each key can be joined independently.
37
+ */
38
+ export declare class DedupingBatchFetcher<Key extends string, Value> {
39
+ #private;
40
+ constructor(options: DedupingBatchFetcherOptions<Key, Value>);
41
+ /**
42
+ * Minimum age (ms) before a key is re-fetched.
43
+ *
44
+ * @returns The current freshness TTL in milliseconds.
45
+ */
46
+ get freshnessTtlMs(): number;
47
+ set freshnessTtlMs(ms: number);
48
+ /**
49
+ * Fetch values for the given keys, deduplicating against fresh and inflight
50
+ * fetches.
51
+ *
52
+ * @param keys - The keys to fetch.
53
+ * @returns Values keyed by key. Only contains entries for keys that were
54
+ * actually fetched (or joined from inflight) and had a value.
55
+ */
56
+ fetch(keys: Key[]): Promise<Record<Key, Value>>;
57
+ /**
58
+ * Clear the freshness cache, forcing the next fetch to re-request every key
59
+ * regardless of TTL. Does not affect inflight fetches.
60
+ */
61
+ invalidate(): void;
62
+ /** Clear all freshness and inflight state. */
63
+ destroy(): void;
64
+ }
65
+ //# sourceMappingURL=dedupingBatchFetcher.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dedupingBatchFetcher.d.cts","sourceRoot":"","sources":["../../src/utils/dedupingBatchFetcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,MAAM,YAAY,CAAC,GAAG,SAAS,MAAM,EAAE,KAAK,IAAI,CACpD,IAAI,EAAE,GAAG,EAAE,KACR,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAEjC,MAAM,MAAM,2BAA2B,CAAC,GAAG,SAAS,MAAM,EAAE,KAAK,IAAI;IACnE,sEAAsE;IACtE,UAAU,EAAE,YAAY,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACrC;;;OAGG;IACH,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,oBAAoB,CAAC,GAAG,SAAS,MAAM,EAAE,KAAK;;gBAc7C,OAAO,EAAE,2BAA2B,CAAC,GAAG,EAAE,KAAK,CAAC;IAK5D;;;;OAIG;IACH,IAAI,cAAc,IAAI,MAAM,CAE3B;IAED,IAAI,cAAc,CAAC,EAAE,EAAE,MAAM,EAE5B;IAED;;;;;;;OAOG;IACG,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAmBrD;;;OAGG;IACH,UAAU,IAAI,IAAI;IAIlB,8CAA8C;IAC9C,OAAO,IAAI,IAAI;CA4GhB"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Executes the underlying batched fetch for a set of keys. Only keys that have
3
+ * a value need to be present in the returned record; keys the source had no
4
+ * data for are simply omitted (they are still marked fresh — see
5
+ * {@link DedupingBatchFetcher.fetch}).
6
+ *
7
+ * @param keys - The keys to fetch (already filtered to stale, not-inflight keys).
8
+ * @returns The fetched values keyed by key.
9
+ */
10
+ export type BatchFetchFn<Key extends string, Value> = (keys: Key[]) => Promise<Record<Key, Value>>;
11
+ export type DedupingBatchFetcherOptions<Key extends string, Value> = {
12
+ /** Performs the actual batched fetch for stale, not-inflight keys. */
13
+ fetchBatch: BatchFetchFn<Key, Value>;
14
+ /**
15
+ * Minimum age (ms) before a key is considered stale and re-fetched. Keys
16
+ * fetched more recently than this are skipped entirely.
17
+ */
18
+ freshnessTtlMs: number;
19
+ };
20
+ /**
21
+ * Deduplicates batched fetches by key across two dimensions:
22
+ *
23
+ * 1. **Freshness TTL** — keys fetched within `freshnessTtlMs` are skipped
24
+ * entirely. Note the freshness window only starts once a fetch *completes*,
25
+ * so it does not cover requests that are still in flight.
26
+ * 2. **Inflight coalescing** — if a fetch is already in progress for a key,
27
+ * concurrent callers join the existing promise instead of issuing a new
28
+ * request. This covers the request-in-flight window the freshness TTL
29
+ * structurally cannot.
30
+ *
31
+ * Both layers are per-key, so a call for a partially-overlapping set of keys
32
+ * reuses the fresh/inflight keys and only fetches the genuinely-missing ones.
33
+ *
34
+ * The fetch is batched: all stale, not-inflight keys from a single `fetch()`
35
+ * call are passed to `fetchBatch` together, then split into per-key results so
36
+ * each key can be joined independently.
37
+ */
38
+ export declare class DedupingBatchFetcher<Key extends string, Value> {
39
+ #private;
40
+ constructor(options: DedupingBatchFetcherOptions<Key, Value>);
41
+ /**
42
+ * Minimum age (ms) before a key is re-fetched.
43
+ *
44
+ * @returns The current freshness TTL in milliseconds.
45
+ */
46
+ get freshnessTtlMs(): number;
47
+ set freshnessTtlMs(ms: number);
48
+ /**
49
+ * Fetch values for the given keys, deduplicating against fresh and inflight
50
+ * fetches.
51
+ *
52
+ * @param keys - The keys to fetch.
53
+ * @returns Values keyed by key. Only contains entries for keys that were
54
+ * actually fetched (or joined from inflight) and had a value.
55
+ */
56
+ fetch(keys: Key[]): Promise<Record<Key, Value>>;
57
+ /**
58
+ * Clear the freshness cache, forcing the next fetch to re-request every key
59
+ * regardless of TTL. Does not affect inflight fetches.
60
+ */
61
+ invalidate(): void;
62
+ /** Clear all freshness and inflight state. */
63
+ destroy(): void;
64
+ }
65
+ //# sourceMappingURL=dedupingBatchFetcher.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dedupingBatchFetcher.d.mts","sourceRoot":"","sources":["../../src/utils/dedupingBatchFetcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,MAAM,YAAY,CAAC,GAAG,SAAS,MAAM,EAAE,KAAK,IAAI,CACpD,IAAI,EAAE,GAAG,EAAE,KACR,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAEjC,MAAM,MAAM,2BAA2B,CAAC,GAAG,SAAS,MAAM,EAAE,KAAK,IAAI;IACnE,sEAAsE;IACtE,UAAU,EAAE,YAAY,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACrC;;;OAGG;IACH,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,oBAAoB,CAAC,GAAG,SAAS,MAAM,EAAE,KAAK;;gBAc7C,OAAO,EAAE,2BAA2B,CAAC,GAAG,EAAE,KAAK,CAAC;IAK5D;;;;OAIG;IACH,IAAI,cAAc,IAAI,MAAM,CAE3B;IAED,IAAI,cAAc,CAAC,EAAE,EAAE,MAAM,EAE5B;IAED;;;;;;;OAOG;IACG,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAmBrD;;;OAGG;IACH,UAAU,IAAI,IAAI;IAIlB,8CAA8C;IAC9C,OAAO,IAAI,IAAI;CA4GhB"}