Skip to content

Commit d940aa5

Browse files
feat: scoped caching support (#234)
* feat: scoped caching support * refactor: flag cache options docs * fixup! refactor: flag cache options docs --------- Co-authored-by: Nicklas Lundin <[email protected]>
1 parent 4eae451 commit d940aa5

File tree

18 files changed

+1608
-171
lines changed

18 files changed

+1608
-171
lines changed

.eslintrc

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"@spotify/eslint-config-typescript",
55
"@spotify/eslint-config-react",
66
"prettier",
7-
"plugin:es/restrict-to-es2015"
7+
"plugin:es/restrict-to-es2018"
88
],
99
"plugins": ["@spotify/eslint-plugin"],
1010
"settings": {
@@ -13,13 +13,12 @@
1313
}
1414
},
1515
"rules": {
16-
"es/no-async-functions": "off",
17-
"es/no-trailing-function-commas": "off",
1816
"es/no-rest-spread-properties": "off",
1917
"es/no-optional-chaining": "off",
2018
"es/no-nullish-coalescing-operators": "off",
2119
"no-inner-declarations": "off",
2220
"@typescript-eslint/no-use-before-define": "off",
23-
"no-param-reassign": "off"
21+
"no-param-reassign": "off",
22+
"@typescript-eslint/no-shadow": "off"
2423
}
2524
}

