diff --git a/CHANGES.txt b/CHANGES.txt index 47faaa87..4a80a5e8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +2.5.0 (September 10, 2025) + - Added `factory.getRolloutPlan()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage. + - Added `initialRolloutPlan` configuration option for standalone client-side SDKs, which allows preloading the SDK storage with a snapshot of the rollout plan. + 2.4.1 (June 3, 2025) - Bugfix - Improved the Proxy fallback to flag spec version 1.2 to handle cases where the Proxy does not return an end-of-stream marker in 400 status code responses. @@ -9,7 +13,7 @@ - Updated the Redis storage to: - Avoid lazy require of the `ioredis` dependency when the SDK is initialized, and - Flag the SDK as ready from cache immediately to allow queueing feature flag evaluations before SDK_READY event is emitted (Reverted in v1.7.0). - - Bugfix - Enhanced HTTP client module to implement timeouts for failing requests that might otherwise remain pending indefinitely on some Fetch API implementations. + - Bugfix - Enhanced HTTP client module to implement timeouts for failing requests that might otherwise remain pending indefinitely on some Fetch API implementations, pausing the SDK synchronization process. 2.2.0 (March 28, 2025) - Added a new optional argument to the client `getTreatment` methods to allow passing additional evaluation options, such as a map of properties to append to the generated impressions sent to Split backend. Read more in our docs. diff --git a/package-lock.json b/package-lock.json index ea4ce2f6..14125ac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.5.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", @@ -1780,10 +1780,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2218,10 +2219,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2317,6 +2319,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2666,6 +2681,20 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.33", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.33.tgz", @@ -2733,6 +2762,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -3705,14 +3779,16 @@ "dev": true }, "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3766,14 +3842,24 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3788,6 +3874,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3883,6 +3982,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3917,9 +4028,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "engines": { "node": ">= 0.4" @@ -3929,12 +4040,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -6449,6 +6560,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9378,9 +9498,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -9700,9 +9820,9 @@ "dev": true }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "requires": { "balanced-match": "^1.0.0", @@ -9770,6 +9890,16 @@ "get-intrinsic": "^1.0.2" } }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -10020,6 +10150,17 @@ } } }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "electron-to-chromium": { "version": "1.5.33", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.33.tgz", @@ -10075,6 +10216,39 @@ "unbox-primitive": "^1.0.1" } }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, "es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -10797,14 +10971,16 @@ "dev": true }, "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" } }, "fs.realpath": { @@ -10839,14 +11015,21 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" } }, "get-package-type": { @@ -10855,6 +11038,16 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -10920,6 +11113,12 @@ "slash": "^3.0.0" } }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true + }, "graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -10948,18 +11147,18 @@ "dev": true }, "has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true }, "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "requires": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" } }, "hasown": { @@ -12828,6 +13027,12 @@ "tmpl": "1.0.5" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 27b15da2..155f650a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.5.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/evaluator/convertions/index.ts b/src/evaluator/convertions/index.ts index 7d7384d7..acad8017 100644 --- a/src/evaluator/convertions/index.ts +++ b/src/evaluator/convertions/index.ts @@ -1,3 +1,5 @@ +import { IBetweenMatcherData } from '../../dtos/types'; + export function zeroSinceHH(millisSinceEpoch: number): number { return new Date(millisSinceEpoch).setUTCHours(0, 0, 0, 0); } @@ -5,3 +7,11 @@ export function zeroSinceHH(millisSinceEpoch: number): number { export function zeroSinceSS(millisSinceEpoch: number): number { return new Date(millisSinceEpoch).setUTCSeconds(0, 0); } + +export function betweenDateTimeTransform(betweenMatcherData: IBetweenMatcherData): IBetweenMatcherData { + return { + dataType: betweenMatcherData.dataType, + start: zeroSinceSS(betweenMatcherData.start), + end: zeroSinceSS(betweenMatcherData.end) + }; +} diff --git a/src/evaluator/matchersTransform/index.ts b/src/evaluator/matchersTransform/index.ts index 6219c4dc..075ea9f0 100644 --- a/src/evaluator/matchersTransform/index.ts +++ b/src/evaluator/matchersTransform/index.ts @@ -3,7 +3,7 @@ import { matcherTypes, matcherTypesMapper, matcherDataTypes } from '../matchers/ import { segmentTransform } from './segment'; import { whitelistTransform } from './whitelist'; import { numericTransform } from './unaryNumeric'; -import { zeroSinceHH, zeroSinceSS } from '../convertions'; +import { zeroSinceHH, zeroSinceSS, betweenDateTimeTransform } from '../convertions'; import { IBetweenMatcherData, IInLargeSegmentMatcherData, IInSegmentMatcherData, ISplitMatcher, IUnaryNumericMatcherData } from '../../dtos/types'; import { IMatcherDto } from '../types'; @@ -32,7 +32,7 @@ export function matchersTransform(matchers: ISplitMatcher[]): IMatcherDto[] { let type = matcherTypesMapper(matcherType); // As default input data type we use string (even for ALL_KEYS) let dataType = matcherDataTypes.STRING; - let value = undefined; + let value; if (type === matcherTypes.IN_SEGMENT) { value = segmentTransform(userDefinedSegmentMatcherData as IInSegmentMatcherData); @@ -60,8 +60,7 @@ export function matchersTransform(matchers: ISplitMatcher[]): IMatcherDto[] { dataType = matcherDataTypes.NUMBER; if (value.dataType === 'DATETIME') { - value.start = zeroSinceSS(value.start); - value.end = zeroSinceSS(value.end); + value = betweenDateTimeTransform(value); dataType = matcherDataTypes.DATETIME; } } else if (type === matcherTypes.BETWEEN_SEMVER) { diff --git a/src/logger/messages/error.ts b/src/logger/messages/error.ts index 123f8eee..70a87eb0 100644 --- a/src/logger/messages/error.ts +++ b/src/logger/messages/error.ts @@ -14,7 +14,7 @@ export const codesError: [number, string][] = [ [c.ERROR_SYNC_OFFLINE_LOADING, c.LOG_PREFIX_SYNC_OFFLINE + 'There was an issue loading the mock feature flags data. No changes will be applied to the current cache. %s'], [c.ERROR_STREAMING_SSE, c.LOG_PREFIX_SYNC_STREAMING + 'Failed to connect or error on streaming connection, with error message: %s'], [c.ERROR_STREAMING_AUTH, c.LOG_PREFIX_SYNC_STREAMING + 'Failed to authenticate for streaming. Error: %s.'], - [c.ERROR_HTTP, 'Response status is not OK. Status: %s. URL: %s. Message: %s'], + [c.ERROR_HTTP, 'HTTP request failed with %s. URL: %s. Message: %s'], // client status [c.ERROR_CLIENT_LISTENER, 'A listener was added for %s on the SDK, which has already fired and won\'t be emitted again. The callback won\'t be executed.'], [c.ERROR_CLIENT_DESTROYED, '%s: Client has already been destroyed - no calls possible.'], diff --git a/src/sdkClient/sdkClientMethodCS.ts b/src/sdkClient/sdkClientMethodCS.ts index ebc755a1..b68481a9 100644 --- a/src/sdkClient/sdkClientMethodCS.ts +++ b/src/sdkClient/sdkClientMethodCS.ts @@ -9,13 +9,15 @@ import { RETRIEVE_CLIENT_DEFAULT, NEW_SHARED_CLIENT, RETRIEVE_CLIENT_EXISTING, L import { SDK_SEGMENTS_ARRIVED } from '../readiness/constants'; import { ISdkFactoryContext } from '../sdkFactory/types'; import { buildInstanceId } from './identity'; +import { setRolloutPlan } from '../storages/setRolloutPlan'; +import { ISegmentsCacheSync } from '../storages/types'; /** * Factory of client method for the client-side API variant where TT is ignored. * Therefore, clients don't have a bound TT for the track method. */ export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: SplitIO.SplitKey) => SplitIO.IBrowserClient { - const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log } } = params; + const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log, initialRolloutPlan } } = params; const mainClientInstance = clientCSDecorator( log, @@ -56,6 +58,10 @@ export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: Spl sharedSdkReadiness.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); }); + if (sharedStorage && initialRolloutPlan) { + setRolloutPlan(log, initialRolloutPlan, { segments: sharedStorage.segments as ISegmentsCacheSync, largeSegments: sharedStorage.largeSegments as ISegmentsCacheSync }, matchingKey); + } + // 3 possibilities: // - Standalone mode: both syncManager and sharedSyncManager are defined // - Consumer mode: both syncManager and sharedSyncManager are undefined diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index bf807425..d1dcac43 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -14,6 +14,9 @@ import { strategyOptimizedFactory } from '../trackers/strategy/strategyOptimized import { strategyNoneFactory } from '../trackers/strategy/strategyNone'; import { uniqueKeysTrackerFactory } from '../trackers/uniqueKeysTracker'; import { DEBUG, OPTIMIZED } from '../utils/constants'; +import { setRolloutPlan } from '../storages/setRolloutPlan'; +import { IStorageSync } from '../storages/types'; +import { getMatching } from '../utils/key'; /** * Modular SDK factory @@ -24,7 +27,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA syncManagerFactory, SignalListener, impressionsObserverFactory, integrationsManagerFactory, sdkManagerFactory, sdkClientMethodFactory, filterAdapterFactory, lazyInit } = params; - const { log, sync: { impressionsMode } } = settings; + const { log, sync: { impressionsMode }, initialRolloutPlan, core: { key } } = settings; // @TODO handle non-recoverable errors, such as, global `fetch` not available, invalid SDK Key, etc. // On non-recoverable errors, we should mark the SDK as destroyed and not start synchronization. @@ -43,7 +46,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA const storage = storageFactory({ settings, - onReadyCb: (error) => { + onReadyCb(error) { if (error) { // If storage fails to connect, SDK_READY_TIMED_OUT event is emitted immediately. Review when timeout and non-recoverable errors are reworked readiness.timeout(); @@ -52,11 +55,16 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA readiness.splits.emit(SDK_SPLITS_ARRIVED); readiness.segments.emit(SDK_SEGMENTS_ARRIVED); }, - onReadyFromCacheCb: () => { + onReadyFromCacheCb() { readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); } }); - // @TODO add support for dataloader: `if (params.dataLoader) params.dataLoader(storage);` + + if (initialRolloutPlan) { + setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key)); + if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); + } + const clients: Record = {}; const telemetryTracker = telemetryTrackerFactory(storage.telemetry, platform.now); const integrationsManager = integrationsManagerFactory && integrationsManagerFactory({ settings, storage, telemetryTracker }); diff --git a/src/services/splitHttpClient.ts b/src/services/splitHttpClient.ts index dcb841c8..89e12533 100644 --- a/src/services/splitHttpClient.ts +++ b/src/services/splitHttpClient.ts @@ -70,7 +70,7 @@ export function splitHttpClientFactory(settings: ISettings, { getOptions, getFet } if (!resp || resp.status !== 403) { // 403's log we'll be handled somewhere else. - log[logErrorsAsInfo ? 'info' : 'error'](ERROR_HTTP, [resp ? resp.status : 'NO_STATUS', url, msg]); + log[logErrorsAsInfo ? 'info' : 'error'](ERROR_HTTP, [resp ? 'status code ' + resp.status : 'no status code', url, msg]); } const networkError: NetworkError = new Error(msg); diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 761c5cb9..2a4b9b78 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -1,4 +1,4 @@ -import { ISplitsCacheSync } from './types'; +import { ISplitsCacheSync, IStorageSync } from './types'; import { IRBSegment, ISplit } from '../dtos/types'; import { objectAssign } from '../utils/lang/objectAssign'; import { IN_SEGMENT, IN_LARGE_SEGMENT } from '../utils/constants'; @@ -88,3 +88,7 @@ export function usesSegments(ruleEntity: ISplit | IRBSegment) { return false; } + +export function usesSegmentsSync(storage: Pick) { + return storage.splits.usesSegments() || storage.rbSegments.usesSegments(); +} diff --git a/src/storages/__tests__/dataLoader.spec.ts b/src/storages/__tests__/dataLoader.spec.ts new file mode 100644 index 00000000..3f1de562 --- /dev/null +++ b/src/storages/__tests__/dataLoader.spec.ts @@ -0,0 +1,133 @@ +import { InMemoryStorageFactory } from '../inMemory/InMemoryStorage'; +import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS'; +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; +import { IRBSegment, ISplit } from '../../dtos/types'; + +import { validateRolloutPlan, setRolloutPlan } from '../setRolloutPlan'; +import { getRolloutPlan } from '../getRolloutPlan'; + +const otherKey = 'otherKey'; +const expectedRolloutPlan = { + splitChanges: { + ff: { d: [{ name: 'split1' }], t: 123, s: -1 }, + rbs: { d: [{ name: 'rbs1' }], t: 321, s: -1 } + }, + memberships: { + [fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } }, + [otherKey]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } } + }, + segmentChanges: [{ + name: 'segment1', + added: [fullSettings.core.key as string, otherKey], + removed: [], + since: -1, + till: 123 + }] +}; + +describe('validateRolloutPlan', () => { + afterEach(() => { + loggerMock.mockClear(); + }); + + test('valid rollout plan and mode', () => { + expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: expectedRolloutPlan } as any)).toEqual(expectedRolloutPlan); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + + test('invalid rollout plan', () => { + expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: {} } as any)).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith('storage: invalid rollout plan provided'); + }); + + test('invalid mode', () => { + expect(validateRolloutPlan(loggerMock, { mode: 'consumer', initialRolloutPlan: expectedRolloutPlan } as any)).toBeUndefined(); + expect(loggerMock.warn).toHaveBeenCalledWith('storage: initial rollout plan is ignored in consumer mode'); + }); +}); + +describe('getRolloutPlan & setRolloutPlan (client-side)', () => { + // @ts-expect-error Load server-side storage + const serverStorage = InMemoryStorageFactory({ settings: fullSettings }); + serverStorage.splits.update([{ name: 'split1' } as ISplit], [], 123); + serverStorage.rbSegments.update([{ name: 'rbs1' } as IRBSegment], [], 321); + serverStorage.segments.update('segment1', [fullSettings.core.key as string, otherKey], [], 123); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('using preloaded data (no memberships, no segments)', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual([]); + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual([]); + + // Get preloaded data from client-side storage + expect(getRolloutPlan(loggerMock, clientStorage)).toEqual(rolloutPlan); + expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined, segmentChanges: undefined }); + }); + + test('using preloaded data with memberships', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string, otherKey] }); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + + // @TODO requires internal storage cache for `shared` storages + // // Get preloaded data from client-side storage + // expect(getRolloutPlan(loggerMock, clientStorage, { keys: [fullSettings.core.key as string, otherKey] })).toEqual(rolloutPlan); + // expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, segmentChanges: undefined }); + }); + + test('using preloaded data with segments', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { exposeSegments: true }); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + + expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined }); + }); + + test('using preloaded data with memberships and segments', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string], exposeSegments: true }); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // main client membership is set via the rollout plan `memberships` field + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // shared client membership is set via the rollout plan `segmentChanges` field + + expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: { [fullSettings.core.key as string]: expectedRolloutPlan.memberships![fullSettings.core.key as string] } }); + }); +}); diff --git a/src/storages/dataLoader.ts b/src/storages/dataLoader.ts deleted file mode 100644 index 49522bce..00000000 --- a/src/storages/dataLoader.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PreloadedData } from '../types'; -import { DataLoader, ISegmentsCacheSync, ISplitsCacheSync } from './types'; - -// This value might be eventually set via a config parameter -const DEFAULT_CACHE_EXPIRATION_IN_MILLIS = 864000000; // 10 days - -/** - * Factory of client-side storage loader - * - * @param preloadedData - validated data following the format proposed in https://github.com/godaddy/split-javascript-data-loader - * and extended with a `mySegmentsData` property. - * @returns function to preload the storage - */ -export function dataLoaderFactory(preloadedData: PreloadedData): DataLoader { - - /** - * Storage-agnostic adaptation of `loadDataIntoLocalStorage` function - * (https://github.com/godaddy/split-javascript-data-loader/blob/master/src/load-data.js) - * - * @param storage - object containing `splits` and `segments` cache (client-side variant) - * @param userId - user key string of the provided MySegmentsCache - */ - // @TODO extend to support SegmentsCache (server-side variant) by making `userId` optional and adding the corresponding logic. - // @TODO extend to load data on shared mySegments storages. Be specific when emitting SDK_READY_FROM_CACHE on shared clients. Maybe the serializer should provide the `useSegments` flag. - return function loadData(storage: { splits: ISplitsCacheSync, segments: ISegmentsCacheSync }, userId: string) { - // Do not load data if current preloadedData is empty - if (Object.keys(preloadedData).length === 0) return; - - const { lastUpdated = -1, segmentsData = {}, since = -1, splitsData = {} } = preloadedData; - - const storedSince = storage.splits.getChangeNumber(); - const expirationTimestamp = Date.now() - DEFAULT_CACHE_EXPIRATION_IN_MILLIS; - - // Do not load data if current localStorage data is more recent, - // or if its `lastUpdated` timestamp is older than the given `expirationTimestamp`, - if (storedSince > since || lastUpdated < expirationTimestamp) return; - - // cleaning up the localStorage data, since some cached splits might need be part of the preloaded data - storage.splits.clear(); - - // splitsData in an object where the property is the split name and the pertaining value is a stringified json of its data - storage.splits.update(Object.keys(splitsData).map(splitName => JSON.parse(splitsData[splitName])), [], since); - - // add mySegments data - let mySegmentsData = preloadedData.mySegmentsData && preloadedData.mySegmentsData[userId]; - if (!mySegmentsData) { - // segmentsData in an object where the property is the segment name and the pertaining value is a stringified object that contains the `added` array of userIds - mySegmentsData = Object.keys(segmentsData).filter(segmentName => { - const userIds = JSON.parse(segmentsData[segmentName]).added; - return Array.isArray(userIds) && userIds.indexOf(userId) > -1; - }); - } - storage.segments.resetSegments({ k: mySegmentsData.map(s => ({ n: s })) }); - }; -} diff --git a/src/storages/getRolloutPlan.ts b/src/storages/getRolloutPlan.ts new file mode 100644 index 00000000..40e6ea84 --- /dev/null +++ b/src/storages/getRolloutPlan.ts @@ -0,0 +1,72 @@ +import SplitIO from '../../types/splitio'; +import { IStorageSync } from './types'; +import { setToArray } from '../utils/lang/sets'; +import { getMatching } from '../utils/key'; +import { ILogger } from '../logger/types'; +import { RolloutPlan } from './types'; +import { IMembershipsResponse, IMySegmentsResponse } from '../dtos/types'; + +/** + * Gets the rollout plan snapshot from the given synchronous storage. + */ +export function getRolloutPlan(log: ILogger, storage: IStorageSync, options: SplitIO.RolloutPlanOptions = {}): RolloutPlan { + + const { keys, exposeSegments } = options; + const { splits, segments, rbSegments } = storage; + + log.debug(`storage: get feature flags${keys ? `, and memberships for keys: ${keys}` : ''}${exposeSegments ? ', and segments' : ''}`); + + return { + splitChanges: { + ff: { + t: splits.getChangeNumber(), + s: -1, + d: splits.getAll(), + }, + rbs: { + t: rbSegments.getChangeNumber(), + s: -1, + d: rbSegments.getAll(), + } + }, + segmentChanges: exposeSegments ? // @ts-ignore accessing private prop + Object.keys(segments.segmentCache).map(segmentName => ({ + name: segmentName, // @ts-ignore + added: setToArray(segments.segmentCache[segmentName] as Set), + removed: [], + since: -1, + till: segments.getChangeNumber(segmentName)! + })) : + undefined, + memberships: keys ? + keys.reduce>((prev, key) => { + const matchingKey = getMatching(key); + if (storage.shared) { // Client-side segments + const sharedStorage = storage.shared(matchingKey); + prev[matchingKey] = { + ms: { // @ts-ignore + k: Object.keys(sharedStorage.segments.segmentCache).map(segmentName => ({ n: segmentName })), + }, + ls: sharedStorage.largeSegments ? { // @ts-ignore + k: Object.keys(sharedStorage.largeSegments.segmentCache).map(segmentName => ({ n: segmentName })), + } : undefined + }; + } else { // Server-side segments + prev[matchingKey] = { + ms: { // @ts-ignore + k: Object.keys(storage.segments.segmentCache).reduce((prev, segmentName) => { // @ts-ignore + return storage.segments.segmentCache[segmentName].has(matchingKey) ? + prev!.concat({ n: segmentName }) : + prev; + }, []) + }, + ls: { + k: [] + } + }; + } + return prev; + }, {}) : + undefined + }; +} diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index 37f6ad8e..cfc68cf5 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -105,6 +105,10 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { return item && JSON.parse(item); } + getAll(): IRBSegment[] { + return this.getNames().map(key => this.get(key)!); + } + contains(names: Set): boolean { const namesArray = setToArray(names); const namesInStorage = this.getNames(); diff --git a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts index 913d6a3b..13ab1b32 100644 --- a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts @@ -173,6 +173,9 @@ test('SPLITS CACHE / LocalStorage / flag set cache tests', () => { ], [], -1); cache.addSplit(featureFlagWithEmptyFS); + // Adding an existing FF should not affect the cache + cache.update([featureFlagTwo], [], -1); + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 93d3144c..3fa54ec6 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -17,7 +17,7 @@ const MILLIS_IN_A_DAY = 86400000; * @returns `true` if cache should be cleared, `false` otherwise */ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { - const { log } = settings; + const { log, initialRolloutPlan } = settings; // Check expiration const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10); @@ -41,7 +41,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS } catch (e) { log.error(LOG_PREFIX + e); } - if (isThereCache) { + if (isThereCache && !initialRolloutPlan) { log.info(LOG_PREFIX + 'SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); return true; } diff --git a/src/storages/inMemory/RBSegmentsCacheInMemory.ts b/src/storages/inMemory/RBSegmentsCacheInMemory.ts index 568b0deb..2b876202 100644 --- a/src/storages/inMemory/RBSegmentsCacheInMemory.ts +++ b/src/storages/inMemory/RBSegmentsCacheInMemory.ts @@ -51,6 +51,10 @@ export class RBSegmentsCacheInMemory implements IRBSegmentsCacheSync { return this.cache[name] || null; } + getAll(): IRBSegment[] { + return this.getNames().map(key => this.get(key)!); + } + contains(names: Set): boolean { const namesArray = setToArray(names); const namesInStorage = this.getNames(); diff --git a/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts index 56ca1300..2ed4478b 100644 --- a/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts +++ b/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts @@ -135,6 +135,9 @@ test('SPLITS CACHE / In Memory / flag set cache tests', () => { ], [], -1); cache.addSplit(featureFlagWithEmptyFS); + // Adding an existing FF should not affect the cache + cache.update([featureFlagTwo], [], -1); + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); diff --git a/src/storages/setRolloutPlan.ts b/src/storages/setRolloutPlan.ts new file mode 100644 index 00000000..a8529231 --- /dev/null +++ b/src/storages/setRolloutPlan.ts @@ -0,0 +1,71 @@ +import SplitIO from '../../types/splitio'; +import { IRBSegmentsCacheSync, ISegmentsCacheSync, ISplitsCacheSync } from './types'; +import { ILogger } from '../logger/types'; +import { isObject } from '../utils/lang'; +import { isConsumerMode } from '../utils/settingsValidation/mode'; +import { RolloutPlan } from './types'; + +/** + * Validates if the given rollout plan is valid. + */ +export function validateRolloutPlan(log: ILogger, settings: SplitIO.ISettings): RolloutPlan | undefined { + const { mode, initialRolloutPlan } = settings; + + if (isConsumerMode(mode)) { + log.warn('storage: initial rollout plan is ignored in consumer mode'); + return; + } + + if (isObject(initialRolloutPlan) && isObject((initialRolloutPlan as any).splitChanges)) return initialRolloutPlan as RolloutPlan; + + log.error('storage: invalid rollout plan provided'); + return; +} + +/** + * Sets the given synchronous storage with the provided rollout plan snapshot. + * If `matchingKey` is provided, the storage is handled as a client-side storage (segments and largeSegments are instances of MySegmentsCache). + * Otherwise, the storage is handled as a server-side storage (segments is an instance of SegmentsCache). + */ +export function setRolloutPlan(log: ILogger, rolloutPlan: RolloutPlan, storage: { splits?: ISplitsCacheSync, rbSegments?: IRBSegmentsCacheSync, segments: ISegmentsCacheSync, largeSegments?: ISegmentsCacheSync }, matchingKey?: string) { + const { splits, rbSegments, segments, largeSegments } = storage; + const { splitChanges: { ff, rbs } } = rolloutPlan; + + log.debug(`storage: set feature flags and segments${matchingKey ? ` for key ${matchingKey}` : ''}`); + + if (splits && ff) { + splits.clear(); + splits.update(ff.d, [], ff.t); + } + + if (rbSegments && rbs) { + rbSegments.clear(); + rbSegments.update(rbs.d, [], rbs.t); + } + + const segmentChanges = rolloutPlan.segmentChanges; + if (matchingKey) { // add memberships data (client-side) + let memberships = rolloutPlan.memberships && rolloutPlan.memberships[matchingKey]; + if (!memberships && segmentChanges) { + memberships = { + ms: { + k: segmentChanges.filter(segment => { + return segment.added.indexOf(matchingKey) > -1; + }).map(segment => ({ n: segment.name })) + } + }; + } + + if (memberships) { + if (memberships.ms) segments.resetSegments(memberships.ms!); + if (memberships.ls && largeSegments) largeSegments.resetSegments(memberships.ls!); + } + } else { // add segments data (server-side) + if (segmentChanges) { + segments.clear(); + segmentChanges.forEach(segment => { + segments.update(segment.name, segment.added, segment.removed, segment.till); + }); + } + } +} diff --git a/src/storages/types.ts b/src/storages/types.ts index 8e93daca..2737da40 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -1,5 +1,5 @@ import SplitIO from '../../types/splitio'; -import { MaybeThenable, ISplit, IRBSegment, IMySegmentsResponse } from '../dtos/types'; +import { MaybeThenable, ISplit, IRBSegment, IMySegmentsResponse, IMembershipsResponse, ISegmentChangesResponse, ISplitChangesResponse } from '../dtos/types'; import { MySegmentsData } from '../sync/polling/types'; import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, MultiMethodExceptions, MultiMethodLatencies, MultiConfigs, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent, UniqueKeysPayloadCs, UniqueKeysPayloadSs, TelemetryUsageStatsPayload, UpdatesFromSSEEnum } from '../sync/submitters/types'; import { ISettings } from '../types'; @@ -235,6 +235,7 @@ export interface IRBSegmentsCacheSync extends IRBSegmentsCacheBase { update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean, get(name: string): IRBSegment | null, getChangeNumber(): number, + getAll(): IRBSegment[], clear(): void, contains(names: Set): boolean, // Used only for smart pausing in client-side standalone. Returns true if the storage contains a RBSegment using segments or large segments matchers @@ -465,7 +466,7 @@ export interface IStorageBase< telemetry?: TTelemetryCache, uniqueKeys: TUniqueKeysCache, destroy(): void | Promise, - shared?: (matchingKey: string, onReadyCb: (error?: any) => void) => this + shared?: (matchingKey: string, onReadyCb?: (error?: any) => void) => this } export interface IStorageSync extends IStorageBase< @@ -496,8 +497,6 @@ export interface IStorageAsync extends IStorageBase< /** StorageFactory */ -export type DataLoader = (storage: IStorageSync, matchingKey: string) => void - export interface IStorageFactoryParams { settings: ISettings, /** @@ -505,6 +504,9 @@ export interface IStorageFactoryParams { * It is meant for emitting SDK_READY event in consumer mode, and waiting before using the storage in the synchronizer. */ onReadyCb: (error?: any) => void, + /** + * For emitting SDK_READY_FROM_CACHE event in consumer mode with Redis to allow immediate evaluations + */ onReadyFromCacheCb: () => void, } @@ -518,3 +520,21 @@ export type IStorageAsyncFactory = SplitIO.StorageAsyncFactory & { readonly type: SplitIO.StorageType, (params: IStorageFactoryParams): IStorageAsync } + +export type RolloutPlan = { + /** + * Feature flags and rule-based segments. + */ + splitChanges: ISplitChangesResponse; + /** + * Optional map of matching keys to their memberships. + */ + memberships?: { + [matchingKey: string]: IMembershipsResponse; + }; + /** + * Optional list of standard segments. + * This property is ignored if `memberships` is provided. + */ + segmentChanges?: ISegmentChangesResponse[]; +}; diff --git a/src/sync/polling/pollingManagerCS.ts b/src/sync/polling/pollingManagerCS.ts index 6a5ba679..5e197e62 100644 --- a/src/sync/polling/pollingManagerCS.ts +++ b/src/sync/polling/pollingManagerCS.ts @@ -8,6 +8,7 @@ import { getMatching } from '../../utils/key'; import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../../readiness/constants'; import { POLLING_SMART_PAUSING, POLLING_START, POLLING_STOP } from '../../logger/constants'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; +import { usesSegmentsSync } from '../../storages/AbstractSplitsCacheSync'; /** * Expose start / stop mechanism for polling data from services. @@ -43,7 +44,7 @@ export function pollingManagerCSFactory( // smart pausing readiness.splits.on(SDK_SPLITS_ARRIVED, () => { if (!splitsSyncTask.isRunning()) return; // noop if not doing polling - const usingSegments = storage.splits.usesSegments() || storage.rbSegments.usesSegments(); + const usingSegments = usesSegmentsSync(storage); if (usingSegments !== mySegmentsSyncTask.isRunning()) { log.info(POLLING_SMART_PAUSING, [usingSegments ? 'ON' : 'OFF']); if (usingSegments) { @@ -59,9 +60,9 @@ export function pollingManagerCSFactory( // smart ready function smartReady() { - if (!readiness.isReady() && !storage.splits.usesSegments() && !storage.rbSegments.usesSegments()) readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + if (!readiness.isReady() && !usesSegmentsSync(storage)) readiness.segments.emit(SDK_SEGMENTS_ARRIVED); } - if (!storage.splits.usesSegments() && !storage.rbSegments.usesSegments()) setTimeout(smartReady, 0); + if (!usesSegmentsSync(storage)) setTimeout(smartReady, 0); else readiness.splits.once(SDK_SPLITS_ARRIVED, smartReady); mySegmentsSyncTasks[matchingKey] = mySegmentsSyncTask; @@ -77,7 +78,7 @@ export function pollingManagerCSFactory( log.info(POLLING_START); splitsSyncTask.start(); - if (storage.splits.usesSegments() || storage.rbSegments.usesSegments()) startMySegmentsSyncTasks(); + if (usesSegmentsSync(storage)) startMySegmentsSyncTasks(); }, // Stop periodic fetching (polling) diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index 501e3b7a..5de512fa 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -8,6 +8,7 @@ import { SYNC_MYSEGMENTS_FETCH_RETRY } from '../../../logger/constants'; import { MySegmentsData } from '../types'; import { IMembershipsResponse } from '../../../dtos/types'; import { MEMBERSHIPS_LS_UPDATE } from '../../streaming/constants'; +import { usesSegmentsSync } from '../../../storages/AbstractSplitsCacheSync'; type IMySegmentsUpdater = (segmentsData?: MySegmentsData, noCache?: boolean, till?: number) => Promise @@ -27,7 +28,7 @@ export function mySegmentsUpdaterFactory( matchingKey: string ): IMySegmentsUpdater { - const { splits, rbSegments, segments, largeSegments } = storage; + const { segments, largeSegments } = storage; let readyOnAlreadyExistentState = true; let startingUp = true; @@ -51,7 +52,7 @@ export function mySegmentsUpdaterFactory( } // Notify update if required - if ((splits.usesSegments() || rbSegments.usesSegments()) && (shouldNotifyUpdate || readyOnAlreadyExistentState)) { + if (usesSegmentsSync(storage) && (shouldNotifyUpdate || readyOnAlreadyExistentState)) { readyOnAlreadyExistentState = false; segmentsEventEmitter.emit(SDK_SEGMENTS_ARRIVED); } diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index ea5e5e44..0331bc43 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -59,7 +59,7 @@ interface ISplitMutations { /** * If there are defined filters and one feature flag doesn't match with them, its status is changed to 'ARCHIVE' to avoid storing it - * If there are set filter defined, names filter is ignored + * If there is `bySet` filter, `byName` and `byPrefix` filters are ignored * * @param featureFlag - feature flag to be evaluated * @param filters - splitFiltersValidation bySet | byName diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index 21bf81e7..aac6f7e4 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -10,6 +10,7 @@ import { isConsentGranted } from '../consent'; import { POLLING, STREAMING, SYNC_MODE_UPDATE } from '../utils/constants'; import { ISdkFactoryContextSync } from '../sdkFactory/types'; import { SDK_SPLITS_CACHE_LOADED } from '../readiness/constants'; +import { usesSegmentsSync } from '../storages/AbstractSplitsCacheSync'; /** * Online SyncManager factory. @@ -155,14 +156,14 @@ export function syncManagerOnlineFactory( if (pushManager) { if (pollingManager.isRunning()) { // if doing polling, we must start the periodic fetch of data - if (storage.splits.usesSegments() || storage.rbSegments.usesSegments()) mySegmentsSyncTask.start(); + if (usesSegmentsSync(storage)) mySegmentsSyncTask.start(); } else { // if not polling, we must execute the sync task for the initial fetch // of segments since `syncAll` was already executed when starting the main client mySegmentsSyncTask.execute(); } } else { - if (storage.splits.usesSegments() || storage.rbSegments.usesSegments()) mySegmentsSyncTask.start(); + if (usesSegmentsSync(storage)) mySegmentsSyncTask.start(); } } else { if (!readinessManager.isReady()) mySegmentsSyncTask.execute(); diff --git a/src/types.ts b/src/types.ts index bdb0933c..ad3fa04c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import SplitIO from '../types/splitio'; import { ISplitFiltersValidation } from './dtos/types'; import { ILogger } from './logger/types'; +import { RolloutPlan } from './storages/types'; /** * SplitIO.ISettings interface extended with private properties for internal use @@ -10,6 +11,7 @@ export interface ISettings extends SplitIO.ISettings { __splitFiltersValidation: ISplitFiltersValidation; }; readonly log: ILogger; + readonly initialRolloutPlan?: RolloutPlan; } /** @@ -42,38 +44,3 @@ export interface IBasicClient extends SplitIO.IBasicClient { isClientSide?: boolean; key?: SplitIO.SplitKey; } -/** - * Defines the format of rollout plan data to preload the factory storage (cache). - */ -export interface PreloadedData { - /** - * Timestamp of the last moment the data was synchronized with Split servers. - * If this value is older than 10 days ago (expiration time policy), the data is not used to update the storage content. - */ - // @TODO configurable expiration time policy? - lastUpdated: number; - /** - * Change number of the preloaded data. - * If this value is older than the current changeNumber at the storage, the data is not used to update the storage content. - */ - since: number; - /** - * Map of feature flags to their stringified definitions. - */ - splitsData: { - [splitName: string]: string; - }; - /** - * Optional map of user keys to their list of segments. - */ - mySegmentsData?: { - [key: string]: string[]; - }; - /** - * Optional map of segments to their stringified definitions. - * This property is ignored if `mySegmentsData` was provided. - */ - segmentsData?: { - [segmentName: string]: string; - }; -} diff --git a/src/utils/inputValidation/__tests__/preloadedData.spec.ts b/src/utils/inputValidation/__tests__/preloadedData.spec.ts deleted file mode 100644 index 79f1d1a4..00000000 --- a/src/utils/inputValidation/__tests__/preloadedData.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; - -// Import the module mocking the logger. -import { validatePreloadedData } from '../preloadedData'; - -const method = 'some_method'; -const testCases = [ - // valid inputs - { - input: { lastUpdated: 10, since: 10, splitsData: {} }, - output: true, - warn: `${method}: preloadedData.splitsData doesn't contain feature flag definitions.` - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { 'some_key': [] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { 'some_key': [] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: {} }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: [] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: ['some_segment'] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, segmentsData: {} }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, segmentsData: { some_segment: 'SEGMENT DEFINITION' } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: ['some_segment'], some_other_key: ['some_segment'] }, segmentsData: { some_segment: 'SEGMENT DEFINITION', some_other_segment: 'SEGMENT DEFINITION' } }, - output: true - }, - { - // should be true, even using objects for strings and numbers or having extra properties - input: { ignoredProperty: 'IGNORED', lastUpdated: new Number(10), since: new Number(10), splitsData: { 'some_split': new String('SPLIT DEFINITION') }, mySegmentsData: { some_key: [new String('some_segment')] }, segmentsData: { some_segment: new String('SEGMENT DEFINITION') } }, - output: true - }, - - // invalid inputs - { - // should be false if preloadedData is not an object - input: undefined, - output: false, - error: `${method}: preloadedData must be an object.` - }, - { - // should be false if preloadedData is not an object - input: [], - output: false, - error: `${method}: preloadedData must be an object.` - }, - { - // should be false if lastUpdated property is invalid - input: { lastUpdated: undefined, since: 10, splitsData: {} }, - output: false, - error: `${method}: preloadedData.lastUpdated must be a positive number.` - }, - { - // should be false if lastUpdated property is invalid - input: { lastUpdated: -1, since: 10, splitsData: {} }, - output: false, - error: `${method}: preloadedData.lastUpdated must be a positive number.` - }, - { - // should be false if since property is invalid - input: { lastUpdated: 10, since: undefined, splitsData: {} }, - output: false, - error: `${method}: preloadedData.since must be a positive number.` - }, - { - // should be false if since property is invalid - input: { lastUpdated: 10, since: -1, splitsData: {} }, - output: false, - error: `${method}: preloadedData.since must be a positive number.` - }, - { - // should be false if splitsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: undefined }, - output: false, - error: `${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.` - }, - { - // should be false if splitsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: ['DEFINITION'] }, - output: false, - error: `${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.` - }, - { - // should be false if splitsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: undefined } }, - output: false, - error: `${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.` - }, - { - // should be false if mySegmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, mySegmentsData: ['DEFINITION'] }, - output: false, - error: `${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.` - }, - { - // should be false if mySegmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, mySegmentsData: { some_key: undefined } }, - output: false, - error: `${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.` - }, - { - // should be false if segmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, segmentsData: ['DEFINITION'] }, - output: false, - error: `${method}: preloadedData.segmentsData must be a map of segment names to their stringified definitions.` - }, - { - // should be false if segmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, segmentsData: { some_segment: undefined } }, - output: false, - error: `${method}: preloadedData.segmentsData must be a map of segment names to their stringified definitions.` - } -]; - -test('INPUT VALIDATION for preloadedData', () => { - - for (let i = 0; i < testCases.length; i++) { - const testCase = testCases[i]; - expect(validatePreloadedData(loggerMock, testCase.input, method)).toBe(testCase.output); - - if (testCase.error) { - expect(loggerMock.error.mock.calls[0]).toEqual([testCase.error]); // Should log the error for the invalid preloadedData. - loggerMock.error.mockClear(); - } else { - expect(loggerMock.error).not.toBeCalled(); // Should not log any error. - } - - if (testCase.warn) { - expect(loggerMock.warn.mock.calls[0]).toEqual([testCase.warn]); // Should log the warning for the given preloadedData. - loggerMock.warn.mockClear(); - } else { - expect(loggerMock.warn).not.toBeCalled(); // Should not log any warning. - } - } -}); diff --git a/src/utils/inputValidation/index.ts b/src/utils/inputValidation/index.ts index 96cf4be6..eac9777d 100644 --- a/src/utils/inputValidation/index.ts +++ b/src/utils/inputValidation/index.ts @@ -10,5 +10,4 @@ export { validateTrafficType } from './trafficType'; export { validateIfNotDestroyed, validateIfOperational } from './isOperational'; export { validateSplitExistence } from './splitExistence'; export { validateTrafficTypeExistence } from './trafficTypeExistence'; -export { validatePreloadedData } from './preloadedData'; export { validateEvaluationOptions } from './eventProperties'; diff --git a/src/utils/inputValidation/preloadedData.ts b/src/utils/inputValidation/preloadedData.ts deleted file mode 100644 index f07ee432..00000000 --- a/src/utils/inputValidation/preloadedData.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { isObject, isString, isFiniteNumber } from '../lang'; -import { validateSplit } from './split'; -import { ILogger } from '../../logger/types'; - -function validateTimestampData(log: ILogger, maybeTimestamp: any, method: string, item: string) { - if (isFiniteNumber(maybeTimestamp) && maybeTimestamp > -1) return true; - log.error(`${method}: preloadedData.${item} must be a positive number.`); - return false; -} - -function validateSplitsData(log: ILogger, maybeSplitsData: any, method: string) { - if (isObject(maybeSplitsData)) { - const splitNames = Object.keys(maybeSplitsData); - if (splitNames.length === 0) log.warn(`${method}: preloadedData.splitsData doesn't contain feature flag definitions.`); - // @TODO in the future, consider handling the possibility of having parsed definitions of splits - if (splitNames.every(splitName => validateSplit(log, splitName, method) && isString(maybeSplitsData[splitName]))) return true; - } - log.error(`${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.`); - return false; -} - -function validateMySegmentsData(log: ILogger, maybeMySegmentsData: any, method: string) { - if (isObject(maybeMySegmentsData)) { - const userKeys = Object.keys(maybeMySegmentsData); - if (userKeys.every(userKey => { - const segmentNames = maybeMySegmentsData[userKey]; - // an empty list is valid - return Array.isArray(segmentNames) && segmentNames.every(segmentName => isString(segmentName)); - })) return true; - } - log.error(`${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.`); - return false; -} - -function validateSegmentsData(log: ILogger, maybeSegmentsData: any, method: string) { - if (isObject(maybeSegmentsData)) { - const segmentNames = Object.keys(maybeSegmentsData); - if (segmentNames.every(segmentName => isString(maybeSegmentsData[segmentName]))) return true; - } - log.error(`${method}: preloadedData.segmentsData must be a map of segment names to their stringified definitions.`); - return false; -} - -export function validatePreloadedData(log: ILogger, maybePreloadedData: any, method: string) { - if (!isObject(maybePreloadedData)) { - log.error(`${method}: preloadedData must be an object.`); - } else if ( - validateTimestampData(log, maybePreloadedData.lastUpdated, method, 'lastUpdated') && - validateTimestampData(log, maybePreloadedData.since, method, 'since') && - validateSplitsData(log, maybePreloadedData.splitsData, method) && - (!maybePreloadedData.mySegmentsData || validateMySegmentsData(log, maybePreloadedData.mySegmentsData, method)) && - (!maybePreloadedData.segmentsData || validateSegmentsData(log, maybePreloadedData.segmentsData, method)) - ) { - return true; - } - return false; -} diff --git a/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index 3c7ecfe7..2dc63018 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -7,6 +7,7 @@ import { ISettingsValidationParams } from './types'; import { ISettings } from '../../types'; import { validateKey } from '../inputValidation/key'; import { ERROR_MIN_CONFIG_PARAM, LOG_PREFIX_CLIENT_INSTANTIATION } from '../../logger/constants'; +import { validateRolloutPlan } from '../../storages/setRolloutPlan'; // Exported for telemetry export const base = { @@ -152,6 +153,9 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV // @ts-ignore, modify readonly prop if (storage) withDefaults.storage = storage(withDefaults); + // @ts-ignore, modify readonly prop + if (withDefaults.initialRolloutPlan) withDefaults.initialRolloutPlan = validateRolloutPlan(log, withDefaults); + // Validate key and TT (for client-side) const maybeKey = withDefaults.core.key; if (validationParams.acceptKey) { diff --git a/src/utils/settingsValidation/splitFilters.ts b/src/utils/settingsValidation/splitFilters.ts index cea3117f..455d3ee1 100644 --- a/src/utils/settingsValidation/splitFilters.ts +++ b/src/utils/settingsValidation/splitFilters.ts @@ -69,12 +69,6 @@ function validateSplitFilter(log: ILogger, type: SplitIO.SplitFilterType, values /** * Returns a string representing the URL encoded query component of /splitChanges URL. * - * The possible formats of the query string are: - * - null: if all filters are empty - * - '&names=': if only `byPrefix` filter is undefined - * - '&prefixes=': if only `byName` filter is undefined - * - '&names=&prefixes=': if no one is undefined - * * @param groupedFilters - object of filters. Each filter must be a list of valid, unique and ordered string values. * @returns null or string with the `split filter query` component of the URL. */ diff --git a/types/splitio.d.ts b/types/splitio.d.ts index ad8644b2..2680f8ef 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -24,7 +24,11 @@ interface ISharedSettings { sync?: { /** * List of feature flag filters. These filters are used to fetch a subset of the feature flag definitions in your environment, in order to reduce the delay of the SDK to be ready. - * This configuration is only meaningful when the SDK is working in "standalone" mode. + * + * NOTES: + * - This configuration is only meaningful when the SDK is working in `"standalone"` mode. + * - If `bySet` filter is provided, `byName` and `byPrefix` filters are ignored. + * - If both `byName` and `byPrefix` filters are provided, the intersection of the two groups of feature flags is fetched. * * Example: * ``` @@ -66,12 +70,17 @@ interface ISharedSettings { * * @example * ``` - * const getHeaderOverrides = (context) => { - * return { - * 'Authorization': context.headers['Authorization'] + ', other-value', - * 'custom-header': 'custom-value' - * }; - * }; + * const factory = SplitFactory({ + * ... + * sync: { + * getHeaderOverrides: (context) => { + * return { + * 'Authorization': context.headers['Authorization'] + ', other-value', + * 'custom-header': 'custom-value' + * }; + * } + * } + * }); * ``` */ getHeaderOverrides?: (context: { headers: Record }) => Record; @@ -341,6 +350,11 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#localhost-mode} */ features?: SplitIO.MockedFeaturesMap; + /** + * Rollout plan object (i.e., feature flags and segment definitions) to initialize the SDK storage with. If provided and valid, the SDK will be ready from cache immediately. + * This object is derived from calling the Node.js SDK’s `getRolloutPlan` method. + */ + initialRolloutPlan?: SplitIO.RolloutPlan; /** * SDK Startup settings. */ @@ -546,6 +560,7 @@ declare namespace SplitIO { eventsFirstPushWindow: number; }; readonly storage: StorageSyncFactory | StorageAsyncFactory | StorageOptions; + readonly initialRolloutPlan?: SplitIO.RolloutPlan; readonly urls: { events: string; sdk: string; @@ -952,7 +967,7 @@ declare namespace SplitIO { */ prefix?: string; /** - * Number of days before cached data expires if it was not updated. If cache expires, it is cleared on initialization. + * Number of days before cached data expires if it was not successfully synchronized (i.e., last SDK_READY or SDK_UPDATE event emitted). If cache expires, it is cleared on initialization. * * @defaultValue `10` */ @@ -1011,7 +1026,28 @@ declare namespace SplitIO { type: NodeSyncStorage | NodeAsyncStorage | BrowserStorage; prefix?: string; options?: Object; - } + }; + /** + * A JSON-serializable plain object that defines the format of rollout plan data to preload the SDK cache with feature flags and segments. + */ + type RolloutPlan = Object; + /** + * Options for the `factory.getRolloutPlan` method. + */ + type RolloutPlanOptions = { + /** + * Optional list of keys to generate the rollout plan snapshot with the memberships of the given keys. + * + * @defaultValue `undefined` + */ + keys?: SplitKey[]; + /** + * Optional flag to expose segments data in the rollout plan snapshot. + * + * @defaultValue `false` + */ + exposeSegments?: boolean; + }; /** * Impression listener interface. This is the interface that needs to be implemented * by the element you provide to the SDK as impression listener. @@ -1034,7 +1070,7 @@ declare namespace SplitIO { type IntegrationFactory = { readonly type: string; (params: any): (Integration | void); - } + }; /** * A pair of user key and it's trafficType, required for tracking valid Split events. */ @@ -1130,7 +1166,7 @@ declare namespace SplitIO { */ type: SplitFilterType; /** - * List of values: feature flag names for 'byName' filter type, and feature flag name prefixes for 'byPrefix' type. + * List of values: flag set names for 'bySet' filter type, feature flag names for 'byName' filter type, and feature flag name prefixes for 'byPrefix' type. */ values: string[]; } @@ -1292,7 +1328,7 @@ declare namespace SplitIO { */ prefix?: string; /** - * Optional settings for the 'LOCALSTORAGE' storage type. It specifies the number of days before cached data expires if it was not updated. If cache expires, it is cleared on initialization. + * Optional settings for the 'LOCALSTORAGE' storage type. It specifies the number of days before cached data expires if it was not successfully synchronized (i.e., last SDK_READY or SDK_UPDATE event emitted). If cache expires, it is cleared on initialization. * * @defaultValue `10` */ @@ -1350,12 +1386,17 @@ declare namespace SplitIO { * * @example * ``` - * const getHeaderOverrides = (context) => { - * return { - * 'Authorization': context.headers['Authorization'] + ', other-value', - * 'custom-header': 'custom-value' - * }; - * }; + * const factory = SplitFactory({ + * ... + * sync: { + * getHeaderOverrides: (context) => { + * return { + * 'Authorization': context.headers['Authorization'] + ', other-value', + * 'custom-header': 'custom-value' + * }; + * } + * } + * }); * ``` */ getHeaderOverrides?: (context: { headers: Record }) => Record; @@ -1550,6 +1591,20 @@ declare namespace SplitIO { * @returns The manager instance. */ manager(): IManager; + /** + * Returns the current snapshot of the SDK rollout plan in cache. + * + * Wait for the SDK client to be ready before calling this method. + * + * ```js + * await factory.client().ready(); + * const rolloutPlan = factory.getRolloutPlan(); + * ``` + * + * @param options - An object of type RolloutPlanOptions for advanced options. + * @returns The current snapshot of the SDK rollout plan. + */ + getRolloutPlan(options?: RolloutPlanOptions): RolloutPlan; } /** * This represents the interface for the SDK instance for server-side with asynchronous storage.