From 21116726a227e1dcaa3636e2c174978cd34aabdd Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 4 Aug 2025 11:48:45 +0200 Subject: [PATCH 1/4] feat: add new context filter operators - Bumped version of `@bucketco/flag-evaluation` to 0.2.0 and added new context filter operators: `DATE_AFTER` and `DATE_BEFORE`. - Updated `@bucketco/node-sdk` to 1.9.0 to reflect the new version of `@bucketco/flag-evaluation`. - Updated `@bucketco/openfeature-node-provider` to use the latest `@bucketco/node-sdk` version. --- packages/flag-evaluation/package.json | 2 +- packages/flag-evaluation/src/index.ts | 12 ++++++++++++ packages/node-sdk/package.json | 2 +- packages/openfeature-node-provider/package.json | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/flag-evaluation/package.json b/packages/flag-evaluation/package.json index 0b52d043..cafcaa4f 100644 --- a/packages/flag-evaluation/package.json +++ b/packages/flag-evaluation/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/flag-evaluation", - "version": "0.1.5", + "version": "0.2.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 51488f7e..36b92824 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -85,6 +85,8 @@ type ContextFilterOperator = | "LT" | "AFTER" | "BEFORE" + | "DATE_AFTER" + | "DATE_BEFORE" | "SET" | "NOT_SET" | "IS_TRUE" @@ -342,6 +344,16 @@ export function evaluate( ? fieldValueDate > daysAgo.getTime() : fieldValueDate < daysAgo.getTime(); } + case "DATE_AFTER": { + const fieldValueDate = new Date(fieldValue).getTime(); + const valueDate = new Date(value).getTime(); + return fieldValueDate >= valueDate; + } + case "DATE_BEFORE": { + const fieldValueDate = new Date(fieldValue).getTime(); + const valueDate = new Date(value).getTime(); + return fieldValueDate <= valueDate; + } case "SET": return fieldValue !== ""; case "NOT_SET": diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 7329271e..2b517667 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -44,6 +44,6 @@ "vitest": "~1.6.0" }, "dependencies": { - "@bucketco/flag-evaluation": "0.1.4" + "@bucketco/flag-evaluation": "0.2.0" } } diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 66a642fb..1772b922 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -50,7 +50,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@bucketco/node-sdk": "1.8.4" + "@bucketco/node-sdk": "1.9.0" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" From b7e4fdb2c24cb611fb239f421446ce2d9f4cec00 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 4 Aug 2025 11:51:54 +0200 Subject: [PATCH 2/4] chore: update lock file --- yarn.lock | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/yarn.lock b/yarn.lock index 62534b0f..36c0af2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -770,16 +770,7 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/flag-evaluation@npm:0.1.4": - version: 0.1.4 - resolution: "@bucketco/flag-evaluation@npm:0.1.4" - dependencies: - js-sha256: "npm:0.11.0" - checksum: 10c0/d1f0513a3167d1c4d2a3caf782072f949f508760d77be2c6d5957f5a92d76aaf70467c1d923591c6663a6dcbe01f4d476e46eb501834231ae3d4f28ecff6640c - languageName: node - linkType: hard - -"@bucketco/flag-evaluation@workspace:packages/flag-evaluation": +"@bucketco/flag-evaluation@npm:0.2.0, @bucketco/flag-evaluation@workspace:packages/flag-evaluation": version: 0.0.0-use.local resolution: "@bucketco/flag-evaluation@workspace:packages/flag-evaluation" dependencies: @@ -795,22 +786,13 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:1.8.4": - version: 1.8.4 - resolution: "@bucketco/node-sdk@npm:1.8.4" - dependencies: - "@bucketco/flag-evaluation": "npm:0.1.4" - checksum: 10c0/f5270915b61526f69f7dbd37b41edf0f130a4a008730f20baf00bbbf49c0a2e3e190a56d35a9451d5645e6da4d874f5d0d555f378596440cccb10306c4362003 - languageName: node - linkType: hard - -"@bucketco/node-sdk@workspace:packages/node-sdk": +"@bucketco/node-sdk@npm:1.9.0, @bucketco/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@bucketco/node-sdk@workspace:packages/node-sdk" dependencies: "@babel/core": "npm:~7.24.7" "@bucketco/eslint-config": "npm:~0.0.2" - "@bucketco/flag-evaluation": "npm:0.1.4" + "@bucketco/flag-evaluation": "npm:0.2.0" "@bucketco/tsconfig": "npm:~0.0.2" "@types/node": "npm:^22.12.0" "@vitest/coverage-v8": "npm:~1.6.0" @@ -854,7 +836,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.24.7" "@bucketco/eslint-config": "npm:~0.0.2" - "@bucketco/node-sdk": "npm:1.8.4" + "@bucketco/node-sdk": "npm:1.9.0" "@bucketco/tsconfig": "npm:~0.0.2" "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" From b9149cf17d6776447bd6acc0ed52795b500f8e79 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 4 Aug 2025 12:08:14 +0200 Subject: [PATCH 3/4] fix: add NaN checks --- packages/flag-evaluation/src/index.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 36b92824..1e664b0d 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -344,15 +344,19 @@ export function evaluate( ? fieldValueDate > daysAgo.getTime() : fieldValueDate < daysAgo.getTime(); } - case "DATE_AFTER": { - const fieldValueDate = new Date(fieldValue).getTime(); - const valueDate = new Date(value).getTime(); - return fieldValueDate >= valueDate; - } + case "DATE_AFTER": case "DATE_BEFORE": { const fieldValueDate = new Date(fieldValue).getTime(); const valueDate = new Date(value).getTime(); - return fieldValueDate <= valueDate; + if (isNaN(fieldValueDate) || isNaN(valueDate)) { + console.error( + `${operator} operator requires valid date values: ${fieldValue}, ${value}`, + ); + return false; + } + return operator === "DATE_AFTER" + ? fieldValueDate >= valueDate + : fieldValueDate <= valueDate; } case "SET": return fieldValue !== ""; From a88e4fa8bb4c36166172d1283d815d6f9103ac12 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 4 Aug 2025 12:08:20 +0200 Subject: [PATCH 4/4] test: add tests --- packages/flag-evaluation/test/index.test.ts | 288 ++++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 5434c0ee..fe01b606 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -471,6 +471,235 @@ describe("evaluate feature targeting integration ", () => { expect(res.value ?? false).toEqual(expected); }, ); + + describe("DATE_AFTER and DATE_BEFORE in feature rules", () => { + it("should evaluate DATE_AFTER operator in feature rules", () => { + const res = evaluateFeatureRules({ + featureKey: "time_based_feature", + rules: [ + { + value: "enabled", + filter: { + type: "context", + field: "user.createdAt", + operator: "DATE_AFTER", + values: ["2024-01-01"], + }, + }, + ], + context: { + user: { + createdAt: "2024-06-15", + }, + }, + }); + + expect(res).toEqual({ + featureKey: "time_based_feature", + value: "enabled", + context: { + "user.createdAt": "2024-06-15", + }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + + it("should evaluate DATE_BEFORE operator in feature rules", () => { + const res = evaluateFeatureRules({ + featureKey: "legacy_feature", + rules: [ + { + value: "enabled", + filter: { + type: "context", + field: "user.lastLogin", + operator: "DATE_BEFORE", + values: ["2024-12-31"], + }, + }, + ], + context: { + user: { + lastLogin: "2024-01-15", + }, + }, + }); + + expect(res).toEqual({ + featureKey: "legacy_feature", + value: "enabled", + context: { + "user.lastLogin": "2024-01-15", + }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + + it("should handle complex rules with DATE_AFTER and DATE_BEFORE in groups", () => { + const res = evaluateFeatureRules({ + featureKey: "time_window_feature", + rules: [ + { + value: "active", + filter: { + type: "group", + operator: "and", + filters: [ + { + type: "context", + field: "event.startDate", + operator: "DATE_AFTER", + values: ["2024-01-01"], + }, + { + type: "context", + field: "event.endDate", + operator: "DATE_BEFORE", + values: ["2024-12-31"], + }, + ], + }, + }, + ], + context: { + event: { + startDate: "2024-06-01", + endDate: "2024-11-30", + }, + }, + }); + + expect(res).toEqual({ + featureKey: "time_window_feature", + value: "active", + context: { + "event.startDate": "2024-06-01", + "event.endDate": "2024-11-30", + }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + + it("should fail when DATE_AFTER condition is not met", () => { + const res = evaluateFeatureRules({ + featureKey: "future_feature", + rules: [ + { + value: "enabled", + filter: { + type: "context", + field: "user.signupDate", + operator: "DATE_AFTER", + values: ["2024-12-01"], + }, + }, + ], + context: { + user: { + signupDate: "2024-01-15", // Too early + }, + }, + }); + + expect(res).toEqual({ + featureKey: "future_feature", + value: undefined, + context: { + "user.signupDate": "2024-01-15", + }, + ruleEvaluationResults: [false], + reason: "no matched rules", + missingContextFields: [], + }); + }); + + it("should fail when DATE_BEFORE condition is not met", () => { + const res = evaluateFeatureRules({ + featureKey: "past_feature", + rules: [ + { + value: "enabled", + filter: { + type: "context", + field: "user.lastActivity", + operator: "DATE_BEFORE", + values: ["2024-01-01"], + }, + }, + ], + context: { + user: { + lastActivity: "2024-06-15", // Too late + }, + }, + }); + + expect(res).toEqual({ + featureKey: "past_feature", + value: undefined, + context: { + "user.lastActivity": "2024-06-15", + }, + ruleEvaluationResults: [false], + reason: "no matched rules", + missingContextFields: [], + }); + }); + + it("should work with optimized evaluator", () => { + const evaluator = newEvaluator([ + { + value: "time_sensitive", + filter: { + type: "group", + operator: "and", + filters: [ + { + type: "context", + field: "user.subscriptionDate", + operator: "DATE_AFTER", + values: ["2024-01-01"], + }, + { + type: "context", + field: "user.trialEndDate", + operator: "DATE_BEFORE", + values: ["2024-12-31"], + }, + ], + }, + }, + ]); + + const res = evaluator( + { + user: { + subscriptionDate: "2024-03-15", + trialEndDate: "2024-09-30", + }, + }, + "subscription_feature", + ); + + expect(res).toEqual({ + featureKey: "subscription_feature", + value: "time_sensitive", + context: { + "user.subscriptionDate": "2024-03-15", + "user.trialEndDate": "2024-09-30", + }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + }); }); describe("operator evaluation", () => { @@ -533,6 +762,65 @@ describe("operator evaluation", () => { expect(res).toEqual(expected); }); } + + describe("DATE_AFTER and DATE_BEFORE operators", () => { + const dateTests = [ + // DATE_AFTER tests + ["2024-01-15", "DATE_AFTER", "2024-01-10", true], // After + ["2024-01-10", "DATE_AFTER", "2024-01-10", true], // Same date (>=) + ["2024-01-05", "DATE_AFTER", "2024-01-10", false], // Before + ["2024-12-31", "DATE_AFTER", "2024-01-01", true], // Much later + ["2023-01-01", "DATE_AFTER", "2024-01-01", false], // Much earlier + + // DATE_BEFORE tests + ["2024-01-05", "DATE_BEFORE", "2024-01-10", true], // Before + ["2024-01-10", "DATE_BEFORE", "2024-01-10", true], // Same date (<=) + ["2024-01-15", "DATE_BEFORE", "2024-01-10", false], // After + ["2023-01-01", "DATE_BEFORE", "2024-01-01", true], // Much earlier + ["2024-12-31", "DATE_BEFORE", "2024-01-01", false], // Much later + + // Edge cases with different date formats + ["2024-01-10T10:30:00Z", "DATE_AFTER", "2024-01-10T10:00:00Z", true], // ISO format with time + ["2024-01-10T09:30:00Z", "DATE_BEFORE", "2024-01-10T10:00:00Z", true], // ISO format with time + [ + "2024-01-10T10:30:00.123Z", + "DATE_AFTER", + "2024-01-10T10:00:00.000Z", + true, + ], // ISO format with time and milliseconds + [ + "2024-01-10T09:30:00.123Z", + "DATE_BEFORE", + "2024-01-10T10:00:00.000Z", + true, + ], // ISO format with time and milliseconds + ["01/15/2024", "DATE_AFTER", "01/10/2024", true], // US format + ["01/05/2024", "DATE_BEFORE", "01/10/2024", true], // US format + ] as const; + + for (const [fieldValue, operator, filterValue, expected] of dateTests) { + it(`evaluates '${fieldValue}' ${operator} '${filterValue}' = ${expected}`, () => { + const res = evaluate(fieldValue, operator, [filterValue]); + expect(res).toEqual(expected); + }); + } + + it("handles invalid date formats gracefully", () => { + // Invalid dates should result in NaN comparisons and return false + expect(evaluate("invalid-date", "DATE_AFTER", ["2024-01-10"])).toBe( + false, + ); + expect(evaluate("2024-01-10", "DATE_AFTER", ["invalid-date"])).toBe( + false, + ); + expect(evaluate("invalid-date", "DATE_BEFORE", ["2024-01-10"])).toBe( + false, + ); + expect(evaluate("2024-01-10", "DATE_BEFORE", ["invalid-date"])).toBe( + false, + ); + }); + }); }); describe("rollout hash", () => {