diff --git a/.changeset/exp-3256-now-accessor.md b/.changeset/exp-3256-now-accessor.md new file mode 100644 index 00000000..da54ad4e --- /dev/null +++ b/.changeset/exp-3256-now-accessor.md @@ -0,0 +1,9 @@ +--- +'flags': minor +'@flags-sdk/vercel': minor +'@vercel/flags-core': minor +--- + +Add a system `now` accessor and lock evaluation time per request/batch. + +Flag conditions can compare against the current time with BEFORE/AFTER (ISO or epoch ms) or numeric GT/GTE/LT/LTE. Rollouts and bulk evaluation share one locked `now` so results stay consistent within a request. diff --git a/packages/adapter-global-config/src/index.test.ts b/packages/adapter-global-config/src/index.test.ts index 330f07ff..c18ccf43 100644 --- a/packages/adapter-global-config/src/index.test.ts +++ b/packages/adapter-global-config/src/index.test.ts @@ -56,6 +56,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers, cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }); expect(result).toEqual({ 'flag-a': true, 'flag-b': false }); @@ -74,6 +75,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers, cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }); expect(result).toEqual({ 'flag-a': true }); @@ -91,6 +93,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers, cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }); await adapter().bulkDecide!({ @@ -98,6 +101,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers, cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }); expect(fakeGlobalConfigClient.get).toHaveBeenCalledOnce(); @@ -122,6 +126,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers: new Headers(), cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }), ).resolves.toEqual(true); expect(fakeGlobalConfigClient.get).toHaveBeenCalledWith('flags'); @@ -143,6 +148,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers, cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }), ).resolves.toEqual(true); @@ -154,6 +160,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers, cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }), ).resolves.toEqual(true); expect(fakeGlobalConfigClient.get).toHaveBeenCalledWith('flags'); @@ -172,6 +179,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers: new Headers(), cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }), ).resolves.toEqual(true); @@ -183,6 +191,7 @@ describe('createGlobalConfigAdapter', () => { entities: {}, headers: new Headers(), cookies: {} as ReadonlyRequestCookies, + now: 1700000000000, }), ).resolves.toEqual(true); diff --git a/packages/adapter-launchdarkly/src/index.test.ts b/packages/adapter-launchdarkly/src/index.test.ts index 34244242..3647ac94 100644 --- a/packages/adapter-launchdarkly/src/index.test.ts +++ b/packages/adapter-launchdarkly/src/index.test.ts @@ -62,6 +62,7 @@ describe('ldAdapter', () => { cookies: {} as ReadonlyRequestCookies, entities: {} as LDContext, defaultValue: false, + now: 1700000000000, }); await expect(valuePromise).resolves.toEqual(true); diff --git a/packages/adapter-openfeature/src/index.test.ts b/packages/adapter-openfeature/src/index.test.ts index 0f4015c6..9c9eed39 100644 --- a/packages/adapter-openfeature/src/index.test.ts +++ b/packages/adapter-openfeature/src/index.test.ts @@ -37,6 +37,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getBooleanValue).toHaveBeenCalledWith( @@ -65,6 +66,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getStringValue).toHaveBeenCalledWith( @@ -93,6 +95,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getNumberValue).toHaveBeenCalledWith( @@ -121,6 +124,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getObjectValue).toHaveBeenCalledWith( @@ -158,6 +162,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getBooleanValue).toHaveBeenCalledWith( @@ -186,6 +191,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getStringValue).toHaveBeenCalledWith( @@ -214,6 +220,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getNumberValue).toHaveBeenCalledWith( @@ -242,6 +249,7 @@ describe('OpenFeature Adapter', () => { entities, headers: mockHeaders as ReadonlyHeaders, cookies: mockCookies as ReadonlyRequestCookies, + now: 1700000000000, }); expect(mockClient.getObjectValue).toHaveBeenCalledWith( diff --git a/packages/adapter-vercel/src/index.test.ts b/packages/adapter-vercel/src/index.test.ts index 9920b7fc..065bba8b 100644 --- a/packages/adapter-vercel/src/index.test.ts +++ b/packages/adapter-vercel/src/index.test.ts @@ -151,6 +151,7 @@ describe('createVercelAdapter', () => { entities: { user: { id: 'u1' } } as any, headers: undefined as any, cookies: undefined as any, + now: 1700000000000, }); expect(bulkEvaluateMock).toHaveBeenCalledTimes(1); @@ -160,6 +161,7 @@ describe('createVercelAdapter', () => { { key: 'b', defaultValue: undefined }, ], { user: { id: 'u1' } }, + { now: 1700000000000 }, ); expect(result).toEqual({ a: 'x', b: 'y' }); }); @@ -178,6 +180,7 @@ describe('createVercelAdapter', () => { flags: [{ key: 'a' }, { key: 'b' }], headers: undefined as any, cookies: undefined as any, + now: 1700000000000, }); expect(result).toEqual({ a: 'ok' }); expect('b' in result).toBe(false); diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index 6c4ef850..6c1132e8 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -40,11 +40,12 @@ export function createVercelAdapter( adapterId, origin: flagsClient.origin, config: { reportValue: false }, - async decide({ key, entities }) { + async decide({ key, entities, now }) { const evaluationResult = await flagsClient.evaluate( key, undefined, entities, + { now }, ); if (evaluationResult.value === undefined) { @@ -61,7 +62,7 @@ export function createVercelAdapter( // when there was an error but the defaultValue was set return evaluationResult.value; }, - async bulkDecide({ flags, entities }) { + async bulkDecide({ flags, entities, now }) { // `flags` is typed `{ key: string; defaultValue?: unknown }[]` on // `Adapter.bulkDecide` (to keep `ValueType` covariant). The client // here narrows it back to `ValueType`; `defaultValue` is shuttled @@ -69,6 +70,7 @@ export function createVercelAdapter( const results = await flagsClient.bulkEvaluate( flags as { key: string; defaultValue?: unknown }[], entities, + { now }, ); const out: Record = {}; for (const key in results) { diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index 23ced1a4..8fdd989b 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -180,10 +180,33 @@ interface BulkStoreData { cookies: ReadonlyRequestCookies; dedupeCacheKey: Headers | IncomingHttpHeaders; overrides: Record | null; + /** + * The current time (epoch ms) for this `evaluate()` batch. Seeded from + * `nowByRequest` so standalone `flag()` calls in the same request agree. + */ + now: number; } const bulkStore = new AsyncLocalStorage(); +/** + * Locks evaluation time once per request, keyed by the same object + * `identifyArgsMap` uses (`headers()` store or `request.headers`). + * Get-or-set only — never overwrite an existing lock. + */ +const nowByRequest = new WeakMap(); + +function getRequestNow( + key: Headers | IncomingHttpHeaders, + preferred?: number, +): number { + const existing = nowByRequest.get(key); + if (existing !== undefined) return existing; + const now = preferred ?? Date.now(); + nowByRequest.set(key, now); + return now; +} + let headersModulePromise: Promise | undefined; let headersModule: typeof import('next/headers') | undefined; @@ -384,6 +407,11 @@ export function getRun( overrides = await readOverrides(readonlyCookies); } + // Lock after the request key is known. Prefer the evaluate() batch's + // now when present, but never overwrite a lock already set by an + // earlier flag() in this request. + const now = getRequestNow(dedupeCacheKey, bulkData?.now); + // the flag is being used in app router // skip microtask if identify does not exist const entities = options.identify @@ -409,6 +437,7 @@ export function getRun( headers: readonlyHeaders, cookies: readonlyCookies, entities, + now, }), }); }; @@ -520,11 +549,16 @@ async function evaluateImpl( // Read overrides once const overrides = await readOverrides(readonlyCookies); + // Lock "now" for this request (shared with standalone flag() calls that + // use the same dedupeCacheKey). Get-or-set so a prior flag() wins. + const now = getRequestNow(dedupeCacheKey); + const storeData: BulkStoreData = { headers: readonlyHeaders, cookies: readonlyCookies, dedupeCacheKey, overrides, + now, }; return bulkStore.run(storeData, async () => { @@ -629,6 +663,7 @@ async function evaluateImpl( entities, headers: readonlyHeaders, cookies: readonlyCookies, + now, }); } catch (err) { bulkError = err; diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 120ac5d7..d11b00bd 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -189,6 +189,88 @@ describe('flag on app router', () => { expect(mockDecide).toHaveBeenCalledTimes(1); }); + it('locks now across standalone flag() calls in the same request', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2000-01-01T00:00:00.000Z')); + const t0 = Date.parse('2000-01-01T00:00:00.000Z'); + + const seen: number[] = []; + const a = flag({ + key: 'now-a', + decide: ({ now }) => { + seen.push(now!); + return now!; + }, + }); + const b = flag({ + key: 'now-b', + decide: ({ now }) => { + seen.push(now!); + return now!; + }, + }); + + const headers = new Headers(); + mocks.headers.mockReturnValueOnce(headers); + mocks.headers.mockReturnValueOnce(headers); + + await expect(a()).resolves.toEqual(t0); + vi.setSystemTime(new Date('2000-01-01T00:00:01.000Z')); + await expect(b()).resolves.toEqual(t0); + expect(seen).toEqual([t0, t0]); + + // Different request identity gets a fresh lock at the advanced wall clock. + const t1 = Date.parse('2000-01-01T00:00:01.000Z'); + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(a()).resolves.toEqual(t1); + expect(seen).toEqual([t0, t0, t1]); + + vi.useRealTimers(); + }); + + it('shares locked now between flag() and evaluate() in either order', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2000-01-01T00:00:00.000Z')); + const t0 = Date.parse('2000-01-01T00:00:00.000Z'); + + const seen: number[] = []; + const make = (key: string) => + flag({ + key, + decide: ({ now }) => { + seen.push(now!); + return key; + }, + }); + + // flag() first, then evaluate() after wall clock advances + const a = make('now-share-a'); + const b = make('now-share-b'); + const headers1 = new Headers(); + mocks.headers.mockReturnValueOnce(headers1); + mocks.headers.mockReturnValueOnce(headers1); + await a(); + vi.setSystemTime(new Date('2000-01-01T00:00:01.000Z')); + await evaluate({ b }); + expect(seen).toEqual([t0, t0]); + + // evaluate() first, then flag() after wall clock advances further + seen.length = 0; + vi.setSystemTime(new Date('2000-01-01T00:00:02.000Z')); + const t2 = Date.parse('2000-01-01T00:00:02.000Z'); + const c = make('now-share-c'); + const d = make('now-share-d'); + const headers2 = new Headers(); + mocks.headers.mockReturnValueOnce(headers2); + mocks.headers.mockReturnValueOnce(headers2); + await evaluate({ c }); + vi.setSystemTime(new Date('2000-01-01T00:00:03.000Z')); + await d(); + expect(seen).toEqual([t2, t2]); + + vi.useRealTimers(); + }); + it('respects overrides', async () => { const decide = vi.fn(() => false); const f = flag({ key: 'first-flag', decide }); @@ -1244,6 +1326,43 @@ describe('evaluate', () => { socket.destroy(); }); + it('locks now across evaluate(req) and flag(req) in the same request', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2000-01-01T00:00:00.000Z')); + const t0 = Date.parse('2000-01-01T00:00:00.000Z'); + + const seen: number[] = []; + const a = flag({ + key: 'now-pages-a', + decide: ({ now }) => { + seen.push(now!); + return 'a'; + }, + }); + const b = flag({ + key: 'now-pages-b', + decide: ({ now }) => { + seen.push(now!); + return 'b'; + }, + }); + + const [request, socket] = createRequest(); + await expect(evaluate({ a }, request)).resolves.toEqual({ a: 'a' }); + vi.setSystemTime(new Date('2000-01-01T00:00:01.000Z')); + await expect(b(request)).resolves.toEqual('b'); + expect(seen).toEqual([t0, t0]); + + // A different request object gets a fresh lock. + const [request2, socket2] = createRequest(); + await expect(a(request2)).resolves.toEqual('a'); + expect(seen).toEqual([t0, t0, Date.parse('2000-01-01T00:00:01.000Z')]); + + socket.destroy(); + socket2.destroy(); + vi.useRealTimers(); + }); + it('accepts a web Request (NextRequest) passed directly to flag(req)', async () => { const decideMock = vi.fn(({ cookies }) => cookies.get('flag')?.value); const a = flag({ key: 'a', decide: decideMock }); diff --git a/packages/flags/src/sveltekit/index.ts b/packages/flags/src/sveltekit/index.ts index 58ba094d..6ad35cda 100644 --- a/packages/flags/src/sveltekit/index.ts +++ b/packages/flags/src/sveltekit/index.ts @@ -223,6 +223,7 @@ export function flag< headers, cookies, entities, + now: store.now, }); store.usedFlags[definition.key] = valuePromise as Promise; @@ -264,6 +265,11 @@ interface AsyncLocalContext { params: Record; usedFlags: Record>; identifiers: Map, ReturnType>>; + /** + * The current time (epoch ms), locked once per request so every flag + * evaluated for it agrees on "now". + */ + now: number; } function createContext( @@ -277,6 +283,7 @@ function createContext( params: params ?? {}, usedFlags: {}, identifiers: new Map(), + now: Date.now(), }; } diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index dc9563d0..1a6b5b79 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -138,6 +138,12 @@ export type GenerousOption = boolean extends T export type Decide = ( params: FlagParamsType & { entities?: EntitiesType; + /** + * The current time (epoch ms), locked once per request/batch so every + * flag decided together agrees on "now" — use this instead of calling + * `Date.now()` directly for time-based decisions. + */ + now?: number; }, ) => Promise | ValueType; @@ -174,6 +180,12 @@ export interface Adapter { // output positions only. Keeping it covariant lets `Adapter` and // `Flag` remain assignable to `Adapter` / `Flag`. defaultValue?: unknown; + /** + * The current time (epoch ms), locked once per request/batch so every + * flag decided together agrees on "now" — use this instead of calling + * `Date.now()` directly for time-based decisions. + */ + now?: number; }) => Promise | ValueType; /** * Optional batch hook used by `evaluate()` to resolve many flags that @@ -193,6 +205,12 @@ export interface Adapter { entities?: EntitiesType; headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; + /** + * The current time (epoch ms), locked once per request/batch so every + * flag decided together agrees on "now" — use this instead of calling + * `Date.now()` directly for time-based decisions. + */ + now?: number; }) => Promise> | Record; } diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index cb1ac406..e2401a42 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -9,6 +9,7 @@ import type { BundledDefinitions, ControllerInterface, Datafile, + EvaluationOptions, EvaluationResult, Metrics, Packed, @@ -68,6 +69,7 @@ export async function evaluate>( flagKey: string, defaultValue?: T, entities?: E, + options?: EvaluationOptions, ): Promise> { const controller = getInstance(id).controller as EvaluationTrackingController; @@ -137,6 +139,7 @@ export async function evaluate>( environment: datafile.environment, entities: entities ?? {}, segments: datafile.segments, + now: options?.now ?? evalStartTime, }); const evaluationDurationMs = Date.now() - evalStartTime; @@ -174,6 +177,7 @@ export async function bulkEvaluate>( id: number, flags: BulkEvaluateInput[], entities?: E, + options?: EvaluationOptions, ): Promise>> { const controller = getInstance(id).controller as EvaluationTrackingController; @@ -249,6 +253,7 @@ export async function bulkEvaluate>( entities: (entities ?? {}) as Record, environment: datafile.environment, segments: datafile.segments, + now: options?.now ?? evalStartTime, }); const evaluationDurationMs = Date.now() - evalStartTime; diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index bf4acb06..9846345c 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -14,6 +14,7 @@ import type { BulkEvaluateInput, BundledDefinitions, ControllerInterface, + EvaluationOptions, EvaluationResult, FlagsClient, Value, @@ -99,6 +100,7 @@ export function createCreateRawClient(fns: { flagKey: string, defaultValue?: T, entities?: E, + options?: EvaluationOptions, ): Promise> => { const instance = controllerInstanceMap.get(id); if (!instance?.initialized) { @@ -109,11 +111,12 @@ export function createCreateRawClient(fns: { // chain (last known value → datafile → bundled → defaultValue → throw) } } - return fns.evaluate(id, flagKey, defaultValue, entities); + return fns.evaluate(id, flagKey, defaultValue, entities, options); }, bulkEvaluate: async ( flags: BulkEvaluateInput[], entities?: E, + options?: EvaluationOptions, ): Promise>> => { const instance = controllerInstanceMap.get(id); if (!instance?.initialized) { @@ -124,7 +127,7 @@ export function createCreateRawClient(fns: { // chain (last known value → datafile → bundled → defaultValue → throw) } } - return fns.bulkEvaluate(id, flags, entities); + return fns.bulkEvaluate(id, flags, entities, options); }, }; return api; diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index 6ecb9312..2b04b995 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -19,6 +19,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: {}, }), ).toEqual({ @@ -37,6 +38,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: {}, }), ).toEqual({ @@ -57,6 +59,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'this-env-does-not-exist-and-will-cause-an-error', + now: Date.now(), entities: {}, }), ).toEqual({ @@ -87,6 +90,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, }), ).toEqual({ @@ -115,6 +119,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: {} }, }), ).toEqual({ @@ -141,6 +146,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: {}, }), ).toEqual({ @@ -169,6 +175,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, }), ).toEqual({ @@ -187,6 +194,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), }), ).toEqual({ value: false, @@ -204,6 +212,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'preview', + now: Date.now(), }), ).toEqual({ value: true, @@ -232,6 +241,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'preview', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -263,6 +273,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'preview', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: {}, }), @@ -288,6 +299,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, }), ).toEqual({ @@ -333,6 +345,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -358,6 +371,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, segments: { segment1: { rules: [], include: {}, exclude: {} }, @@ -376,6 +390,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -399,6 +414,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Jim' } }, segments: { segment1: { @@ -424,6 +440,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, segments: { segment1: { @@ -444,6 +461,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, segments: { segment1: { @@ -468,6 +486,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Jim' } }, segments: { segment1: { @@ -493,6 +512,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, segments: { segment1: { @@ -514,6 +534,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -541,6 +562,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -573,6 +595,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: `name${i}` } }, segments: { segment1: { @@ -608,6 +631,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: `name${i}` } }, segments: { segment1: { @@ -643,6 +667,7 @@ describe('evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: `name${i}` } }, segments: { segment1: { @@ -676,6 +701,7 @@ describe('evaluate', () => { condition: Packed.Condition; entities: Record | undefined; segments?: Record; + now?: number; result: boolean; }>([ // EQ (string) @@ -1482,6 +1508,111 @@ describe('evaluate', () => { result: false, }, + // BEFORE / AFTER with epoch ms (entity or now) + { + name: `${Comparator.BEFORE} epoch lhs vs ISO rhs match`, + condition: [ + ['user', 'createdAt'], + Comparator.BEFORE, + '2000-01-01T00:00:00.000Z', + ], + entities: { user: { createdAt: Date.parse('1970-01-01T00:00:00.000Z') } }, + result: true, + }, + { + name: `${Comparator.AFTER} epoch lhs vs epoch rhs match`, + condition: [ + ['user', 'createdAt'], + Comparator.AFTER, + Date.parse('1970-01-01T00:00:00.000Z'), + ], + entities: { user: { createdAt: Date.parse('2000-01-01T00:00:00.000Z') } }, + result: true, + }, + { + name: `${Comparator.BEFORE} invalid date string miss`, + condition: [['user', 'createdAt'], Comparator.BEFORE, 'not-a-date'], + entities: { user: { createdAt: '2000-01-01T00:00:00.000Z' } }, + result: false, + }, + + // now accessor + { + name: `now ${Comparator.BEFORE} ISO match`, + condition: ['now', Comparator.BEFORE, '2000-01-01T00:00:00.000Z'], + entities: {}, + now: Date.parse('1970-01-01T00:00:00.000Z'), + result: true, + }, + { + name: `now ${Comparator.BEFORE} ISO miss`, + condition: ['now', Comparator.BEFORE, '1970-01-01T00:00:00.000Z'], + entities: {}, + now: Date.parse('2000-01-01T00:00:00.000Z'), + result: false, + }, + { + name: `now ${Comparator.AFTER} ISO match`, + condition: ['now', Comparator.AFTER, '1970-01-01T00:00:00.000Z'], + entities: {}, + now: Date.parse('2000-01-01T00:00:00.000Z'), + result: true, + }, + { + name: `now ${Comparator.AFTER} ISO miss`, + condition: ['now', Comparator.AFTER, '2000-01-01T00:00:00.000Z'], + entities: {}, + now: Date.parse('1970-01-01T00:00:00.000Z'), + result: false, + }, + { + name: `now ${Comparator.BEFORE} epoch rhs match`, + condition: [ + 'now', + Comparator.BEFORE, + Date.parse('2000-01-01T00:00:00.000Z'), + ], + entities: {}, + now: Date.parse('1970-01-01T00:00:00.000Z'), + result: true, + }, + { + name: `now ${Comparator.AFTER} epoch rhs match`, + condition: [ + 'now', + Comparator.AFTER, + Date.parse('1970-01-01T00:00:00.000Z'), + ], + entities: {}, + now: Date.parse('2000-01-01T00:00:00.000Z'), + result: true, + }, + { + name: `now ${Comparator.LT} epoch match`, + condition: ['now', Comparator.LT, Date.parse('2000-01-01T00:00:00.000Z')], + entities: {}, + now: Date.parse('1970-01-01T00:00:00.000Z'), + result: true, + }, + { + name: `now ${Comparator.GTE} epoch match`, + condition: [ + 'now', + Comparator.GTE, + Date.parse('2000-01-01T00:00:00.000Z'), + ], + entities: {}, + now: Date.parse('2000-01-01T00:00:00.000Z'), + result: true, + }, + { + name: `now ${Comparator.LT} ISO rhs does not coerce`, + condition: ['now', Comparator.LT, '2000-01-01T00:00:00.000Z'], + entities: {}, + now: Date.parse('1970-01-01T00:00:00.000Z'), + result: false, + }, + // ---- Case-insensitive via string shorthand: 'i' ---- // EQ 'i' @@ -1956,6 +2087,7 @@ describe('evaluate', () => { condition, entities, segments, + now, result, }) => { expect( @@ -1971,6 +2103,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: now ?? Date.now(), entities, segments, }), @@ -2011,6 +2144,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { id: longString } }, }), ).toEqual({ @@ -2047,6 +2181,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { id: longString } }, }), ).toEqual({ @@ -2083,12 +2218,14 @@ describe('evaluate', () => { const first = evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, }); const second = evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid2' } }, }); @@ -2133,6 +2270,7 @@ describe('evaluate', () => { variants: [false, true], } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { id: okString } }, }), ).toEqual({ @@ -2240,6 +2378,7 @@ describe('evaluate', () => { variants, } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities, }), ).toEqual({ @@ -2270,6 +2409,7 @@ describe('evaluate', () => { seed, } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { id: `uid${i}` } }, }).value as 'a' | 'b' | 'c' | 'd'; totals[result]++; @@ -2318,6 +2458,7 @@ describe('evaluate', () => { seed: 7, } satisfies Packed.FlagDefinition, environment: 'production', + now: Date.now(), entities: { user: { id: `uid${i}` } }, }).value as string; counts[variants.indexOf(result)]!++; @@ -2376,6 +2517,30 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), + entities: { user: { id: 'uid1' } }, + }), + ).toEqual({ + value: false, + variantId: null, + reason: ResolutionReason.FALLTHROUGH, + outcomeType: OutcomeType.ROLLOUT, + }); + }); + + it('uses params.now instead of the wall clock', () => { + // Wall clock is after the rollout finishes (100%), but params.now is + // before start — evaluation must follow the locked now. + vi.setSystemTime(startTimestamp + 100 * HOUR); + expect( + evaluate({ + definition: { + environments: { production: { fallthrough: makeRollout() } }, + seed: 7, + variants: [false, true], + }, + environment: 'production', + now: startTimestamp - 1, entities: { user: { id: 'uid1' } }, }), ).toEqual({ @@ -2396,6 +2561,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, }), ).toEqual({ @@ -2416,6 +2582,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, }), ).toEqual({ @@ -2436,6 +2603,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: {}, }), ).toEqual({ @@ -2459,6 +2627,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: `uid${i}` } }, }); if (result.value === true) trueCount++; @@ -2489,6 +2658,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: uid } }, }); if (evalResult.value === true) result.add(uid); @@ -2533,6 +2703,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: `uid${i}` } }, }); if (result.value === true) trueCount++; @@ -2560,6 +2731,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: `uid${i}` } }, }); if (result.value === true) rollToCount++; @@ -2596,6 +2768,7 @@ describe('evaluate', () => { variants: [false, true], }, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1', plan: 'pro' } }, }), ).toEqual({ @@ -2635,6 +2808,7 @@ describe('evaluate', () => { variants: [...VARIANTS], }, environment: 'production', + now: Date.now(), entities: { user: { id: uid } }, }).value; }; @@ -2666,6 +2840,7 @@ describe('evaluate', () => { variants: [...VARIANTS], }, environment: 'production', + now: Date.now(), entities: { user: { id: uid } }, }).value; }; @@ -2734,6 +2909,7 @@ describe('bulkEvaluate', () => { }, { environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, }, ), @@ -2770,7 +2946,7 @@ describe('bulkEvaluate', () => { a: { definition, defaultValue: true }, b: { definition, defaultValue: false }, }, - { environment: 'this-env-does-not-exist', entities: {} }, + { environment: 'this-env-does-not-exist', entities: {}, now: Date.now() }, ); expect(results.a).toEqual({ @@ -2810,6 +2986,7 @@ describe('bulkEvaluate', () => { }, { environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -2834,7 +3011,66 @@ describe('bulkEvaluate', () => { expect(results.b).toEqual(expected); }); + it('shares a locked now across flags with date conditions', () => { + const boundary = Date.parse('2000-01-01T00:00:00.000Z'); + const beforeFlag: Packed.FlagDefinition = { + environments: { + production: { + rules: [ + { + conditions: [['now', Comparator.BEFORE, boundary]], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + }; + const afterFlag: Packed.FlagDefinition = { + environments: { + production: { + rules: [ + { + conditions: [['now', Comparator.AFTER, boundary]], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + }; + + const results = bulkEvaluate( + { + before: { definition: beforeFlag }, + after: { definition: afterFlag }, + }, + { + environment: 'production', + now: Date.parse('1999-12-31T00:00:00.000Z'), + entities: {}, + }, + ); + + expect(results.before).toEqual({ + value: true, + variantId: null, + reason: ResolutionReason.RULE_MATCH, + outcomeType: OutcomeType.VALUE, + }); + expect(results.after).toEqual({ + value: false, + variantId: null, + reason: ResolutionReason.FALLTHROUGH, + outcomeType: OutcomeType.VALUE, + }); + }); + it('returns an empty object when no flags are provided', () => { - expect(bulkEvaluate({}, { environment: 'production' })).toEqual({}); + expect( + bulkEvaluate({}, { environment: 'production', now: Date.now() }), + ).toEqual({}); }); }); diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 1e51f82c..51b64a7d 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -112,6 +112,19 @@ function isArray(input: unknown): input is unknown[] { return Array.isArray(input); } +/** + * Normalize a date-like value to epoch milliseconds. + * Accepts finite numbers (epoch ms) and parseable date strings. + */ +function toEpochMs(value: unknown): number | null { + if (isNumber(value) && Number.isFinite(value)) return value; + if (isString(value)) { + const time = new Date(value).getTime(); + return Number.isNaN(time) ? null : time; + } + return null; +} + function lower(input: T): T { if (typeof input === 'string') return input.toLowerCase() as T; if (Array.isArray(input)) return input.map(lower) as T; @@ -220,9 +233,12 @@ function matchConditions( return rawRhs && matchSegmentCondition(cmpKey, rawRhs, params); } - const lhs = ignoreCase - ? lower(access(lhsAccessor, params)) - : access(lhsAccessor, params); + // "now" is the locked current time, not an entity attribute + const rawLhs = + lhsAccessor === Packed.AccessorType.NOW + ? params.now + : access(lhsAccessor, params); + const lhs = ignoreCase ? lower(rawLhs) : rawLhs; const rhs = ignoreCase ? lower(rawRhs) : rawRhs; try { @@ -334,18 +350,17 @@ function matchConditions( } return false; case Comparator.BEFORE: { - if (!isString(lhs) || !isString(rhs)) return false; - const a = new Date(lhs); - const b = new Date(rhs); - // if any date fails to parse getTime will return NaN, which will cause - // comparisons to fail. - return a.getTime() < b.getTime(); + const a = toEpochMs(lhs); + const b = toEpochMs(rhs); + // Unparseable values (including NaN from bad date strings) do not match. + if (a === null || b === null) return false; + return a < b; } case Comparator.AFTER: { - if (!isString(lhs) || !isString(rhs)) return false; - const a = new Date(lhs); - const b = new Date(rhs); - return a.getTime() > b.getTime(); + const a = toEpochMs(lhs); + const b = toEpochMs(rhs); + if (a === null || b === null) return false; + return a > b; } default: { const _x: never = cmpKey; // exhaustive check @@ -477,8 +492,7 @@ function handleOutcome( } // Determine active slot based on elapsed time - const now = Date.now(); - const elapsed = now - outcome.startTimestamp; + const elapsed = params.now - outcome.startTimestamp; const rollFromVariant = getVariant( params.definition, @@ -663,12 +677,14 @@ export function bulkEvaluate( entities?: Record; environment: string; segments?: EvaluationParams['segments']; + now: number; }, ): Record> { const params: EvaluationParams = { entities: shared.entities, environment: shared.environment, segments: shared.segments, + now: shared.now, definition: undefined as unknown as Packed.FlagDefinition, defaultValue: undefined, }; diff --git a/packages/vercel-flags-core/src/integration.test.ts b/packages/vercel-flags-core/src/integration.test.ts index fe79a4df..7284f367 100644 --- a/packages/vercel-flags-core/src/integration.test.ts +++ b/packages/vercel-flags-core/src/integration.test.ts @@ -136,6 +136,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, segments: { segment1: { @@ -160,6 +161,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Jim' } }, segments: { segment1: { @@ -185,6 +187,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { id: 'uid1' } }, segments: { segment1: { @@ -206,6 +209,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -233,6 +237,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: 'Joe' } }, segments: { segment1: { @@ -265,6 +270,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: `name${i}` } }, segments: { segment1: { @@ -300,6 +306,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: `name${i}` } }, segments: { segment1: { @@ -335,6 +342,7 @@ describe('integration evaluate', () => { evaluate({ definition, environment: 'production', + now: Date.now(), entities: { user: { name: `name${i}` } }, segments: { segment1: { diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 6e7fbe4d..2b68394c 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -123,6 +123,16 @@ export type BulkEvaluateInput = { defaultValue?: T; }; +export type EvaluationOptions = { + /** + * The current time (epoch ms) to use for time-based conditions and + * rollouts. Defaults to `Date.now()`. Callers evaluating several flags + * together (e.g. a request-scoped batch) should pass the same `now` to + * every call so they all agree on "now". + */ + now?: number; +}; + /** * A client for Vercel Flags */ @@ -149,6 +159,7 @@ export type FlagsClient> = { flagKey: string, defaultValue?: T, entities?: E, + options?: EvaluationOptions, ) => Promise>; /** * Evaluate multiple feature flags against the same entities in a single call. @@ -165,6 +176,7 @@ export type FlagsClient> = { bulkEvaluate: ( flags: BulkEvaluateInput[], entities?: E, + options?: EvaluationOptions, ) => Promise>>; /** * Retrieve the latest datafile during startup, and set up subscriptions if needed. @@ -192,6 +204,12 @@ export type EvaluationParams = { segments?: Record; definition: Packed.FlagDefinition; defaultValue?: T; + /** + * The current time (epoch ms), locked once per evaluation/batch by the + * caller so all conditions and rollout calculations within it agree on + * "now", regardless of how long evaluation takes. + */ + now: number; }; // Copied from the OpenFeature ErrorCode and commented out unused types @@ -455,15 +473,15 @@ export enum Comparator { */ NOT_REGEX = '!regex', /** - * lhs must be date string - * rhs must be date string + * lhs/rhs must be a date string or epoch milliseconds (number). + * Used with entity date attributes or the `now` accessor. * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format */ BEFORE = 'before', /** - * lhs must be date string - * rhs must be date string + * lhs/rhs must be a date string or epoch milliseconds (number). + * Used with entity date attributes or the `now` accessor. * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format */ @@ -483,6 +501,7 @@ export namespace Original { export enum AccessorType { SEGMENT = 'segment', ENTITY = 'entity', + NOW = 'now', } export type SegmentOutcome = SegmentAllOutcome | SegmentSplitOutcome; @@ -569,6 +588,11 @@ export namespace Original { attribute: string; }; export type SegmentAccessor = { type: AccessorType.SEGMENT }; + /** + * References the current time (epoch ms), as locked for the duration of + * this evaluation. Packs to the `'now'` literal in `Packed.LHS`. + */ + export type NowAccessor = { type: AccessorType.NOW }; export type List = { // backwards compatibility, we should only use "list" going forward @@ -577,7 +601,7 @@ export namespace Original { id?: never; }; - export type LHS = SegmentAccessor | EntityAccessor; + export type LHS = SegmentAccessor | EntityAccessor | NowAccessor; export type RHS = | string | number @@ -707,6 +731,7 @@ export namespace Packed { export enum AccessorType { SEGMENT = 'segment', ENTITY = 'entity', + NOW = 'now', } export type SplitOutcome = { @@ -784,14 +809,18 @@ export namespace Packed { export type Outcome = VariantIndex | SplitOutcome | RolloutOutcome; - // an array means it's an entity, the string "segment" means a segment + // an array means it's an entity, the string "segment" means a segment, + // the string "now" means the current time (epoch ms) as locked for this + // evaluation. Prefer BEFORE/AFTER (ISO string or epoch ms RHS); GT/GTE/LT/LTE + // also work when the RHS is epoch milliseconds. export type EntityAccessor = (string | number)[]; export type SegmentAccessor = 'segment'; + export type NowAccessor = 'now'; /** * An array means an entity */ - export type LHS = EntityAccessor | SegmentAccessor; + export type LHS = EntityAccessor | SegmentAccessor | NowAccessor; /** * undefined when the rhs is not used by the comparator