Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/exp-3256-now-accessor.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/adapter-global-config/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers,
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(result).toEqual({ 'flag-a': true, 'flag-b': false });
Expand All @@ -74,6 +75,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers,
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(result).toEqual({ 'flag-a': true });
Expand All @@ -91,13 +93,15 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers,
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
});

await adapter().bulkDecide!({
flags: [{ key: 'flag-b' }],
entities: {},
headers,
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(fakeGlobalConfigClient.get).toHaveBeenCalledOnce();
Expand All @@ -122,6 +126,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers: new Headers(),
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
}),
).resolves.toEqual(true);
expect(fakeGlobalConfigClient.get).toHaveBeenCalledWith('flags');
Expand All @@ -143,6 +148,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers,
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
}),
).resolves.toEqual(true);

Expand All @@ -154,6 +160,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers,
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
}),
).resolves.toEqual(true);
expect(fakeGlobalConfigClient.get).toHaveBeenCalledWith('flags');
Expand All @@ -172,6 +179,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers: new Headers(),
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
}),
).resolves.toEqual(true);

Expand All @@ -183,6 +191,7 @@ describe('createGlobalConfigAdapter', () => {
entities: {},
headers: new Headers(),
cookies: {} as ReadonlyRequestCookies,
now: 1700000000000,
}),
).resolves.toEqual(true);

Expand Down
1 change: 1 addition & 0 deletions packages/adapter-launchdarkly/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('ldAdapter', () => {
cookies: {} as ReadonlyRequestCookies,
entities: {} as LDContext,
defaultValue: false,
now: 1700000000000,
});

await expect(valuePromise).resolves.toEqual(true);
Expand Down
8 changes: 8 additions & 0 deletions packages/adapter-openfeature/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getBooleanValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -65,6 +66,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getStringValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -93,6 +95,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getNumberValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -121,6 +124,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getObjectValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -158,6 +162,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getBooleanValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -186,6 +191,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getStringValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -214,6 +220,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getNumberValue).toHaveBeenCalledWith(
Expand Down Expand Up @@ -242,6 +249,7 @@ describe('OpenFeature Adapter', () => {
entities,
headers: mockHeaders as ReadonlyHeaders,
cookies: mockCookies as ReadonlyRequestCookies,
now: 1700000000000,
});

expect(mockClient.getObjectValue).toHaveBeenCalledWith(
Expand Down
3 changes: 3 additions & 0 deletions packages/adapter-vercel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -160,6 +161,7 @@ describe('createVercelAdapter', () => {
{ key: 'b', defaultValue: undefined },
],
{ user: { id: 'u1' } },
{ now: 1700000000000 },
);
expect(result).toEqual({ a: 'x', b: 'y' });
});
Expand All @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/adapter-vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown, unknown>(
key,
undefined,
entities,
{ now },
);

if (evaluationResult.value === undefined) {
Expand All @@ -61,14 +62,15 @@ 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
// through opaquely so the cast is safe.
const results = await flagsClient.bulkEvaluate<unknown, unknown>(
flags as { key: string; defaultValue?: unknown }[],
entities,
{ now },
);
const out: Record<string, unknown> = {};
for (const key in results) {
Expand Down
35 changes: 35 additions & 0 deletions packages/flags/src/next/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,33 @@ interface BulkStoreData {
cookies: ReadonlyRequestCookies;
dedupeCacheKey: Headers | IncomingHttpHeaders;
overrides: Record<string, any> | 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<BulkStoreData>();

/**
* 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<Headers | IncomingHttpHeaders, number>();

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<typeof import('next/headers')> | undefined;
let headersModule: typeof import('next/headers') | undefined;

Expand Down Expand Up @@ -384,6 +407,11 @@ export function getRun<ValueType, EntitiesType>(
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
Expand All @@ -409,6 +437,7 @@ export function getRun<ValueType, EntitiesType>(
headers: readonlyHeaders,
cookies: readonlyCookies,
entities,
now,
}),
});
};
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -629,6 +663,7 @@ async function evaluateImpl(
entities,
headers: readonlyHeaders,
cookies: readonlyCookies,
now,
});
} catch (err) {
bulkError = err;
Expand Down
Loading
Loading