api/sdk.api.md

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@
44
55
```ts
66

7+
import { BinaryReader } from '@bufbuild/protobuf/wire';
8+
import { BinaryWriter } from '@bufbuild/protobuf/wire';
9+
10+
// @public
11+
export interface CacheOptions {
12+
// Warning: (ae-forgotten-export) The symbol "CacheEntry" needs to be exported by the entry point index.d.ts
13+
//
14+
// @internal (undocumented)
15+
entries?: AsyncIterable<CacheEntry>;
16+
scope?: CacheScope;
17+
}
18+
19+
// Warning: (ae-forgotten-export) The symbol "CacheProvider" needs to be exported by the entry point index.d.ts
20+
// Warning: (ae-missing-release-tag) "CacheScope" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
21+
//
22+
// @public
23+
export type CacheScope = (provider: CacheProvider) => CacheProvider;
24+
725
// @public
826
export namespace Closer {
927
export function combine(...closers: Closer[]): Closer;
@@ -15,14 +33,15 @@ export type Closer = () => void;
1533
// @public
1634
export class Confidence implements EventSender, Trackable, FlagResolver {
1735
// @internal
18-
constructor(config: Configuration, parent?: Confidence);
36+
constructor({ context, ...config }: Configuration, parent?: Confidence);
1937
clearContext(): void;
38+
// Warning: (ae-incompatible-release-tags) The symbol "config" is marked as @public, but its signature references "Configuration" which is marked as @internal
2039
readonly config: Configuration;
2140
// Warning: (ae-forgotten-export) The symbol "Subscribe" needs to be exported by the entry point index.d.ts
2241
//
2342
// @internal
2443
readonly contextChanges: Subscribe<string[]>;
25-
static create({ clientSecret, region, timeout, environment, fetchImplementation, logger, resolveBaseUrl, disableTelemetry, applyDebounce, waitUntil, }: ConfidenceOptions): Confidence;
44+
static create(options: ConfidenceOptions): Confidence;
2645
get environment(): string;
2746
evaluateFlag(path: string, defaultValue: string): FlagEvaluation<string>;
2847
// (undocumented)
@@ -44,6 +63,8 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
4463
protected resolveFlags(): AccessiblePromise<void>;
4564
setContext(context: Context): boolean;
4665
subscribe(onStateChange?: StateObserver): () => void;
66+
// (undocumented)
67+
toOptions(signal?: AbortSignal): ConfidenceOptions;
4768
track(name: string, data?: EventData): void;
4869
track(manager: Trackable.Manager): Closer;
4970
withContext(context: Context): Confidence;
@@ -52,7 +73,10 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
5273
// @public
5374
export interface ConfidenceOptions {
5475
applyDebounce?: number;
76+
cache?: CacheOptions;
5577
clientSecret: string;
78+
// (undocumented)
79+
context?: Context;
5680
disableTelemetry?: boolean;
5781
environment: 'client' | 'backend';
5882
fetchImplementation?: SimpleFetch;
@@ -64,21 +88,19 @@ export interface ConfidenceOptions {
6488
waitUntil?: WaitUntil;
6589
}
6690

67-
// @public
68-
export interface Configuration {
91+
// Warning: (ae-internal-missing-underscore) The name "Configuration" should be prefixed with an underscore because the declaration is marked as @internal
92+
//
93+
// @internal
94+
export interface Configuration extends ConfidenceOptions {
95+
// (undocumented)
96+
readonly cacheProvider: CacheProvider;
6997
// (undocumented)
7098
readonly clientSecret: string;
71-
readonly environment: 'client' | 'backend';
7299
// Warning: (ae-forgotten-export) The symbol "EventSenderEngine" needs to be exported by the entry point index.d.ts
73-
//
74-
// @internal
75100
readonly eventSenderEngine: EventSenderEngine;
76101
// Warning: (ae-forgotten-export) The symbol "FlagResolverClient" needs to be exported by the entry point index.d.ts
77-
//
78-
// @internal
79102
readonly flagResolverClient: FlagResolverClient;
80103
readonly logger: Logger;
81-
readonly timeout: number;
82104
}
83105

84106
// @public

packages/sdk/src/AccessiblePromise.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
22
return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function';
33
}
44

5-
export class AccessiblePromise<T> {
5+
export class AccessiblePromise<T> implements Promise<T> {
66
#state: 'PENDING' | 'RESOLVED' | 'REJECTED';
77
#value: any;
88

@@ -27,6 +27,7 @@ export class AccessiblePromise<T> {
2727
}
2828

2929
protected chain<S>(value: any, rejected?: boolean): AccessiblePromise<S> {
30+
if (value instanceof AccessiblePromise) return value;
3031
return new AccessiblePromise(value, rejected);
3132
}
3233

@@ -112,7 +113,12 @@ export class AccessiblePromise<T> {
112113
return this.orSupply(() => value);
113114
}
114115

115-
static resolve<T = void>(value?: T | PromiseLike<T>): AccessiblePromise<T> {
116+
get [Symbol.toStringTag]() {
117+
return 'AccessiblePromise';
118+
}
119+
120+
static resolve<T = void>(value?: T | PromiseLike<T>): AccessiblePromise<Awaited<T>> {
121+
if (value instanceof AccessiblePromise) return value;
116122
return new AccessiblePromise(value);
117123
}
118124

packages/sdk/src/Confidence.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ describe('Confidence', () => {
2929
logger: {},
3030
eventSenderEngine: eventSenderEngineMock,
3131
flagResolverClient: flagResolverClientMock,
32+
cacheProvider: () => {
33+
throw new Error('Not implemented');
34+
},
3235
});
3336
flagResolverClientMock.resolve.mockImplementation((context, _flags) => {
3437
const flagResolution = new Promise<FlagResolution>(resolve => {

packages/sdk/src/Confidence.ts

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
CachingFlagResolverClient,
3-
FetchingFlagResolverClient,
4-
FlagResolverClient,
5-
PendingResolution,
6-
} from './FlagResolverClient';
1+
import { FetchingFlagResolverClient, FlagResolverClient, PendingResolution } from './FlagResolverClient';
72
import { EventSenderEngine } from './EventSenderEngine';
83
import { Value } from './Value';
94
import { EventData, EventSender } from './events';
@@ -19,6 +14,7 @@ import { FlagResolution } from './FlagResolution';
1914
import { AccessiblePromise } from './AccessiblePromise';
2015
import { Telemetry } from './Telemetry';
2116
import { SimpleFetch } from './fetch-util';
17+
import { CacheOptions, CacheProvider, FlagCache } from './flag-cache';
2218

2319
/**
2420
* Confidence options, to be used for easier initialization of Confidence
@@ -48,19 +44,21 @@ export interface ConfidenceOptions {
4844
* This is particularly useful in serverless environments where you need to ensure certain operations complete before the environment is reclaimed.
4945
*/
5046
waitUntil?: WaitUntil;
47+
/**
48+
* Configuration options for the Confidence SDK's flag caching system.
49+
* @see {@link CacheOptions}
50+
*/
51+
cache?: CacheOptions;
52+
context?: Context;
5153
}
5254

5355
/**
5456
* Confidence configuration
55-
* @public
57+
* @internal
5658
*/
57-
export interface Configuration {
58-
/** Environment: can be either client of backend */
59-
readonly environment: 'client' | 'backend';
59+
export interface Configuration extends ConfidenceOptions {
6060
/** Debug logger */
6161
readonly logger: Logger;
62-
/** Resolve timeout */
63-
readonly timeout: number;
6462
/** Event Sender Engine
6563
* @internal */
6664
readonly eventSenderEngine: EventSenderEngine;
@@ -69,6 +67,7 @@ export interface Configuration {
6967
readonly flagResolverClient: FlagResolverClient;
7068
/* @internal */
7169
readonly clientSecret: string;
70+
readonly cacheProvider: CacheProvider;
7271
}
7372

7473
/**
@@ -79,7 +78,7 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
7978
/** Internal Confidence configurations */
8079
readonly config: Configuration;
8180
private readonly parent?: Confidence;
82-
private _context: Map<string, Value> = new Map();
81+
private _context: Map<string, Value>;
8382
private contextChanged?: Observer<string[]>;
8483

8584
/**
@@ -93,9 +92,10 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
9392
private readonly flagStateSubject: Subscribe<State>;
9493

9594
/** @internal */
96-
constructor(config: Configuration, parent?: Confidence) {
95+
constructor({ context = {}, ...config }: Configuration, parent?: Confidence) {
9796
this.config = config;
9897
this.parent = parent;
98+
this._context = new Map(Object.entries(context));
9999
this.contextChanges = subject(observer => {
100100
let parentSubscription: Closer | void;
101101
if (parent) {
@@ -336,6 +336,18 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
336336
);
337337
}
338338

339+
toOptions(signal?: AbortSignal): ConfidenceOptions {
340+
const cache = this.config.cacheProvider(this.config.clientSecret).toOptions(signal);
341+
return {
342+
clientSecret: this.config.clientSecret,
343+
region: this.config.region,
344+
timeout: this.config.timeout,
345+
environment: this.config.environment,
346+
cache,
347+
context: this.getContext(),
348+
};
349+
}
350+
339351
/**
340352
* Creates a Confidence instance
341353
* @param clientSecret - clientSecret found on the Confidence console
@@ -347,18 +359,20 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
347359
* @param resolveBaseUrl - custom backend resolve URL
348360
* @returns
349361
*/
350-
static create({
351-
clientSecret,
352-
region,
353-
timeout,
354-
environment,
355-
fetchImplementation = defaultFetchImplementation(),
356-
logger = defaultLogger(),
357-
resolveBaseUrl,
358-
disableTelemetry = false,
359-
applyDebounce = 10,
360-
waitUntil,
361-
}: ConfidenceOptions): Confidence {
362+
static create(options: ConfidenceOptions): Confidence {
363+
const {
364+
clientSecret,
365+
region,
366+
timeout,
367+
environment,
368+
fetchImplementation = defaultFetchImplementation(),
369+
logger = defaultLogger(),
370+
resolveBaseUrl,
371+
disableTelemetry = false,
372+
applyDebounce = 10,
373+
waitUntil,
374+
cache = {},
375+
} = options;
362376
if (environment !== 'client' && environment !== 'backend') {
363377
throw new Error(`Invalid environment: ${environment}. Must be 'client' or 'backend'.`);
364378
}
@@ -373,7 +387,8 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
373387
if (!clientSecret) {
374388
logger.error?.(`Confidence: confidence cannot be instantiated without a client secret`);
375389
}
376-
let flagResolverClient: FlagResolverClient = new FetchingFlagResolverClient({
390+
const cacheProvider = FlagCache.provider(clientSecret, cache);
391+
const flagResolverClient: FlagResolverClient = new FetchingFlagResolverClient({
377392
clientSecret,
378393
fetchImplementation,
379394
sdk,
@@ -385,10 +400,8 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
385400
logger,
386401
applyDebounce,
387402
waitUntil,
403+
cacheProvider,
388404
});
389-
if (environment === 'client') {
390-
flagResolverClient = new CachingFlagResolverClient(flagResolverClient, Number.POSITIVE_INFINITY);
391-
}
392405
const estEventSizeKb = 1;
393406
const flushTimeoutMilliseconds = 500;
394407
// default grpc payload limit is 4MB, so we aim for a 1MB batch-size
@@ -408,12 +421,11 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
408421
logger,
409422
});
410423
return new Confidence({
411-
environment: environment,
424+
...options,
412425
flagResolverClient,
413426
eventSenderEngine,
414-
timeout,
415427
logger,
416-
clientSecret,
428+
cacheProvider,
417429
});
418430
}
419431
}

packages/sdk/src/FlagResolution.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export class ReadyFlagResolution implements FlagResolution {
5151
resolveResponse: ResolveFlagsResponse,
5252
private readonly applier?: Applier,
5353
) {
54-
for (const { flag, variant, value, reason, flagSchema, shouldApply } of resolveResponse.resolvedFlags) {
54+
for (const resolvedFlag of resolveResponse.resolvedFlags) {
55+
const { flag, variant, value, reason, flagSchema } = resolvedFlag;
5556
const name = flag.slice(FLAG_PREFIX.length);
5657

5758
const schema = flagSchema ? Schema.parse({ structSchema: flagSchema }) : Schema.ANY;
@@ -60,7 +61,12 @@ export class ReadyFlagResolution implements FlagResolution {
6061
value: value! as Value.Struct,
6162
variant,
6263
reason: toEvaluationReason(reason),
63-
shouldApply,
64+
set shouldApply(value) {
65+
resolvedFlag.shouldApply = value;
66+
},
67+
get shouldApply() {
68+
return resolvedFlag.shouldApply;
69+
},
6470
});
6571
}
6672
this.resolveToken = base64FromBytes(resolveResponse.resolveToken);
@@ -83,6 +89,7 @@ export class ReadyFlagResolution implements FlagResolution {
8389

8490
if (flag.shouldApply && this.applier) {
8591
this.applier?.(name);
92+
flag.shouldApply = false;
8693
}
8794

8895
if (reason !== 'MATCH') {

0 commit comments

Comments
 (0)