diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..02371d1 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,26 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/typescript-node/.devcontainer/base.Dockerfile + +# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster +ARG VARIANT="$VARIANT" +FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} + + +RUN if [ ! -d /workspaces \]; then mkdir /workspaces; fi && chown -R $USER_UID:$USER_GID /workspaces + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment if you want to install an additional version of node using nvm +# ARG EXTRA_NODE_VERSION=10 +# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" + +# [Optional] Uncomment if you want to install more global node packages +# RUN su node -c "npm install -g " + +# Install kubectl +RUN curl -sSL -o /usr/local/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl \ + && chmod +x /usr/local/bin/kubectl + +# Install Helm +RUN curl -s https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4080153 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,57 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/typescript-node +{ + "name": "Feature tog-node", + //runArgs is used to set container name to USERNAME.programname + //the reason it shows ${localEnv:USERNAME} is so that it covers both Windwos and *nix systems as frontend VSCode + "runArgs": ["--init", "--network=host", "--name=tog-node"], + "build": { + "dockerfile": "Dockerfile", + // Update 'VARIANT' to pick a Node version: 16, 14, 12. + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "args": { + "VARIANT": "16-bullseye" + } + }, + + "portsAttributes": { + "9080": { + "label": "api port", + "onAutoForward": "notify" + } + }, + // Set *default* container specific settings.json values on container create. + "settings": {}, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "dbaeumer.vscode-eslint", + "ms-azuretools.vscode-docker", + "mindaro-dev.file-downloader", + "github.vscode-pull-request-github", + "redhat.vscode-yaml" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "node", + "features": { + "docker-from-docker": { + "version": "latest", + "moby": true + }, + "kubectl-helm-minikube": "latest", + "git": "latest", + "github-cli": "latest" + }, + + // install all dependent packages after container is built and files copied into + "postCreateCommand": "npm install -g npm@^7.24.2 && npm install husky --save-dev && npm set-script prepare \"husky install\" && npm run prepare", + "postStartCommand": "npm update -g" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6e6a8d4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/lib/index.js", + "preLaunchTask": "tsc: build - tsconfig.json", + "outFiles": [ + "${workspaceFolder}/lib/**/*.js" + ] + }, + { + "name": "Test via NPM test", + "type": "node", + "request": "launch", + "runtimeArgs": ["run-script", "test"], + "runtimeExecutable": "npm", + "skipFiles": ["/**"], + "sourceMaps": true, + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceRoot}", + "console": "integratedTerminal" + }, + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 00c6429..e99a072 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ const { SessionClient } = require('tog-node') const sessions = new SessionClient('redis://127.0.0.1:6379') // wherever you whish to retrieve a session -const session = await sessions.session('my_app', 'session-123-xyz') +const session = await sessions.session('my_app', 'session-123-xyz', ["session traits 1","session traits 2"]) const buttonColor = session.flags['blue-button'] ? 'blue' : 'red' ``` @@ -43,7 +43,9 @@ await flags.saveFlag({ name: 'blue-button', description: 'Makes the call-to-action button blue', rollout: [ + { value: false} { percentage: 30, value: true } // will be `true` for 30% of users + { traits:["beta","power_user"], percentage: 60, value true} // for sessions that match both traits "beta" and "power_user", 60% of the chances will be true ] }) diff --git a/package-lock.json b/package-lock.json index b773bac..4d6170f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "ioredis": "^4.17.3", - "murmurhash-js": "^1.0.0" + "murmurhash-js": "^1.0.0", + "typescript": "^4.5.5" }, "devDependencies": { "@babel/core": "^7.10.2", @@ -20,10 +21,10 @@ "@types/jest": "^27.4.0", "@types/murmurhash-js": "^1.0.3", "babel-jest": "^25.5.1", + "husky": "^8.0.1", "jest": "^27.5.1", "standard-version": "^9.0.0", "typedoc": "^0.22.11", - "typescript": "^3.9.5", "wait-for-expect": "^3.0.2" } }, @@ -5060,6 +5061,21 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz", + "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9180,9 +9196,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/minimist-options": { @@ -11272,10 +11288,9 @@ } }, "node_modules/typescript": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", - "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", - "dev": true, + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15897,6 +15912,12 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "husky": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz", + "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", + "dev": true + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -19055,9 +19076,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "minimist-options": { @@ -20699,10 +20720,9 @@ } }, "typescript": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", - "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", - "dev": true + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==" }, "uglify-js": { "version": "3.9.4", diff --git a/package.json b/package.json index e6e9896..692e2ed 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "test:watch": "jest --watch --runInBand", "docs": "typedoc", "build": "tsc", - "release": "./scripts/release.sh" + "release": "./scripts/release.sh", + "prepare": "husky install" }, "files": [ "/lib" @@ -18,7 +19,8 @@ "license": "ISC", "dependencies": { "ioredis": "^4.17.3", - "murmurhash-js": "^1.0.0" + "murmurhash-js": "^1.0.0", + "typescript": "^4.5.5" }, "devDependencies": { "@babel/core": "^7.10.2", @@ -28,10 +30,10 @@ "@types/jest": "^27.4.0", "@types/murmurhash-js": "^1.0.3", "babel-jest": "^25.5.1", + "husky": "^8.0.1", "jest": "^27.5.1", "standard-version": "^9.0.0", "typedoc": "^0.22.11", - "typescript": "^3.9.5", "wait-for-expect": "^3.0.2" }, "babel": { @@ -46,5 +48,27 @@ ], "@babel/preset-typescript" ] + }, + "jest": { + "verbose": true, + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "testRegex": "\\.(spec|test)\\.ts$", + "collectCoverage": true, + "collectCoverageFrom": [ + "/src/**/*.(t|j)s" + ], + "coveragePathIgnorePatterns": [ + "/node_modules", + "/migrations", + "/dist", + "/test" + ], + "coverageDirectory": "/test/coverage", + "testEnvironment": "node" } } diff --git a/src/index.ts b/src/index.ts index a06c6b3..7a0c9d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './types' export * from './flagClient' export * from './sessionClient' +export {resolveState} from './sessions' \ No newline at end of file diff --git a/src/sessionClient.ts b/src/sessionClient.ts index 82dadd9..84d93fa 100644 --- a/src/sessionClient.ts +++ b/src/sessionClient.ts @@ -50,7 +50,7 @@ export class SessionClient { * @param id Unique session ID * @param options Options used when creating the flag, which are ignored if it already exists */ - async session(namespace: string, id: string, options?: SessionOptions): Promise { + async session(namespace: string, id: string, traits?: string[], options?: SessionOptions): Promise { try { const flagOverrides = options && options.flags || {} const availableFlags = await withTimeout( @@ -59,7 +59,7 @@ export class SessionClient { const flags = availableFlags .reduce((all, flag) => ({ ...all, - [flag.name]: resolveState(flag.rollout, flag.timestamp || 0, id) + [flag.name]: resolveState(flag.rollout, flag.timestamp || 0, id, traits ?? []) }), {}) const session: Session = { namespace, diff --git a/src/sessions.test.ts b/src/sessions.test.ts index 2c02f5e..f677c7f 100644 --- a/src/sessions.test.ts +++ b/src/sessions.test.ts @@ -5,41 +5,51 @@ describe('pick variant', () => { Array(reps).fill(0) .map((n, i) => pick(i)) .map(r => r && r.toString()) - .reduce((acc, r) => ({ ...acc, [r]: (acc[r] || 0) + 1 }), {}) + .reduce((acc, r) => ({ ...acc, [r.toString()]: (acc[r.toString()] || 0) + 1 }), {}) - test('returns undefined for no variants', () => + /// test new resolve with traits + test('No rollout defined, return false', () => expect(resolveState([], 0, 'abc')).toBe(false)) - test('always returns the same variant with percentage 100', () => { - const n = 1000 - const distribution = getDistribution(n, i => resolveState([ - { value: true, percentage: 100 } - ], i, 'def')) + test('Rollout without traits defined, session with traits return true', () => + expect(resolveState([{ value: true }], 0, 'abc', ["blue"])).toBe(true)) + + test('Rollout traits match exact session traits, return true', () => + expect(resolveState([{ value: true, traits: ["blue"]}], 0, 'abc', ["blue"])).toBe(true)) + + test('Rollout traits defined but session traits empty, return false', () => + expect(resolveState([{ value: true, traits: ["blue"]}], 0, 'abc')).toBe(false)) + + test('Rollout traits defined but not match session traits, return false', () => + expect(resolveState([{ value: true, traits: ["blue"]}], 0, 'abc', ["red"])).toBe(false)) + + test('Rollout traits defined with more elements than session traits, return false', () => + expect(resolveState([{ value: true, traits: ["blue", "circle"]}], 0, 'abc', ["blue"])).toBe(false)) + + test('Multiple Rollout defined more traits matching session traits triumph', () => + expect(resolveState([{ value: true, traits: ["blue", "circle"]},{ value: false, traits: ["blue"]}], 0, 'abc', ["blue","circle"])).toBe(true)) + test('Multiple Rollout defined matching session same number of traits false triumph', () => + expect(resolveState([{ value: true, traits: ["blue"]},{ value: false, traits: ["circle"]}], 0, 'abc', ["blue","circle"])).toBe(false)) + + test('Rollout with percentage 100 matches exact session traits always return true', () => { + const n = 1000 + const distribution = getDistribution(n, i => resolveState( + [{ value: true, percentage: 100 , traits:["blue","circle"]}], + i, + 'def', + ["blue","circle"])) expect(distribution).toEqual({ true: n }) }) - test('correct distribution with complementing weights', () => { - const n = 10000 - const distribution = getDistribution(n, i => resolveState([ - { value: true, percentage: 70 }, - { value: false, percentage: 30 } - ], i, 'ghi')) - - expect(distribution['true'] + distribution['false']).toEqual(n) + test('Multiple Rollouts both true, higher percentage triumph', () => { + const n = 1000 + const distribution = getDistribution(n, i => resolveState( + [{ value: true, percentage: 20 , traits:["blue","circle"]}, { value: true, percentage: 70 , traits:["big","circle"]}], + i, + 'def', + ["blue","circle","big"])) expect(distribution['true'] / n).toBeCloseTo(0.7, 1) - expect(distribution['false'] / n).toBeCloseTo(0.3, 1) }) - test('correct distribution with incomplete variants', () => { - const n = 10000 - const distribution = getDistribution(n, i => resolveState([ - { value: true, percentage: 20 }, - { value: false, percentage: 50 } - ], i, 'jkl')) - - expect(distribution['true'] + distribution['false']).toEqual(n) - expect(distribution['true'] / n).toBeCloseTo(0.2, 1) - expect(distribution['false'] / n).toBeCloseTo(0.8, 1) - }) }) diff --git a/src/sessions.ts b/src/sessions.ts index 5bb5442..fbb113b 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -10,7 +10,7 @@ export function parseSession (namespace: string, id: string, value: string): Ses } } -export function resolveState (rollouts: Rollout[], timestamp: number, sessionId: string): boolean { +export function resolveState_no_traits (rollouts: Rollout[], timestamp: number, sessionId: string): boolean { if (!rollouts || rollouts.length === 0) { return false } @@ -25,3 +25,65 @@ export function resolveState (rollouts: Rollout[], timestamp: number, sessionId: return (rollout && rollout.value) || false } + +const hashCode = function(str:string) { + var hash = 0, + i, chr; + if (str.length === 0) return hash; + for (i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +export function resolveState (rollouts: Rollout[], timestamp: number, sessionId: string, sessionTraits: string[] = []): boolean { + if (!rollouts || rollouts.length === 0) { + return false + } + + const param = hashCode(`${sessionId}${timestamp}`) % 100; + // the strategy for session traits to rollout traits matches: + // * both traits are string[], the match can be multi to multi. + // a match means all of the rollout traits are found in the session traits (i.e. give rollout traits ["a","b","c"], session traits ["a","b","c"] and ["a","b","c","d"] will match but session traits ["a","b"] do not) + // the more array member matches has higher precedence: + // (i.e. session traits ["a","b","c"], will match rollout traits ["a","b","c"] and ["a","b"], but rollout traits ["a","b","c"] will have higher precendence because it is more specific match + // if multiple Rollout traits match a session traits, {"value":false} take precedence (so you can ensure a feature matches the traits can be turned off) + // if multiple Rollout traits of {"value":false} match, the highest value of percentage takes precedence. + // {"value":true} Rollout traits with same level of precendence will only be considered if no false traits matches at this specificity + // if no traits matching rollout strategy found, then consider strategies do not have traits () + + // Examples: + // when the session has traits ["blue", "square", "plastic"], it will match rollout strategies that has traits: ["blue"], or ["blue","square"] or ["blue", "square", "plastic"], + // however, a rollout strategy traits:["blue", "square", "metal"] will NOT match this session + // so if you want both "blue" and "red" to be considered, they shall be 2 different rollout strategies, not listed as traits:["blue","red"], because this will only match sessions have traits:["blue","red"...] + let no_of_traits = -1; + let rollout_percentage = rollouts.reduce( + (pv, cro) => { + if ( cro.traits == null || cro.traits?.reduce((tpv, tcv) => {return tpv && sessionTraits.includes(tcv)}, (1==1)) ) { // if all Rollout Traits are found in session traits + if ( (cro.traits?.length ?? 0) > no_of_traits ) { // if this Rollout strategy has more specific matching than previous ones use this percentage value (default is 100) + no_of_traits = (cro.traits?.length ?? 0); + if ( cro.value ) { + return (typeof cro.percentage === 'number' )? cro.percentage : 100 ; + } else { // if this is a "turn off" strategy, turn the percentage to a negative number, + return (typeof cro.percentage === 'number' )? -cro.percentage : -100 ; + } + } else if ((cro.traits?.length ?? 0 ) == no_of_traits ) { // if equal #_of_matching traits, false take precedence, then larger percentage value + if ( cro.value == false ) { + return Math.min( pv, (typeof cro.percentage === 'number' )? -cro.percentage : -100 ); + } else { + if (pv >= 0) { // if this is true, then only change the value if previous percentage is positive (meaning no false rules have applied yet at this specifity level) + return Math.max( pv, (typeof cro.percentage === 'number' )? cro.percentage : 100 ); + } + } + } + }; + return pv; + } + ,0 + ) + + // default is false + return (param * Math.sign(1/rollout_percentage) < rollout_percentage ) || false +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b8fc446..b525cc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,9 @@ export interface Rollout { /** If defined, applies the given value for X% of the sessions */ percentage?: number + /** If defined, applies to only sessions match ALL the elements in this string[] */ + traits?: string[] + /** Value to be used */ value: FlagValue } diff --git a/test/saveFlag.test.ts b/test/saveFlag.test.ts index e7fd7dd..b43ec33 100644 --- a/test/saveFlag.test.ts +++ b/test/saveFlag.test.ts @@ -40,7 +40,7 @@ describe.only('save flag', () => { namespace: 'foo', name: 'black', timestamp: 123, - rollout: [{ value: true }], + rollout: [{ value: true , percentage: 90}], description: 'some description' } await tog.saveFlag(flag) @@ -56,7 +56,7 @@ describe.only('save flag', () => { namespace: 'foo', name: 'black', rollout: [ - { value: true, percentage: 42 } + { value: true, percentage: 42 , traits: ["black","circle"]} ] } await tog.saveFlag(flag) @@ -72,7 +72,7 @@ describe.only('save flag', () => { namespace: 'foo', name: 'black', timestamp: 123, - rollout: [{ value: true }], + rollout: [{ value: true , percentage: 73, traits:["blue"]}], } await tog.saveFlag(flag) diff --git a/test/session.test.ts b/test/session.test.ts index 5ee9cf2..36a9e03 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -149,7 +149,7 @@ describe('session', () => { { namespace, timestamp: 2, name: 'white', rollout: [] } ]) - const session = await tog.session('foo', 'abc123', { flags: { blue: true } }) + const session = await tog.session('foo', 'abc123', [], { flags: { blue: true } }) expect(session).toEqual({ namespace: 'foo', id: 'abc123', @@ -166,7 +166,7 @@ describe('session', () => { { namespace, timestamp: 2, name: 'white', rollout: [] } ]) - const session = await tog.session('foo', 'abc123', { flags: { white: true } }) + const session = await tog.session('foo', 'abc123', [], { flags: { white: true } }) expect(session).toEqual({ namespace: 'foo', id: 'abc123',