diff --git a/.changeset/add-semver-operators.md b/.changeset/add-semver-operators.md new file mode 100644 index 0000000000..34a651a8b2 --- /dev/null +++ b/.changeset/add-semver-operators.md @@ -0,0 +1,5 @@ +--- +'posthog-node': patch +--- + +Add semver comparison operators to local feature flag evaluation: semver_eq, semver_neq, semver_gt, semver_gte, semver_lt, semver_lte, semver_tilde, semver_caret, and semver_wildcard diff --git a/packages/node/src/__tests__/feature-flags.spec.ts b/packages/node/src/__tests__/feature-flags.spec.ts index 864d486488..5309dd8899 100644 --- a/packages/node/src/__tests__/feature-flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.spec.ts @@ -4,6 +4,7 @@ import { matchProperty, InconclusiveMatchError, relativeDateParseForFeatureFlagMatching, + parseSemver, } from '@/extensions/feature-flags/feature-flags' import { anyFlagsCall, anyLocalEvalCall, apiImplementation, waitForPromises } from './utils' @@ -2993,6 +2994,417 @@ describe('match properties', () => { }) }) +describe('semver parsing', () => { + it('parses basic semver strings', () => { + expect(parseSemver('1.2.3')).toEqual([1, 2, 3]) + expect(parseSemver('0.0.0')).toEqual([0, 0, 0]) + expect(parseSemver('10.20.30')).toEqual([10, 20, 30]) + }) + + it('strips v prefix', () => { + expect(parseSemver('v1.2.3')).toEqual([1, 2, 3]) + expect(parseSemver('V1.2.3')).toEqual([1, 2, 3]) + }) + + it('strips leading and trailing whitespace', () => { + expect(parseSemver(' 1.2.3 ')).toEqual([1, 2, 3]) + expect(parseSemver('\t1.2.3\n')).toEqual([1, 2, 3]) + }) + + it('strips pre-release and build metadata', () => { + expect(parseSemver('1.2.3-alpha')).toEqual([1, 2, 3]) + expect(parseSemver('1.2.3-alpha.1')).toEqual([1, 2, 3]) + expect(parseSemver('1.2.3+build')).toEqual([1, 2, 3]) + expect(parseSemver('1.2.3-alpha+build')).toEqual([1, 2, 3]) + expect(parseSemver('1.2.3-rc.1+build.123')).toEqual([1, 2, 3]) + }) + + it('defaults missing components to 0', () => { + expect(parseSemver('1.2')).toEqual([1, 2, 0]) + expect(parseSemver('1')).toEqual([1, 0, 0]) + }) + + it('ignores extra components beyond third', () => { + expect(parseSemver('1.2.3.4')).toEqual([1, 2, 3]) + expect(parseSemver('1.2.3.4.5.6')).toEqual([1, 2, 3]) + }) + + it('handles leading zeros', () => { + expect(parseSemver('01.02.03')).toEqual([1, 2, 3]) + }) + + it('throws on invalid input', () => { + expect(() => parseSemver('')).toThrow(InconclusiveMatchError) + expect(() => parseSemver('.1.2')).toThrow(InconclusiveMatchError) + expect(() => parseSemver('abc')).toThrow(InconclusiveMatchError) + expect(() => parseSemver('a.b.c')).toThrow(InconclusiveMatchError) + expect(() => parseSemver('1.2.three')).toThrow(InconclusiveMatchError) + }) + + it('throws on malformed version with trailing non-numeric characters', () => { + // parseInt('3alpha', 10) returns 3, but we should reject this + expect(() => parseSemver('1.2.3alpha')).toThrow(InconclusiveMatchError) + expect(() => parseSemver('1.2alpha.3')).toThrow(InconclusiveMatchError) + expect(() => parseSemver('1alpha.2.3')).toThrow(InconclusiveMatchError) + }) +}) + +describe('semver operators', () => { + describe('semver_eq', () => { + it('matches equal versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '1.2.3' })).toBe(true) + expect(matchProperty({ key: 'version', value: '0.0.0', operator: 'semver_eq' }, { version: '0.0.0' })).toBe(true) + }) + + it('matches with v prefix', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: 'v1.2.3' })).toBe(true) + expect(matchProperty({ key: 'version', value: 'v1.2.3', operator: 'semver_eq' }, { version: '1.2.3' })).toBe(true) + }) + + it('matches with pre-release stripped', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '1.2.3-alpha' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3-alpha', operator: 'semver_eq' }, { version: '1.2.3' })).toBe( + true + ) + }) + + it('matches partial versions', () => { + expect(matchProperty({ key: 'version', value: '1.2', operator: 'semver_eq' }, { version: '1.2.0' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1', operator: 'semver_eq' }, { version: '1.0.0' })).toBe(true) + }) + + it('does not match different versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '1.2.4' })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '1.3.3' })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '2.2.3' })).toBe(false) + }) + }) + + describe('semver_neq', () => { + it('matches different versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_neq' }, { version: '1.2.4' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_neq' }, { version: '2.0.0' })).toBe(true) + }) + + it('does not match equal versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_neq' }, { version: '1.2.3' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_neq' }, { version: 'v1.2.3' })).toBe( + false + ) + }) + }) + + describe('semver_gt', () => { + it('matches greater versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: '1.2.4' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: '1.3.0' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: '2.0.0' })).toBe(true) + }) + + it('does not match equal or lesser versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: '1.2.3' })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: '1.2.2' })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: '0.9.9' })).toBe(false) + }) + }) + + describe('semver_gte', () => { + it('matches greater or equal versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gte' }, { version: '1.2.3' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gte' }, { version: '1.2.4' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gte' }, { version: '2.0.0' })).toBe(true) + }) + + it('does not match lesser versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gte' }, { version: '1.2.2' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gte' }, { version: '0.9.9' })).toBe( + false + ) + }) + }) + + describe('semver_lt', () => { + it('matches lesser versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lt' }, { version: '1.2.2' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lt' }, { version: '1.1.9' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lt' }, { version: '0.9.9' })).toBe(true) + }) + + it('does not match equal or greater versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lt' }, { version: '1.2.3' })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lt' }, { version: '1.2.4' })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lt' }, { version: '2.0.0' })).toBe(false) + }) + }) + + describe('semver_lte', () => { + it('matches lesser or equal versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lte' }, { version: '1.2.3' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lte' }, { version: '1.2.2' })).toBe(true) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lte' }, { version: '0.9.9' })).toBe(true) + }) + + it('does not match greater versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lte' }, { version: '1.2.4' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_lte' }, { version: '2.0.0' })).toBe( + false + ) + }) + }) + + describe('semver_tilde', () => { + // ~1.2.3 means >=1.2.3 and <1.3.0 + it('matches versions in tilde range', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: '1.2.3' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: '1.2.4' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: '1.2.99' })).toBe( + true + ) + }) + + it('does not match versions outside tilde range', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: '1.2.2' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: '1.3.0' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: '2.0.0' })).toBe( + false + ) + }) + + it('handles edge cases at boundaries', () => { + // Lower bound is inclusive + expect(matchProperty({ key: 'version', value: '1.0.0', operator: 'semver_tilde' }, { version: '1.0.0' })).toBe( + true + ) + // Upper bound is exclusive + expect(matchProperty({ key: 'version', value: '1.0.0', operator: 'semver_tilde' }, { version: '1.1.0' })).toBe( + false + ) + }) + }) + + describe('semver_caret', () => { + // ^1.2.3 means >=1.2.3 <2.0.0 (major > 0) + describe('when major > 0', () => { + it('matches versions in caret range', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '1.2.3' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '1.2.4' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '1.9.9' })).toBe( + true + ) + expect( + matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '1.99.99' }) + ).toBe(true) + }) + + it('does not match versions outside caret range', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '1.2.2' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '2.0.0' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: '0.9.9' })).toBe( + false + ) + }) + }) + + // ^0.2.3 means >=0.2.3 <0.3.0 (major = 0, minor > 0) + describe('when major = 0 and minor > 0', () => { + it('matches versions in caret range', () => { + expect(matchProperty({ key: 'version', value: '0.2.3', operator: 'semver_caret' }, { version: '0.2.3' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '0.2.3', operator: 'semver_caret' }, { version: '0.2.4' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '0.2.3', operator: 'semver_caret' }, { version: '0.2.99' })).toBe( + true + ) + }) + + it('does not match versions outside caret range', () => { + expect(matchProperty({ key: 'version', value: '0.2.3', operator: 'semver_caret' }, { version: '0.2.2' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '0.2.3', operator: 'semver_caret' }, { version: '0.3.0' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '0.2.3', operator: 'semver_caret' }, { version: '1.0.0' })).toBe( + false + ) + }) + }) + + // ^0.0.3 means >=0.0.3 <0.0.4 (major = 0, minor = 0) + describe('when major = 0 and minor = 0', () => { + it('matches exact patch version only', () => { + expect(matchProperty({ key: 'version', value: '0.0.3', operator: 'semver_caret' }, { version: '0.0.3' })).toBe( + true + ) + }) + + it('does not match different patch versions', () => { + expect(matchProperty({ key: 'version', value: '0.0.3', operator: 'semver_caret' }, { version: '0.0.2' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '0.0.3', operator: 'semver_caret' }, { version: '0.0.4' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '0.0.3', operator: 'semver_caret' }, { version: '0.1.0' })).toBe( + false + ) + }) + }) + }) + + describe('semver_wildcard', () => { + // 1.* means >=1.0.0 <2.0.0 + describe('major wildcard (X.*)', () => { + it('matches versions in range', () => { + expect(matchProperty({ key: 'version', value: '1.*', operator: 'semver_wildcard' }, { version: '1.0.0' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.*', operator: 'semver_wildcard' }, { version: '1.5.5' })).toBe( + true + ) + expect( + matchProperty({ key: 'version', value: '1.*', operator: 'semver_wildcard' }, { version: '1.99.99' }) + ).toBe(true) + }) + + it('does not match versions outside range', () => { + expect(matchProperty({ key: 'version', value: '1.*', operator: 'semver_wildcard' }, { version: '0.9.9' })).toBe( + false + ) + expect(matchProperty({ key: 'version', value: '1.*', operator: 'semver_wildcard' }, { version: '2.0.0' })).toBe( + false + ) + }) + }) + + // 1.2.* means >=1.2.0 <1.3.0 + describe('minor wildcard (X.Y.*)', () => { + it('matches versions in range', () => { + expect( + matchProperty({ key: 'version', value: '1.2.*', operator: 'semver_wildcard' }, { version: '1.2.0' }) + ).toBe(true) + expect( + matchProperty({ key: 'version', value: '1.2.*', operator: 'semver_wildcard' }, { version: '1.2.5' }) + ).toBe(true) + expect( + matchProperty({ key: 'version', value: '1.2.*', operator: 'semver_wildcard' }, { version: '1.2.99' }) + ).toBe(true) + }) + + it('does not match versions outside range', () => { + expect( + matchProperty({ key: 'version', value: '1.2.*', operator: 'semver_wildcard' }, { version: '1.1.9' }) + ).toBe(false) + expect( + matchProperty({ key: 'version', value: '1.2.*', operator: 'semver_wildcard' }, { version: '1.3.0' }) + ).toBe(false) + }) + }) + }) + + describe('error handling', () => { + it('throws InconclusiveMatchError for missing property key', () => { + expect(() => + matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { other_key: '1.2.3' }) + ).toThrow(InconclusiveMatchError) + }) + + it('returns false for null property value', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: null })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_gt' }, { version: null })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_tilde' }, { version: null })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_caret' }, { version: null })).toBe(false) + expect(matchProperty({ key: 'version', value: '1.*', operator: 'semver_wildcard' }, { version: null })).toBe( + false + ) + }) + + it('returns false for undefined property value', () => { + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: undefined })).toBe( + false + ) + }) + + it('throws InconclusiveMatchError for invalid override value', () => { + expect(() => + matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: 'not-a-version' }) + ).toThrow(InconclusiveMatchError) + expect(() => matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '' })).toThrow( + InconclusiveMatchError + ) + }) + + it('throws InconclusiveMatchError for invalid flag value', () => { + expect(() => + matchProperty({ key: 'version', value: 'not-a-version', operator: 'semver_eq' }, { version: '1.2.3' }) + ).toThrow(InconclusiveMatchError) + }) + }) + + describe('edge cases', () => { + it('handles whitespace in values', () => { + expect(matchProperty({ key: 'version', value: ' 1.2.3 ', operator: 'semver_eq' }, { version: '1.2.3' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: ' 1.2.3 ' })).toBe( + true + ) + }) + + it('handles leading zeros', () => { + expect(matchProperty({ key: 'version', value: '01.02.03', operator: 'semver_eq' }, { version: '1.2.3' })).toBe( + true + ) + }) + + it('handles 4-part versions', () => { + expect(matchProperty({ key: 'version', value: '1.2.3.4', operator: 'semver_eq' }, { version: '1.2.3' })).toBe( + true + ) + expect(matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: '1.2.3.4' })).toBe( + true + ) + }) + + it('handles numeric property values', () => { + // Numbers get converted to strings + expect(matchProperty({ key: 'version', value: '1.0.0', operator: 'semver_eq' }, { version: 1 })).toBe(true) + }) + + it('pre-release suffixes are stripped on both sides', () => { + expect( + matchProperty({ key: 'version', value: '1.2.3-beta.1', operator: 'semver_eq' }, { version: '1.2.3-alpha.2' }) + ).toBe(true) + }) + }) +}) + describe('relative date parsing', () => { jest.useFakeTimers() beforeEach(() => { diff --git a/packages/node/src/extensions/feature-flags/feature-flags.ts b/packages/node/src/extensions/feature-flags/feature-flags.ts index 9c3ff80ca7..694b6fcb4a 100644 --- a/packages/node/src/extensions/feature-flags/feature-flags.ts +++ b/packages/node/src/extensions/feature-flags/feature-flags.ts @@ -1082,6 +1082,45 @@ function matchProperty( } return overrideDate > parsedDate } + case 'semver_eq': { + const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) + return cmp === 0 + } + case 'semver_neq': { + const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) + return cmp !== 0 + } + case 'semver_gt': { + const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) + return cmp > 0 + } + case 'semver_gte': { + const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) + return cmp >= 0 + } + case 'semver_lt': { + const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) + return cmp < 0 + } + case 'semver_lte': { + const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) + return cmp <= 0 + } + case 'semver_tilde': { + const overrideParsed = parseSemver(String(overrideValue)) + const { lower, upper } = computeTildeBounds(String(value)) + return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0 + } + case 'semver_caret': { + const overrideParsed = parseSemver(String(overrideValue)) + const { lower, upper } = computeCaretBounds(String(value)) + return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0 + } + case 'semver_wildcard': { + const overrideParsed = parseSemver(String(overrideValue)) + const { lower, upper } = computeWildcardBounds(String(value)) + return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0 + } default: throw new InconclusiveMatchError(`Unknown operator: ${operator}`) } @@ -1233,6 +1272,132 @@ function isValidRegex(regex: string): boolean { } } +type SemverTuple = [number, number, number] + +/** + * Parse a version string into a [major, minor, patch] tuple. + * - Strips leading/trailing whitespace + * - Strips 'v' or 'V' prefix + * - Strips pre-release and build metadata (-alpha, +build) + * - Defaults missing components to 0 + * - Ignores extra components beyond the third + * - Throws InconclusiveMatchError for invalid input + */ +function parseSemver(value: string): SemverTuple { + const text = String(value).trim().replace(/^[vV]/, '') + + // Strip pre-release and build metadata + const baseVersion = text.split('-')[0].split('+')[0] + + if (!baseVersion || baseVersion.startsWith('.')) { + throw new InconclusiveMatchError(`Invalid semver: ${value}`) + } + + const parts = baseVersion.split('.') + + const parsePart = (part: string | undefined): number => { + if (part === undefined || part === '') { + return 0 + } + if (!/^\d+$/.test(part)) { + throw new InconclusiveMatchError(`Invalid semver: ${value}`) + } + return parseInt(part, 10) + } + + const major = parsePart(parts[0]) + const minor = parsePart(parts[1]) + const patch = parsePart(parts[2]) + + return [major, minor, patch] +} + +/** + * Compare two semver tuples. + * Returns -1 if a < b, 0 if a == b, 1 if a > b + */ +function compareSemverTuples(a: SemverTuple, b: SemverTuple): number { + for (let i = 0; i < 3; i++) { + if (a[i] < b[i]) return -1 + if (a[i] > b[i]) return 1 + } + return 0 +} + +/** + * Compute bounds for tilde operator: ~X.Y.Z means >=X.Y.Z and 0: >=X.Y.Z <(X+1).0.0 + * - ^0.Y.Z where Y > 0: >=0.Y.Z <0.(Y+1).0 + * - ^0.0.Z: >=0.0.Z <0.0.(Z+1) + */ +function computeCaretBounds(value: string): { lower: SemverTuple; upper: SemverTuple } { + const parsed = parseSemver(value) + const [major, minor, patch] = parsed + const lower: SemverTuple = [major, minor, patch] + + let upper: SemverTuple + if (major > 0) { + upper = [major + 1, 0, 0] + } else if (minor > 0) { + upper = [0, minor + 1, 0] + } else { + upper = [0, 0, patch + 1] + } + + return { lower, upper } +} + +/** + * Compute bounds for wildcard operator: + * - "X.*" or "X" with wildcard: >=X.0.0 <(X+1).0.0 + * - "X.Y.*": >=X.Y.0