diff --git a/README.md b/README.md index 7f9fd875..24e210e6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ ## Installation 1. Install dependencies: `yarn` -2. Copy `env.example` into `.env` at the following packages: +2. Build packages: `yarn build` +3. Copy `env.example` into `.env` at the following packages: 1. [react demo](packages/demo-react/README.md) 2. [nodejs demo](packages/demo-nodejs/README.md) 3. [sdk](packages/js-sdk/README.md) @@ -12,7 +13,7 @@ 1. Build packages: `yarn build` 2. Start local server: `yarn sdk:docs` -3. Prevview docs: http://localhost:3001 +3. Preview docs: http://localhost:3001 ## Demo Instructions @@ -20,42 +21,42 @@ ### React -1. Start demo server: `yarn demo:reactjs:start` -2. Build packages: `yarn build` -3. Create enviroment file: `cp demo/reactjs/.env.example demo/reactjs/.env` +1. Build packages: `yarn build` +2. Create environment file: `cp demo/reactjs/.env.example demo/reactjs/.env` +3. Start demo server: `yarn demo:reactjs:start` 4. Test demo app: http://localhost:3002 ### NodeJS -1. Start demo server: `yarn demo:nodejs:start` -2. Build packages: `yarn build` -3. Create enviroment file: `cp demo/nodejs/.env.example demo/nodejs/.env` +1. Build packages: `yarn build` +2. Create environment file: `cp demo/nodejs/.env.example demo/nodejs/.env` +3. Start demo server: `yarn demo:nodejs:start` 4. Test demo app: http://localhost:3003 ### NestJS -1. Start demo server: `yarn demo:nestjs:start` -2. Build packages: `yarn build` -3. Create enviroment file: `cp demo/nestjs/.env.example demo/nestjs/.env` +1. Build packages: `yarn build` +2. Create environment file: `cp demo/nestjs/.env.example demo/nestjs/.env` +3. Start demo server: `yarn demo:nestjs:start` 4. Test demo app: http://localhost:3004 ### NextJs -1. Start demo server: `yarn demo:nextjs:start` -2. Build packages: `yarn build` -3. Create enviroment file: `cp demo/nextjs/.env.example demo/nextjs/.env` +1. Build packages: `yarn build` +2. Create environment file: `cp demo/nextjs/.env.example demo/nextjs/.env` +3. Start demo server: `yarn demo:nextjs:start` 4. Test demo app: http://localhost:3005 ### RemixJs (client side) -1. Start demo server: `yarn demo:remixjs:client:start` -2. Build packages: `yarn build` -3. Create enviroment file: `cp demo/remixjs-client-side/.env.example demo/remixjs-client-side/.env` +1. Build packages: `yarn build` +2. Create environment file: `cp demo/remixjs-client-side/.env.example demo/remixjs-client-side/.env` +3. Start demo server: `yarn demo:remixjs:client:start` 4. Test demo app: http://localhost:3006 ### RemixJs (server side) -1. Start demo server: `yarn demo:remixjs:server:start` -2. Build packages: `yarn build` -3. Create enviroment file: `cp demo/remixjs-server-side/.env.example demo/remixjs-server-side/.env` +1. Build packages: `yarn build` +2. Create environment file: `cp demo/remixjs-server-side/.env.example demo/remixjs-server-side/.env` +3. Start demo server: `yarn demo:remixjs:server:start` 4. Test demo app: http://localhost:3007 \ No newline at end of file diff --git a/generate-rollup-config.mjs b/generate-rollup-config.mjs index 4c51fe0b..0e2b9e00 100644 --- a/generate-rollup-config.mjs +++ b/generate-rollup-config.mjs @@ -190,7 +190,9 @@ const commonJSBundle = ({ tsconfig: resolve(process.env.PROJECT_CWD, 'tsconfig.json'), tsconfigOverride: tsconfigOverride(basePath, packageName) }), - nodeResolve(), + nodeResolve({ + exportConditions: ['node', 'default'] + }), commonjs(), generatePackageJson({ baseContents: (pkg) => ({ @@ -265,7 +267,9 @@ const commonJSLegacyBundle = ({basePath, input, info, packageName}) => ({ tsconfig: resolve(process.env.PROJECT_CWD, 'tsconfig.json'), tsconfigOverride: tsconfigOverride(basePath, packageName) }), - nodeResolve(), + nodeResolve({ + exportConditions: ['node', 'default'] + }), commonjs(), babel({ babelHelpers: 'bundled', @@ -305,7 +309,9 @@ const esmBundle = ({basePath, input, info, packageName}) => ({ tsconfig: resolve(process.env.PROJECT_CWD, 'tsconfig.json'), tsconfigOverride: tsconfigOverride(basePath, packageName, true) }), - nodeResolve(), + nodeResolve({ + exportConditions: ['node', 'default'] + }), commonjs() ]) }); @@ -342,8 +348,10 @@ const umdBundle = ({basePath, input, info}) => ({ tsconfigOverride: tsconfigOverride(basePath) }), nodeResolve({ - mainFields: ['browser'], - preferBuiltins: false + mainFields: ['browser', 'module', 'main'], + preferBuiltins: false, + browser: true, + exportConditions: ['browser', 'import', 'require', 'default'] }), commonjs(), json() diff --git a/packages/api/package.json b/packages/api/package.json index 3cb2159e..9431ed01 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -71,10 +71,10 @@ }, "version": "2.1.4", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-event": ">=2.1.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0", - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-event": ">=2.1.3", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-types": ">=3.9.0", + "@convertcom/js-sdk-utils": ">=2.2.3" } -} +} \ No newline at end of file diff --git a/packages/bucketing/package.json b/packages/bucketing/package.json index 62ac6e93..36bcbb2f 100644 --- a/packages/bucketing/package.json +++ b/packages/bucketing/package.json @@ -71,9 +71,9 @@ }, "version": "3.1.2", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0", - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-types": ">=3.9.0", + "@convertcom/js-sdk-utils": ">=2.2.3" } -} +} \ No newline at end of file diff --git a/packages/data/index.ts b/packages/data/index.ts index 83587ee2..2a9dedfe 100644 --- a/packages/data/index.ts +++ b/packages/data/index.ts @@ -8,3 +8,4 @@ export {DataManager} from './src/data-manager'; export {DataManagerInterface} from './src/interfaces/data-manager'; export {DataStoreManager} from './src/data-store-manager'; export {DataStoreManagerInterface} from './src/interfaces/data-store-manager'; +export {CoreDecider} from './src/core-decider'; diff --git a/packages/data/package.json b/packages/data/package.json index 848ba211..140c44e1 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -71,13 +71,16 @@ }, "version": "3.3.3", "peerDependencies": { - "@convertcom/js-sdk-api": ">=2.1.1", - "@convertcom/js-sdk-bucketing": ">=3.1.0", - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-event": ">=2.1.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-rules": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0", - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-api": ">=2.1.4", + "@convertcom/js-sdk-bucketing": ">=3.1.2", + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-event": ">=2.1.3", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-rules": ">=2.1.3", + "@convertcom/js-sdk-types": ">=3.9.0", + "@convertcom/js-sdk-utils": ">=2.2.3" + }, + "dependencies": { + "@convertcom/decisions-core": "file:../../../decisions-core-sdk/pkg-node" } -} +} \ No newline at end of file diff --git a/packages/data/src/core-decider.ts b/packages/data/src/core-decider.ts new file mode 100644 index 00000000..4f1e5c2e --- /dev/null +++ b/packages/data/src/core-decider.ts @@ -0,0 +1,273 @@ +/*! + * Shared helpers for calling the Core decision engine (`@convertcom/decisions-core`). + */ +import type { + ConfigResponseData, + ExperienceVariationConfig +} from '@convertcom/js-sdk-types'; +import type {LogManagerInterface} from '@convertcom/js-sdk-logger'; +import * as decisionsCoreModule from '@convertcom/decisions-core'; + +export type RustSelectionType = 'forced' | 'cached' | 'newly_bucketed'; +export type RustDecisionOutcomeType = 'matched' | 'not_matched'; +export type RustLocationTransitionType = 'activated' | 'deactivated'; +export type RustTrackingType = 'none' | 'bucket'; + +export interface RustVariationSummary { + experience_id: string; + experience_key: string; + variation: ExperienceVariationConfig; + allocation?: [number, number]; +} + +export interface RustSelectionPayload { + type: RustSelectionType; + summary: RustVariationSummary; +} + +export interface RustMatchedOutcome { + type: 'matched'; + selection: RustSelectionPayload; +} + +export interface RustNotMatchedOutcome { + type: 'not_matched'; + reason: string; +} + +export type RustDecisionOutcome = RustMatchedOutcome | RustNotMatchedOutcome; + +export interface RustStateDiff { + bucketing: Record; + active_locations?: Array; + custom_segments?: Array; +} + +export interface RustLocationTransition { + type: RustLocationTransitionType; + location_id: string; + location_key?: string; + location_name?: string; +} + +export interface RustLogRecord { + level: string; + message: string; + data?: unknown; +} + +export interface RustTrackingInstructionBucket { + type: 'bucket'; + experience_id: string; + variation_id: string; +} + +export interface RustTrackingInstructionNone { + type: 'none'; +} + +export type RustTrackingInstruction = + | RustTrackingInstructionBucket + | RustTrackingInstructionNone; + +export interface RustDecisionResponsePayload { + api_version: string; + outcome: RustDecisionOutcome; + state_diff: RustStateDiff; + location_transitions: Array; + matched_audience_ids: Array; + matched_segment_ids: Array; + logs: Array; + tracking: RustTrackingInstruction; +} + +export interface RustFeatureAggregationPayload { + api_version: string; + features: Array; + logs: Array; +} + +export interface RustExperienceDecisionRequest { + visitorId: string; + experienceKey?: string; + experienceId?: string; + context: Record; + environment?: string; + options?: Record; + visitorState?: Record; +} + +export interface RustFeatureAggregationRequest { + variationSummaries: Array; + filters?: Record; + typeCasting?: boolean; +} + +export interface RustDecisionEngineNamespace { + WasmDecisionEngine: new () => { + decideExperience( + project: ConfigResponseData['project'], + request: RustExperienceDecisionRequest + ): RustDecisionResponsePayload; + aggregateFeatures( + project: ConfigResponseData['project'], + input: RustFeatureAggregationRequest + ): RustFeatureAggregationPayload; + free(): void; + }; + default?: () => Promise; +} + +export class CoreDeciderNotReadyError extends Error { + constructor(message = 'Core decision engine not ready') { + super(message); + this.name = 'CoreDeciderNotReadyError'; + } +} + +type DecisionsCoreModule = RustDecisionEngineNamespace & { + default?: (...args: Array) => unknown; +}; + +/** + * CoreDecider class manages the Core WASM decision engine lifecycle + */ +export class CoreDecider { + private _loggerManager: LogManagerInterface | null; + private _moduleNamespace: RustDecisionEngineNamespace | null = null; + private _initialized = false; + private _initializationPromise: Promise | null = null; + + constructor({loggerManager}: {loggerManager?: LogManagerInterface} = {}) { + this._loggerManager = loggerManager || null; + } + + private async loadModule(): Promise { + this._loggerManager?.trace?.('CoreDecider.loadModule()', 'Attempting to load WASM module'); + const candidate = decisionsCoreModule as unknown as DecisionsCoreModule; + + if (typeof candidate.default === 'function') { + this._loggerManager?.trace?.('CoreDecider.loadModule()', 'Found default export function, calling for async WASM init'); + try { + const maybeNamespace = await candidate.default(); + + if ( + maybeNamespace && + typeof maybeNamespace === 'object' && + 'WasmDecisionEngine' in (maybeNamespace as unknown as Record) + ) { + this._loggerManager?.trace?.('CoreDecider.loadModule()', 'Successfully loaded WASM namespace with WasmDecisionEngine'); + return maybeNamespace as RustDecisionEngineNamespace; + } + } catch (error) { + this._loggerManager?.trace?.('CoreDecider.loadModule()', 'Async WASM init failed, falling back to raw module', {error}); + // If initialization fails (e.g., missing fetch in legacy browsers), fall back to the raw module + } + } + + this._loggerManager?.trace?.('CoreDecider.loadModule()', 'Using raw candidate as namespace'); + return candidate as RustDecisionEngineNamespace; + } + + private setNamespace(namespace: RustDecisionEngineNamespace | null): void { + this._moduleNamespace = namespace; + this._initialized = Boolean(namespace && namespace.WasmDecisionEngine); + this._loggerManager?.trace?.('CoreDecider.setNamespace()', 'Namespace set', {initialized: this._initialized}); + } + + async initialize(): Promise { + this._loggerManager?.trace?.('CoreDecider.initialize()', 'Called', {initialized: this._initialized}); + if (this._initialized) { + this._loggerManager?.trace?.('CoreDecider.initialize()', 'Already initialized, skipping'); + return; + } + if (!this._initializationPromise) { + this._loggerManager?.trace?.('CoreDecider.initialize()', 'Starting new initialization'); + this._initializationPromise = (async () => { + try { + const namespace = await this.loadModule(); + this.setNamespace(namespace); + this._loggerManager?.trace?.('CoreDecider.initialize()', 'Initialization completed successfully'); + } catch (error) { + this._loggerManager?.error?.('CoreDecider.initialize()', 'Initialization failed', {error}); + this.setNamespace(null); + throw error; + } + })(); + } + + try { + await this._initializationPromise; + } finally { + this._initializationPromise = null; + } + } + + isReady(): boolean { + this._loggerManager?.trace?.('CoreDecider.isReady()', {initialized: this._initialized}); + return this._initialized; + } + + private getNamespace(): RustDecisionEngineNamespace { + this._loggerManager?.trace?.('CoreDecider.getNamespace()', 'Retrieving namespace', { + initialized: this._initialized, + hasNamespace: !!this._moduleNamespace + }); + if (!this._moduleNamespace || !this._initialized) { + this._loggerManager?.error?.('CoreDecider.getNamespace()', 'Namespace not ready'); + throw new CoreDeciderNotReadyError(); + } + return this._moduleNamespace; + } + + private disposeEngine(engine: {free?: () => void}): void { + try { + engine?.free?.(); + } catch (error) { + // ignore disposal issues + } + } + + decideExperience( + project: ConfigResponseData['project'], + request: RustExperienceDecisionRequest + ): RustDecisionResponsePayload { + this._loggerManager?.info?.('CoreDecider.decideExperience()', '🦀 USING CORE DECIDER FOR EXPERIENCE', { + experienceKey: request.experienceKey, + experienceId: request.experienceId, + visitorId: request.visitorId + }); + const namespace = this.getNamespace(); + const engine = new namespace.WasmDecisionEngine(); + try { + const result = engine.decideExperience(project, request) as RustDecisionResponsePayload; + this._loggerManager?.info?.('CoreDecider.decideExperience()', '🦀 CORE: Experience decision completed', { + outcomeType: result.outcome.type + }); + return result; + } finally { + this.disposeEngine(engine); + } + } + + aggregateFeatures( + project: ConfigResponseData['project'], + input: RustFeatureAggregationRequest + ): RustFeatureAggregationPayload { + this._loggerManager?.info?.('CoreDecider.aggregateFeatures()', '🦀 USING CORE FOR FEATURE AGGREGATION', { + variationCount: input.variationSummaries?.length, + typeCasting: input.typeCasting + }); + const namespace = this.getNamespace(); + const engine = new namespace.WasmDecisionEngine(); + try { + const result = engine.aggregateFeatures(project, input) as RustFeatureAggregationPayload; + this._loggerManager?.info?.('CoreDecider.aggregateFeatures()', '🦀 CORE: Feature aggregation completed', { + featureCount: result.features?.length + }); + return result; + } finally { + this.disposeEngine(engine); + } + } +} diff --git a/packages/data/src/data-manager.ts b/packages/data/src/data-manager.ts index 210f4c25..98ad3e96 100644 --- a/packages/data/src/data-manager.ts +++ b/packages/data/src/data-manager.ts @@ -46,6 +46,18 @@ import { GenericListMatchingOptions } from '@convertcom/js-sdk-types'; +import { + CoreDecider, + CoreDeciderNotReadyError, + RustDecisionResponsePayload, + RustFeatureAggregationPayload, + RustLogRecord, + RustSelectionPayload, + RustTrackingInstruction, + RustTrackingInstructionBucket, + RustVariationSummary +} from './core-decider'; + import { BucketingError, DATA_ENTITIES, @@ -83,6 +95,8 @@ export class DataManager implements DataManagerInterface { private _asyncStorage: boolean; private _environment: string; private _mapper: (...args: any) => any; + private _useCoreDecider: boolean; + private _coreDecider: CoreDecider | null = null; /** * @param {Config} config * @param {Object} dependencies @@ -119,6 +133,12 @@ export class DataManager implements DataManagerInterface { this._loggerManager = loggerManager; this._eventManager = eventManager; this._config = config; + this._useCoreDecider = Boolean(config?.experimental?.useCoreDecider); + if (this._useCoreDecider) { + // Initialize CoreDecider instance with logger + // Note: Initialization will be awaited by Core before firing READY event + this._coreDecider = new CoreDecider({loggerManager}); + } this._mapper = config?.mapper || ((value: any) => value); this._asyncStorage = asyncStorage; this._data = objectDeepValue(config, 'data'); @@ -174,6 +194,13 @@ export class DataManager implements DataManagerInterface { return this._dataStoreManager; } + /** + * coreDecider getter + */ + get coreDecider(): CoreDecider | null { + return this._coreDecider; + } + /** * Set dataStoreManager at run-time */ @@ -470,6 +497,109 @@ export class DataManager implements DataManagerInterface { return null; } + private _maybeDecideExperienceWithCore({ + visitorId, + identity, + identityField, + visitorProperties, + locationProperties, + environment, + ignoreLocationProperties, + forceVariationId, + enableTracking, + updateVisitorProperties + }: { + visitorId: string; + identity: string; + identityField: IdentityField; + visitorProperties?: Record; + locationProperties?: Record; + environment: string; + ignoreLocationProperties?: boolean; + forceVariationId?: string; + enableTracking: boolean; + updateVisitorProperties: boolean; + }): BucketedVariation | RuleError | BucketingError | null | undefined { + if (!this._useCoreDecider || !this._coreDecider?.isReady()) return undefined; + const project = this._data?.project; + if (!project) return undefined; + + const storeData = (this.getData(visitorId) || {}) as StoreData; + const options: Record = { + ignoreLocationProperties: Boolean(ignoreLocationProperties), + enableTracking, + updateVisitorProperties + }; + if (forceVariationId) options.forceVariationId = forceVariationId; + + const request = { + visitorId, + experienceKey: identityField === 'key' ? identity : undefined, + experienceId: identityField === 'id' ? identity : undefined, + context: { + visitorProperties: visitorProperties || {}, + locationProperties: ignoreLocationProperties + ? {} + : locationProperties || {} + }, + environment, + options, + visitorState: this._buildCoreVisitorState(storeData) + }; + + try { + const response = this._coreDecider.decideExperience( + project, + request + ) as RustDecisionResponsePayload; + + this._replayRustLogs(response.logs); + + const visitorPropsForState = updateVisitorProperties + ? visitorProperties || null + : null; + + if (response.outcome.type !== 'matched') { + this._applyRustStateDiff( + visitorId, + response.state_diff, + visitorPropsForState + ); + this._emitRustLocationTransitions(visitorId, response.location_transitions); + return null; + } + + const bucketedVariation = this._mapRustSelectionToVariation( + response.outcome.selection, + identity + ); + + this._applyRustStateDiff( + visitorId, + response.state_diff, + visitorPropsForState + ); + this._emitRustLocationTransitions(visitorId, response.location_transitions); + this._handleRustTracking( + visitorId, + response.tracking, + enableTracking, + visitorProperties, + storeData?.segments + ); + + return bucketedVariation; + } catch (error) { + if (error instanceof CoreDeciderNotReadyError) return undefined; + this._loggerManager?.warn?.( + 'DataManager._getBucketingByField()', + 'Falling back to JavaScript decision path', + error + ); + return undefined; + } + } + /** * Retrieve variation for visitor * @param {string} visitorId @@ -516,6 +646,23 @@ export class DataManager implements DataManagerInterface { }) ); + const coreResult = this._maybeDecideExperienceWithCore({ + visitorId, + identity, + identityField, + visitorProperties, + locationProperties, + environment, + ignoreLocationProperties, + forceVariationId, + enableTracking, + updateVisitorProperties: Boolean(updateVisitorProperties) + }); + + if (coreResult !== undefined) { + return coreResult; + } + // Retrieve the experience const experience = this.matchRulesByField( visitorId, @@ -719,6 +866,228 @@ export class DataManager implements DataManagerInterface { return bucketedVariation as BucketedVariation; } + private _buildCoreVisitorState( + storeData: StoreData | null | undefined + ): Record { + const state: Record = { + bucketing: storeData?.bucketing || {} + }; + + if (Array.isArray(storeData?.locations) && storeData.locations.length) { + state.active_locations = storeData.locations; + } + + const customSegments = this._extractCustomSegments(storeData?.segments); + if (customSegments.length) { + state.custom_segments = customSegments; + } + + return state; + } + + private _extractCustomSegments(segments?: VisitorSegments): Array { + if (!segments) return []; + const customSegments = (segments as Record)[ + SegmentsKeys.CUSTOM_SEGMENTS + ]; + if (!Array.isArray(customSegments)) return []; + return customSegments + .map((segment) => + segment && typeof segment === 'object' && 'toString' in segment + ? segment.toString() + : String(segment) + ) + .filter((value) => typeof value === 'string' && value.length); + } + + private _replayRustLogs(logs: Array): void { + if (!Array.isArray(logs) || !logs.length) return; + logs.forEach(({level, message, data}) => { + const normalizedLevel = level?.toLowerCase?.(); + const payload = [ + 'RustDecisionEngine', + message, + data && this._mapper(data) + ] as [string, any?, any?]; + switch (normalizedLevel) { + case 'trace': + this._loggerManager?.trace?.(...payload); + break; + case 'debug': + this._loggerManager?.debug?.(...payload); + break; + case 'warn': + this._loggerManager?.warn?.(...payload); + break; + case 'error': + this._loggerManager?.error?.(...payload); + break; + default: + this._loggerManager?.info?.(...payload); + break; + } + }); + } + + private _applyRustStateDiff( + visitorId: string, + stateDiff: RustDecisionResponsePayload['state_diff'], + visitorProperties: Record | null + ): void { + const updates: StoreData = {}; + + if (stateDiff?.bucketing && Object.keys(stateDiff.bucketing).length) { + updates.bucketing = {...stateDiff.bucketing}; + } + + if (Array.isArray(stateDiff?.active_locations)) { + updates.locations = [...stateDiff.active_locations]; + } + + const segmentUpdates: VisitorSegments = {}; + if (visitorProperties && objectNotEmpty(visitorProperties)) { + Object.assign(segmentUpdates, visitorProperties); + } + if (Array.isArray(stateDiff?.custom_segments)) { + segmentUpdates[SegmentsKeys.CUSTOM_SEGMENTS] = stateDiff.custom_segments; + } + + if (objectNotEmpty(segmentUpdates)) { + updates.segments = segmentUpdates; + } + + if (objectNotEmpty(updates)) { + this.putData(visitorId, updates); + } + } + + private _emitRustLocationTransitions( + visitorId: string, + transitions: Array + ): void { + if (!Array.isArray(transitions) || !transitions.length) return; + transitions.forEach((transition) => { + const identity = transition.location_key || transition.location_id; + const payload = { + visitorId, + location: { + id: transition.location_id, + key: transition.location_key, + name: transition.location_name + } + }; + + if (transition.type === 'activated') { + this._eventManager.fire( + SystemEvents.LOCATION_ACTIVATED, + payload, + null, + true + ); + if (identity) { + this._loggerManager?.info?.( + 'DataManager._getBucketingByField()', + MESSAGES.LOCATION_ACTIVATED.replace('#', `#${identity}`) + ); + } + } else if (transition.type === 'deactivated') { + this._eventManager.fire( + SystemEvents.LOCATION_DEACTIVATED, + payload, + null, + true + ); + if (identity) { + this._loggerManager?.info?.( + 'DataManager._getBucketingByField()', + MESSAGES.LOCATION_DEACTIVATED.replace('#', `#${identity}`) + ); + } + } + }); + } + + private _handleRustTracking( + visitorId: string, + tracking: RustTrackingInstruction, + enableTracking: boolean, + visitorProperties?: Record, + segments?: VisitorSegments + ): void { + if (!enableTracking || !tracking || tracking.type !== 'bucket') return; + const {experience_id, variation_id} = tracking as RustTrackingInstructionBucket; + const bucketingEvent: BucketingEvent = { + experienceId: experience_id, + variationId: variation_id + }; + const visitorEvent: VisitorTrackingEvents = { + eventType: eventType.BUCKETING, + data: bucketingEvent + }; + const visitorSegments = this._ruleManager.isUsingCustomInterface( + visitorProperties + ) + ? visitorProperties?.get?.() || {} + : segments; + this._apiManager.enqueue(visitorId, visitorEvent, visitorSegments); + this._loggerManager?.trace?.( + 'DataManager._getBucketingByField()', + this._mapper({ + visitorEvent + }) + ); + } + + private _mapRustSelectionToVariation( + selection: RustSelectionPayload, + identity: string + ): BucketedVariation { + const {summary} = selection; + const experience = this.getEntityById( + summary.experience_id, + 'experiences' + ) as ConfigExperience; + const variation = summary.variation; + const bucketedVariation: BucketedVariation = { + ...variation, + experienceId: summary.experience_id, + experienceKey: summary.experience_key, + experienceName: experience?.name, + bucketingAllocation: summary.allocation?.[1] + }; + + const logTarget = 'DataManager._getBucketingByField()'; + switch (selection.type) { + case 'forced': + this._loggerManager?.info?.( + logTarget, + MESSAGES.BUCKETED_VISITOR_FORCED.replace('#', `#${variation.id}`) + ); + break; + case 'cached': + this._loggerManager?.info?.( + logTarget, + MESSAGES.BUCKETED_VISITOR_FOUND.replace('#', `#${variation.id}`) + ); + break; + default: + this._loggerManager?.info?.( + logTarget, + MESSAGES.BUCKETED_VISITOR.replace('#', `#${variation.id}`) + ); + this._loggerManager?.debug?.( + logTarget, + this._mapper({ + identity, + variationId: variation.id + }) + ); + break; + } + + return bucketedVariation; + } + /** * @param {string} experienceId * @param {string} variationId @@ -739,6 +1108,41 @@ export class DataManager implements DataManagerInterface { ) as ExperienceVariationConfig; } + aggregateFeaturesWithCore( + variationSummaries: Array, + { + filters, + typeCasting = true + }: {filters?: Record; typeCasting?: boolean} = {} + ): RustFeatureAggregationPayload | null { + if (!this._useCoreDecider || !this._coreDecider?.isReady()) return null; + const project = this._data?.project; + if (!project || !Array.isArray(variationSummaries) || !variationSummaries.length) + return null; + try { + const response = this._coreDecider.aggregateFeatures(project, { + variationSummaries, + filters, + typeCasting + }); + this._replayRustLogs(response.logs); + return response; + } catch (error) { + if (!(error instanceof CoreDeciderNotReadyError)) { + this._loggerManager?.warn?.( + 'DataManager.aggregateFeaturesWithCore()', + 'Falling back to JavaScript feature aggregation', + error + ); + } + return null; + } + } + + isCoreDeciderEnabled(): boolean { + return this._useCoreDecider; + } + reset() { this._bucketedVisitors = new Map(); } diff --git a/packages/data/src/interfaces/data-manager.ts b/packages/data/src/interfaces/data-manager.ts index a7c2ac24..a2b001d2 100644 --- a/packages/data/src/interfaces/data-manager.ts +++ b/packages/data/src/interfaces/data-manager.ts @@ -14,8 +14,13 @@ import { ConfigExperience, BucketingAttributes, LocationAttributes, - GoalData + GoalData, + CoreDecider } from '@convertcom/js-sdk-types'; +import type { + RustFeatureAggregationPayload, + RustVariationSummary +} from '../core-decider'; import {DataStoreManagerInterface} from './data-store-manager'; import { BucketingError, @@ -26,6 +31,7 @@ import { export interface DataManagerInterface { data: ConfigResponseData; dataStoreManager: DataStoreManagerInterface; + coreDecider: CoreDecider | null; reset(); putData(storeKey: string, storeData: StoreData); @@ -86,4 +92,9 @@ export interface DataManagerInterface { isValidConfigData(data: ConfigResponseData): boolean; setDataStore(dataStore: any): void; + isCoreDeciderEnabled(): boolean; + aggregateFeaturesWithCore( + variationSummaries: Array, + options?: {filters?: Record; typeCasting?: boolean} + ): RustFeatureAggregationPayload | null; } diff --git a/packages/event/package.json b/packages/event/package.json index 13c3c113..d24b4b32 100644 --- a/packages/event/package.json +++ b/packages/event/package.json @@ -71,8 +71,8 @@ }, "version": "2.1.3", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0" + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-types": ">=3.9.0" } -} +} \ No newline at end of file diff --git a/packages/experience/package.json b/packages/experience/package.json index be98131c..7ca0234c 100644 --- a/packages/experience/package.json +++ b/packages/experience/package.json @@ -71,9 +71,9 @@ }, "version": "2.3.2", "peerDependencies": { - "@convertcom/js-sdk-data": ">=3.2.1", - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0" + "@convertcom/js-sdk-data": ">=3.3.3", + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-types": ">=3.9.0" } -} +} \ No newline at end of file diff --git a/packages/js-sdk/index.browser.umd.wasm.tests.js b/packages/js-sdk/index.browser.umd.wasm.tests.js new file mode 100644 index 00000000..e340a431 --- /dev/null +++ b/packages/js-sdk/index.browser.umd.wasm.tests.js @@ -0,0 +1,12 @@ +// No SDK library imports here. A UMD script should be already loaded in browser by karma +import {assert} from 'chai'; +import runTests from './index.wasm.tests'; + +describe('Karma browser tests for UMD bundle', function () { + it('Should have an SDK instance in namespace', function () { + // eslint-disable-next-line no-undef + assert.isDefined(ConvertSDK); + }); + // eslint-disable-next-line mocha/no-setup-in-describe,no-undef + runTests(ConvertSDK); +}); diff --git a/packages/js-sdk/index.tests.js b/packages/js-sdk/index.tests.js index 053b6ffd..34e2089f 100644 --- a/packages/js-sdk/index.tests.js +++ b/packages/js-sdk/index.tests.js @@ -62,7 +62,8 @@ export default function runTests(bundle) { await convert.onReady(); assert.equal(true, true); }); - it('Shoud successfully create visitor context', function () { + it('Shoud successfully create visitor context', async function () { + await convert.onReady(); const visitorContext = convert.createContext(visitorId, defaultSegments); expect(visitorContext).to.be.an('object'); [ @@ -80,8 +81,9 @@ export default function runTests(bundle) { }); describe('Basic SDK methods', function () { // eslint-disable-next-line mocha/no-hooks-for-single-case - before(function () { + before(async function () { convert = new ConvertSDK(testConfig); + await convert.onReady(); }); // eslint-disable-next-line mocha/no-hooks-for-single-case beforeEach(function () { @@ -208,8 +210,9 @@ export default function runTests(bundle) { }); describe('Test invalid visitor', function () { // eslint-disable-next-line mocha/no-hooks-for-single-case - before(function () { + before(async function () { convert = new ConvertSDK(testConfig); + await convert.onReady(); }); // eslint-disable-next-line mocha/no-hooks-for-single-case beforeEach(function () { diff --git a/packages/js-sdk/index.wasm.tests.js b/packages/js-sdk/index.wasm.tests.js new file mode 100644 index 00000000..803683f0 --- /dev/null +++ b/packages/js-sdk/index.wasm.tests.js @@ -0,0 +1,253 @@ +/* eslint-disable mocha/consistent-spacing-between-blocks */ +import {expect} from 'chai'; +import {assert} from 'chai'; +import testConfig from './tests/test-config.json'; +import { + getFeaturesWithStatuses, + getMultipleFeatureWithStatus, + getSingleFeatureWithStatus, + getVariationsAcrossAllExperiences +} from './tests/setup/shared'; + +class DataStore { + data = {}; + get(key) { + if (!key) return this.data; + return this.data[key.toString()]; + } + set(key, value) { + if (!key) throw new Error('Invalid DataStore key!'); + this.data[key.toString()] = value; + } +} + +const dataStore = new DataStore(); +testConfig.experimental = { + useCoreDecider: true +}; +testConfig.dataStore = dataStore; +testConfig.events = { + batch_size: 1, + release_interval: 1000 +}; + +const accountId = testConfig.data.account_id; +const projectId = testConfig.data.project.id; +const visitorId = 'XXX'; +const storeKey = `${accountId}-${projectId}-${visitorId}`; + +const defaultSegments = {browser: 'chrome'}; + +// eslint-disable-next-line mocha/no-exports +export default function runTests(bundle) { + const ConvertSDK = bundle.default; + let convert, context; + describe('Basic SDK instance', function () { + // eslint-disable-next-line mocha/no-hooks-for-single-case + beforeEach(function () { + convert = new ConvertSDK(testConfig); + }); + it('Should have an SDK instance as an object', function () { + expect(bundle).to.be.an('object'); + }); + it('Should have a constructor', function () { + expect(ConvertSDK).to.be.a('function'); + }); + it('Should create a default SDK instance and fire ready event. Expect no errors', function (done) { + convert.on('ready', function (args, err) { + expect(err).to.be.null; + //expect(convert).to.have.property('version').which.is.a('string'); + expect(convert).to.be.an('object'); + done(); + }); + }); + it('Should create a default SDK instance and resolve a promise. Expect no errors', async function () { + await convert.onReady(); + assert.equal(true, true); + }); + it('Shoud successfully create visitor context', async function () { + await convert.onReady(); + const visitorContext = convert.createContext(visitorId, defaultSegments); + expect(visitorContext).to.be.an('object'); + [ + 'runExperience', + 'runExperiences', + 'runFeature', + 'runFeatures', + 'trackConversion', + 'setDefaultSegments', + 'runCustomSegments' + ].forEach((method) => { + expect(visitorContext).to.have.a.property(method); + }); + }); + }); + describe('Basic SDK methods', function () { + // eslint-disable-next-line mocha/no-hooks-for-single-case + before(async function () { + convert = new ConvertSDK(testConfig); + await convert.onReady(); + }); + // eslint-disable-next-line mocha/no-hooks-for-single-case + beforeEach(function () { + context = convert.createContext(visitorId, defaultSegments); + }); + it('Shoud successfully get variation from specific experience', function (done) { + const experienceKey = 'test-experience-ab-fullstack-2'; + const variation = context.runExperience(experienceKey, { + locationProperties: {url: 'https://convert.com/'}, + visitorProperties: { + varName3: 'something' + } + }); + expect(variation) + .to.be.an('object') + .that.have.keys( + 'experienceId', + 'experienceKey', + 'experienceName', + 'bucketingAllocation', + 'id', + 'key', + 'name', + 'status', + 'changes', + 'is_baseline', + 'traffic_allocation' + ); + expect(variation.experienceKey).to.equal(experienceKey); + done(); + }); + it('Shoud successfully get variations across all experiences', function (done) { + getVariationsAcrossAllExperiences( + { + accountId, + projectId, + context + }, + done + ); + }); + it('Shoud successfully get a single feature and its status', function (done) { + const featureId = '10025'; + getSingleFeatureWithStatus( + { + accountId, + projectId, + featureId, + context + }, + done + ); + }); + it('Shoud successfully get multiple features and its status', function (done) { + getMultipleFeatureWithStatus( + { + accountId, + projectId, + context + }, + done + ); + }); + it('Shoud successfully get features and their statuses', function (done) { + getFeaturesWithStatuses( + { + accountId, + projectId, + context + }, + done + ); + }); + it('Should trigger Conversion', function () { + const goalKey = 'increase-engagement'; + const response = context.trackConversion(goalKey, { + ruleData: { + action: 'buy' + }, + conversionData: [ + { + key: 'amount', + value: 10.3 + }, + { + key: 'productsCount', + value: 2 + } + ] + }); + expect(response).to.be.undefined; + }); + it('Should successfully set default segments', function (done) { + const segments = {country: 'US'}; + context.setDefaultSegments(segments); + setTimeout(function () { + const localSegments = dataStore.get(storeKey); + expect(localSegments) + .to.have.property('segments') + .that.deep.equal({ + ...segments, + ...defaultSegments + }); + done(); + }, testConfig.events.release_interval + 1); + }); + it('Should successfully set custom segments', function (done) { + const segmentKey = 'test-segments-1'; + const segmentId = '200299434'; + context.runCustomSegments(segmentKey, { + ruleData: { + enabled: true + } + }); + setTimeout(function () { + const {segments} = dataStore.get(storeKey) || {}; + expect(segments) + .to.be.an('object') + .that.has.property('customSegments') + .to.deep.equal([segmentId]); + done(); + }, testConfig.events.release_interval + 1); + }); + }); + describe('Test invalid visitor', function () { + // eslint-disable-next-line mocha/no-hooks-for-single-case + before(async function () { + convert = new ConvertSDK(testConfig); + await convert.onReady(); + }); + // eslint-disable-next-line mocha/no-hooks-for-single-case + beforeEach(function () { + context = convert.createContext(); + }); + it('Shoud fail to get variation from specific experience if no visitor is set', function () { + const experienceKey = 'test-experience-ab-fullstack-2'; + const variation = context.runExperience(experienceKey); + expect(variation).to.be.undefined; + }); + it('Shoud fail to get variations across all experiences if no visitor is set', function () { + const variations = context.runExperiences(); + expect(variations).to.be.undefined; + }); + it('Shoud fail to get feature and its status if no visitor is set', function () { + const featureKey = 'feature-1'; + const features = context.runFeature(featureKey); + expect(features).to.be.undefined; + }); + it('Shoud fail to get features and their statuses if no visitor is set', function () { + const features = context.runFeatures(); + expect(features).to.be.undefined; + }); + it('Should fail to trigger Conversion if no visitor is set', function () { + const goalKey = 'increase-engagement'; + const output = context.trackConversion(goalKey); + expect(output).to.be.undefined; + }); + it('Should fail to set custom segments if no visitor is set', function () { + const segmentKey = 'test-segments-1'; + const output = context.runCustomSegments(segmentKey); + expect(output).to.be.undefined; + }); + }); +} diff --git a/packages/js-sdk/karma.base.conf.js b/packages/js-sdk/karma.base.conf.js index cdde42e4..ec86a16c 100644 --- a/packages/js-sdk/karma.base.conf.js +++ b/packages/js-sdk/karma.base.conf.js @@ -64,6 +64,20 @@ module.exports = { devtool: 'inline-source-map', performance: { hints: false + }, + experiments: { + asyncWebAssembly: true + }, + module: { + rules: [ + { + test: /decisions_core_bg\.wasm$/, + type: 'asset/resource', + generator: { + filename: '[name][ext]' + } + } + ] } }, // define browsers diff --git a/packages/js-sdk/karma.umd.conf.js b/packages/js-sdk/karma.umd.conf.js index 10f596d8..6da60896 100644 --- a/packages/js-sdk/karma.umd.conf.js +++ b/packages/js-sdk/karma.umd.conf.js @@ -9,7 +9,12 @@ module.exports = function (config) { config.set({ ...baseConfig, // list of files / patterns to load in the browser - files: ['./lib/index.umd.min.js', './index.browser.umd.tests.js'], + files: [ + './lib/index.umd.min.js', + './index.browser.umd.tests.js', + // Serve WASM file but don't include it in the test runner + {pattern: './lib/decisions_core_bg.wasm', included: false, served: true, watched: false} + ], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { diff --git a/packages/js-sdk/karma.umd.wasm.conf.js b/packages/js-sdk/karma.umd.wasm.conf.js new file mode 100644 index 00000000..32c1d327 --- /dev/null +++ b/packages/js-sdk/karma.umd.wasm.conf.js @@ -0,0 +1,40 @@ +// Karma configuration +// eslint-disable-next-line @typescript-eslint/no-var-requires +const baseConfig = require('./karma.base.conf.js'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require('path'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const os = require('os'); +module.exports = function (config) { + config.set({ + ...baseConfig, + // list of files / patterns to load in the browser + files: [ + './lib/index.umd.min.js', + './index.browser.umd.wasm.tests.js', + // Serve WASM file but don't include it in the test runner + { + pattern: './lib/decisions_core_bg.wasm', + included: false, + served: true, + watched: false + } + ], + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + './index.browser.umd.wasm.tests.js': ['webpack'] + }, + webpack: { + output: { + filename: '[name].js', + path: + path.join(os.tmpdir(), '_karma_webpack_') + + Math.floor(Math.random() * 1000000) + } + }, + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO + }); +}; diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index bfaf1e33..ed9e621c 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -22,12 +22,13 @@ "test:browser": "yarn build && yarn test:cjsbrowser && yarn test:umdbrowser", "test:cjsbrowser": "karma start karma.cjs.conf.js --single-run", "test:umdbrowser": "karma start karma.umd.conf.js --single-run", + "test:umdbrowser:wasm": "karma start karma.umd.wasm.conf.js --single-run", "test:server": "nyc yarn test:mocha", "test:mocha": "mocha -r ts-node/register --recursive \"tests/**/*.tests.ts\" --exit", "clean": "rm -rf lib", "prebuild:version": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", "prebuild": "yarn clean", - "build": "rollup -c ../../rollup.config.mjs", + "build": "rollup -c ../../rollup.config.mjs && node ../scripts/copy-wasm.js", "lint": "eslint src", "lint:fix": "yarn lint -- --fix", "coverage": "nyc report -r cobertura --reporter=text-summary", @@ -106,18 +107,21 @@ "browserstack-local": "1.5.5" } }, + "dependencies": { + "@convertcom/decisions-core": "file:../../../decisions-core-sdk/pkg-node" + }, "version": "4.3.3", "peerDependencies": { - "@convertcom/js-sdk-api": ">=2.1.1", - "@convertcom/js-sdk-bucketing": ">=3.1.0", - "@convertcom/js-sdk-data": ">=3.2.1", - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-event": ">=2.1.0", - "@convertcom/js-sdk-experience": ">=2.2.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-rules": ">=2.1.0", - "@convertcom/js-sdk-segments": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0", - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-api": ">=2.1.4", + "@convertcom/js-sdk-bucketing": ">=3.1.2", + "@convertcom/js-sdk-data": ">=3.3.3", + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-event": ">=2.1.3", + "@convertcom/js-sdk-experience": ">=2.3.2", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-rules": ">=2.1.3", + "@convertcom/js-sdk-segments": ">=2.1.2", + "@convertcom/js-sdk-types": ">=3.9.0", + "@convertcom/js-sdk-utils": ">=2.2.3" } -} +} \ No newline at end of file diff --git a/packages/js-sdk/src/config/default.ts b/packages/js-sdk/src/config/default.ts index 344b5a0a..d81109be 100644 --- a/packages/js-sdk/src/config/default.ts +++ b/packages/js-sdk/src/config/default.ts @@ -33,6 +33,9 @@ export const defaultConfig = { keys_case_sensitive: true, comparisonProcessor: null // Allows 3rd party comparison processor to be passed }, + experimental: { + useCoreDecider: false + }, network: { tracking: true, cacheLevel: 'default' // can be set to 'low' for short-lived cache (for development purposes only) diff --git a/packages/js-sdk/src/core.ts b/packages/js-sdk/src/core.ts index 45a2cd7e..13d26b85 100644 --- a/packages/js-sdk/src/core.ts +++ b/packages/js-sdk/src/core.ts @@ -4,21 +4,21 @@ * Copyright(c) 2020 Convert Insights, Inc * License Apache-2.0 */ -import {ApiManagerInterface} from '@convertcom/js-sdk-api'; -import {ContextInterface} from './interfaces/context'; -import {CoreInterface} from './interfaces/core'; -import {DataManagerInterface} from '@convertcom/js-sdk-data'; -import {EventManagerInterface} from '@convertcom/js-sdk-event'; -import {ExperienceManagerInterface} from '@convertcom/js-sdk-experience'; -import {FeatureManagerInterface} from './interfaces/feature-manager'; -import {LogManagerInterface} from '@convertcom/js-sdk-logger'; -import {SegmentsManagerInterface} from '@convertcom/js-sdk-segments'; +import { ApiManagerInterface } from '@convertcom/js-sdk-api'; +import { ContextInterface } from './interfaces/context'; +import { CoreInterface } from './interfaces/core'; +import { DataManagerInterface } from '@convertcom/js-sdk-data'; +import { EventManagerInterface } from '@convertcom/js-sdk-event'; +import { ExperienceManagerInterface } from '@convertcom/js-sdk-experience'; +import { FeatureManagerInterface } from './interfaces/feature-manager'; +import { LogManagerInterface } from '@convertcom/js-sdk-logger'; +import { SegmentsManagerInterface } from '@convertcom/js-sdk-segments'; -import {Config, ConfigResponseData} from '@convertcom/js-sdk-types'; +import { Config, ConfigResponseData } from '@convertcom/js-sdk-types'; -import {ERROR_MESSAGES, MESSAGES, SystemEvents} from '@convertcom/js-sdk-enums'; -import {objectNotEmpty} from '@convertcom/js-sdk-utils'; -import {Context} from './context'; +import { ERROR_MESSAGES, MESSAGES, SystemEvents } from '@convertcom/js-sdk-enums'; +import { objectNotEmpty } from '@convertcom/js-sdk-utils'; +import { Context } from './context'; const DEFAULT_DATA_REFRESH_INTERVAL = 300000; // in milliseconds (5 minutes) @@ -111,12 +111,38 @@ export class Core implements CoreInterface { error: config.data['error'] }); } else { - this._eventManager.fire(SystemEvents.READY, null, null, true); - this._loggerManager?.trace?.( - 'Core.initialize()', - MESSAGES.CORE_INITIALIZED - ); - this._initialized = true; + // If using Core decider, wait for it to initialize before firing READY + if (config?.experimental?.useCoreDecider && this._dataManager.coreDecider) { + this._dataManager.coreDecider.initialize() + .then(() => { + this._eventManager.fire(SystemEvents.READY, null, null, true); + this._loggerManager?.trace?.( + 'Core.initialize()', + MESSAGES.CORE_INITIALIZED + ); + this._initialized = true; + }) + .catch((error: Error) => { + this._loggerManager?.warn?.( + 'Core.initialize()', + 'Core decider initialization failed, falling back to TypeScript', + error + ); + this._eventManager.fire(SystemEvents.READY, null, null, true); + this._loggerManager?.trace?.( + 'Core.initialize()', + MESSAGES.CORE_INITIALIZED + ); + this._initialized = true; + }); + } else { + this._eventManager.fire(SystemEvents.READY, null, null, true); + this._loggerManager?.trace?.( + 'Core.initialize()', + MESSAGES.CORE_INITIALIZED + ); + this._initialized = true; + } } } else { this._loggerManager?.error?.( @@ -200,25 +226,34 @@ export class Core implements CoreInterface { this._loggerManager?.trace?.('Core.fetchConfig()', { data }); - this._eventManager.fire( - objectNotEmpty(this._dataManager.data) - ? SystemEvents.CONFIG_UPDATED - : SystemEvents.READY, - null, - null, - true - ); - if (objectNotEmpty(this._dataManager.data)) { + + const isInitialLoad = !objectNotEmpty(this._dataManager.data); + const eventType = isInitialLoad ? SystemEvents.READY : SystemEvents.CONFIG_UPDATED; + + // If using Rust decider on initial load, wait for it to initialize before firing READY + if (isInitialLoad && this._config?.experimental?.useCoreDecider && this._dataManager.coreDecider) { + await this._dataManager.coreDecider.initialize().catch((error: Error) => { + this._loggerManager?.warn?.( + 'Core.fetchConfig()', + 'Core decider initialization failed, falling back to TypeScript', + error + ); + }); + } + + this._eventManager.fire(eventType, null, null, true); + + if (isInitialLoad) { this._loggerManager?.trace?.( 'Core.fetchConfig()', - MESSAGES.CONFIG_DATA_UPDATED + MESSAGES.CORE_INITIALIZED ); + this._initialized = true; } else { this._loggerManager?.trace?.( 'Core.fetchConfig()', - MESSAGES.CORE_INITIALIZED + MESSAGES.CONFIG_DATA_UPDATED ); - this._initialized = true; } this.data = data; this._dataManager.data = data; diff --git a/packages/js-sdk/src/feature-manager.ts b/packages/js-sdk/src/feature-manager.ts index 0590e526..f4e1d820 100644 --- a/packages/js-sdk/src/feature-manager.ts +++ b/packages/js-sdk/src/feature-manager.ts @@ -15,7 +15,8 @@ import { IdentityField, VariableType, ConfigExperience, - BucketingAttributes + BucketingAttributes, + ExperienceVariationConfig } from '@convertcom/js-sdk-types'; import { MESSAGES, @@ -31,6 +32,18 @@ import { } from '@convertcom/js-sdk-utils'; import {BucketedVariation} from '@convertcom/js-sdk-types'; +type CoreFeatureDecision = { + id: string; + key: string; + name?: string; + status: 'Enabled' | 'Disabled'; + experience_id?: string; + experience_key?: string; + variation_id?: string; + variation_key?: string; + variables?: Record | null; +}; + /** * Provides features specific logic * @category Modules @@ -362,6 +375,13 @@ export class FeatureManager implements FeatureManagerInterface { ); if (matchedErrors.length) return matchedErrors as Array; + const coreAggregated = this._maybeAggregateFeaturesWithCore( + bucketedVariations as Array, + filter, + typeCasting + ); + if (coreAggregated) return coreAggregated; + // Collect features from bucketed variations for (const k in bucketedVariations) { const bucketedVariation = bucketedVariations[k] as BucketedVariation; @@ -470,4 +490,98 @@ export class FeatureManager implements FeatureManagerInterface { castType(value: any, type: VariableType): any { return castType(value, type); } + + private _maybeAggregateFeaturesWithCore( + variations: Array, + filter: Record> | undefined, + typeCasting: boolean + ): Array | null { + if (!this._dataManager?.isCoreDeciderEnabled()) return null; + const variationSummaries = variations + .map((variation) => this._toCoreVariationSummary(variation)) + .filter(Boolean) as Array<{ + experience_id: string; + experience_key: string; + variation: ExperienceVariationConfig; + allocation?: [number, number]; + }>; + + if (!variationSummaries.length) return null; + + const filters = filter + ? { + feature_keys: filter.features, + experience_keys: filter.experiences + } + : undefined; + + const response = this._dataManager.aggregateFeaturesWithCore( + variationSummaries, + { + filters, + typeCasting + } + ); + + if (!response) return null; + + return response.features.map((decision: CoreFeatureDecision) => + this._mapCoreFeatureDecision(decision as CoreFeatureDecision) + ); + } + + private _toCoreVariationSummary( + variation: BucketedVariation + ): { + experience_id: string; + experience_key: string; + variation: ExperienceVariationConfig; + allocation?: [number, number]; + } | null { + const { + experienceId, + experienceKey, + experienceName: _experienceName, + bucketingAllocation, + ...variationPayload + } = variation; + + if (!experienceId || !experienceKey || !variationPayload?.id) return null; + + return { + experience_id: String(experienceId), + experience_key: String(experienceKey), + variation: variationPayload as ExperienceVariationConfig, + allocation: undefined + }; + } + + private _mapCoreFeatureDecision(decision: CoreFeatureDecision): BucketedFeature { + const status = + decision.status === 'Enabled' + ? FeatureStatus.ENABLED + : FeatureStatus.DISABLED; + + const feature: BucketedFeature = { + id: decision.id, + key: decision.key, + name: decision.name, + status, + experienceId: decision.experience_id || undefined, + experienceKey: decision.experience_key || undefined, + variables: decision.variables || undefined + } as BucketedFeature; + + if (decision.experience_id) { + const experience = this._dataManager.getEntityById( + decision.experience_id, + 'experiences' + ) as ConfigExperience; + if (experience?.name) { + feature.experienceName = experience.name; + } + } + + return feature; + } } diff --git a/packages/js-sdk/tests/feature-manager.tests.ts b/packages/js-sdk/tests/feature-manager.tests.ts index 7637f9c3..c8f7232e 100644 --- a/packages/js-sdk/tests/feature-manager.tests.ts +++ b/packages/js-sdk/tests/feature-manager.tests.ts @@ -8,12 +8,14 @@ import {BucketingManager as bm} from '@convertcom/js-sdk-bucketing'; import {RuleManager as rm} from '@convertcom/js-sdk-rules'; import {EventManager as em} from '@convertcom/js-sdk-event'; import {ApiManager as am} from '@convertcom/js-sdk-api'; -import {DataManager as dm} from '@convertcom/js-sdk-data'; +import {DataManager as dm, DataManagerInterface} from '@convertcom/js-sdk-data'; import {FeatureManager as fm} from '../src/feature-manager'; import testConfig from './test-config.json'; import {Config as ConfigType} from '@convertcom/js-sdk-types'; import {objectDeepMerge} from '@convertcom/js-sdk-utils'; import {defaultConfig} from '../src/config/default'; +import {FeatureStatus} from '@convertcom/js-sdk-enums'; +import {BucketedVariation, ConfigFeature} from '@convertcom/js-sdk-types'; const host = 'http://localhost'; const port = 8090; @@ -38,6 +40,124 @@ const ruleManager = new rm(configuration); const eventManager = new em(configuration); const apiManager = new am(configuration, {eventManager}); +class CoreDataManagerStub { + public aggregateCalled = false; + public aggregateArgs: any; + + private _experiences = [ + { + id: 'exp-1', + key: 'experience-1', + name: 'Experience 1' + } + ]; + + private _variation: BucketedVariation = { + id: 'var-1', + key: 'variation-1', + name: 'Variation 1', + status: 'running' as any, + changes: [], + traffic_allocation: 10000, + experienceId: 'exp-1', + experienceKey: 'experience-1', + experienceName: 'Experience 1' + }; + + data = configuration.data; + + aggregateFeaturesWithCore(variationSummaries, options): any { + this.aggregateCalled = true; + this.aggregateArgs = {variationSummaries, options}; + return { + api_version: '1.0.0', + features: [ + { + id: 'feature-1', + key: 'feature-1', + name: 'Feature 1', + status: 'Enabled', + experience_id: 'exp-1', + experience_key: 'experience-1', + variation_id: 'var-1', + variation_key: 'variation-1', + variables: {flag: true} + } + ], + logs: [] + }; + } + + getEntitiesList(entityType: string): Array> { + if (entityType === 'experiences') { + return this._experiences; + } + if (entityType === 'features') { + return configuration?.data?.features || []; + } + return []; + } + + getEntities(keys: Array, entityType: string): Array> { + if (entityType === 'experiences') { + return this._experiences.filter((experience) => + keys?.includes?.(experience.key) + ); + } + if (entityType === 'features') { + return (configuration?.data?.features || []).filter((feature: ConfigFeature) => + keys?.includes?.(feature.key) + ); + } + return []; + } + + getBucketing(): BucketedVariation { + return this._variation; + } + + getEntityById(id: string): Record { + return this._experiences.find((experience) => experience.id === id); + } + + getEntity(key: string, entityType: string): Record { + if (entityType === 'features') { + return (configuration?.data?.features || []).find( + (feature: ConfigFeature) => feature.key === key + ); + } + return null; + } + + getEntitiesListObject( + entityType: string, + field: string + ): Record { + if (entityType !== 'features') return {}; + const features = configuration?.data?.features || []; + return features.reduce((acc, feature) => { + const identity = feature?.[field]; + if (identity) acc[String(identity)] = feature; + return acc; + }, {} as Record); + } + + getItemsByKeys(keys: Array, entityType: string): Array> { + if (entityType === 'features') { + return (configuration?.data?.features || []).filter((feature: ConfigFeature) => + keys?.includes?.(feature.key) + ); + } + return []; + } + + isCoreDeciderEnabled(): boolean { + return true; + } + + reset(): void {} +} + describe('FeatureManager tests', function () { const visitorId = 'XXX'; let dataManager, featureManager, accountId, projectId, server; @@ -255,6 +375,31 @@ describe('FeatureManager tests', function () { res.end('{}'); }); }); + it('Should prefer Core aggregation when available', function () { + const stub = new CoreDataManagerStub(); + const rustFeatureManager = new fm(configuration, { + dataManager: stub as unknown as DataManagerInterface + }); + const features = rustFeatureManager.runFeatures('visitor-123', { + visitorProperties: {}, + locationProperties: {} + }); + + expect(stub.aggregateCalled).to.equal(true); + expect(stub.aggregateArgs?.options?.filters).to.equal(undefined); + expect(features).to.deep.equal([ + { + id: 'feature-1', + key: 'feature-1', + name: 'Feature 1', + status: FeatureStatus.ENABLED, + experienceId: 'exp-1', + experienceKey: 'experience-1', + experienceName: 'Experience 1', + variables: {flag: true} + } + ]); + }); it('Convert value type', function () { let value = featureManager.castType('123', 'integer'); expect(typeof value).to.equal('number'); diff --git a/packages/logger/package.json b/packages/logger/package.json index ea84f18f..56630de6 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -68,6 +68,6 @@ }, "version": "2.1.2", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" } -} +} \ No newline at end of file diff --git a/packages/rules/package.json b/packages/rules/package.json index fb0ff6d8..a418aece 100644 --- a/packages/rules/package.json +++ b/packages/rules/package.json @@ -71,9 +71,9 @@ }, "version": "2.1.3", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0", - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-types": ">=3.9.0", + "@convertcom/js-sdk-utils": ">=2.2.3" } -} +} \ No newline at end of file diff --git a/packages/scripts/copy-wasm.js b/packages/scripts/copy-wasm.js new file mode 100755 index 00000000..86863363 --- /dev/null +++ b/packages/scripts/copy-wasm.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const libDir = path.join(root, 'js-sdk', 'lib'); +const sdkRoot = path.resolve(root, '..', '..', 'decisions-core-sdk'); +const candidates = [ + path.join(sdkRoot, 'pkg-node', 'browser', 'decisions_core_bg.wasm'), + path.join(sdkRoot, 'pkg-node', 'decisions_core_bg.wasm'), + path.join(sdkRoot, 'pkg-web', 'decisions_core_bg.wasm') +]; + +const destinations = [ + path.join(libDir, 'decisions_core_bg.wasm'), + path.join(libDir, 'browser', 'decisions_core_bg.wasm') +]; + +const source = candidates.find((candidate) => fs.existsSync(candidate)); + +if (!source) { + console.warn('[copy-wasm] Skipping copy; source wasm not found. Looked in:'); + candidates.forEach((candidate) => console.warn(' -', candidate)); + process.exit(0); +} + +for (const dest of destinations) { + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.copyFileSync(source, dest); +} + +console.log('[copy-wasm] Copied decisions_core_bg.wasm to lib/'); diff --git a/packages/segments/package.json b/packages/segments/package.json index 67e1722b..f086ed33 100644 --- a/packages/segments/package.json +++ b/packages/segments/package.json @@ -71,10 +71,10 @@ }, "version": "2.1.2", "peerDependencies": { - "@convertcom/js-sdk-data": ">=3.2.1", - "@convertcom/js-sdk-enums": ">=2.2.0", - "@convertcom/js-sdk-logger": ">=2.1.0", - "@convertcom/js-sdk-rules": ">=2.1.0", - "@convertcom/js-sdk-types": ">=3.6.0" + "@convertcom/js-sdk-data": ">=3.3.3", + "@convertcom/js-sdk-enums": ">=2.3.0", + "@convertcom/js-sdk-logger": ">=2.1.2", + "@convertcom/js-sdk-rules": ">=2.1.3", + "@convertcom/js-sdk-types": ">=3.9.0" } -} +} \ No newline at end of file diff --git a/packages/types/index.ts b/packages/types/index.ts index 68ac1a02..44f674e3 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -13,6 +13,7 @@ export * from './src/BucketingAllocation'; export * from './src/BucketingHash'; export * from './src/Config'; export * from './src/ConversionAttributes'; +export * from './src/CoreDecider'; export * from './src/Entity'; export * from './src/GoalData'; export * from './src/IndentityField'; diff --git a/packages/types/package.json b/packages/types/package.json index 630bc1c2..7a8800d1 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -51,6 +51,6 @@ }, "version": "3.9.0", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" } -} +} \ No newline at end of file diff --git a/packages/types/src/Config.ts b/packages/types/src/Config.ts index 9ffe139e..c48e0cdc 100644 --- a/packages/types/src/Config.ts +++ b/packages/types/src/Config.ts @@ -48,6 +48,9 @@ type ConfigBase = { cacheLevel?: string; source?: string; }; + experimental?: { + useCoreDecider?: boolean; + }; mapper?: (...args: any) => any; }; diff --git a/packages/types/src/CoreDecider.ts b/packages/types/src/CoreDecider.ts new file mode 100644 index 00000000..93a65ab2 --- /dev/null +++ b/packages/types/src/CoreDecider.ts @@ -0,0 +1,15 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * CoreDecider interface for type checking across packages + * The actual implementation lives in @convertcom/js-sdk-data + */ +export interface CoreDecider { + initialize(): Promise; + isReady(): boolean; +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 0b4c4318..183e24ac 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -72,6 +72,6 @@ }, "version": "2.2.3", "peerDependencies": { - "@convertcom/js-sdk-enums": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" } -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d7031dc7..d35f2514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2066,6 +2066,20 @@ __metadata: languageName: node linkType: hard +"@convertcom/decisions-core@file:../../../decisions-core-sdk/pkg-node::locator=%40convertcom%2Fjs-sdk%40workspace%3Apackages%2Fjs-sdk": + version: 0.1.0 + resolution: "@convertcom/decisions-core@file:../../../decisions-core-sdk/pkg-node#../../../decisions-core-sdk/pkg-node::hash=0f6de7&locator=%40convertcom%2Fjs-sdk%40workspace%3Apackages%2Fjs-sdk" + checksum: 10c0/f7077de5f0d68357cd80e6e5c42acbc852288159a517a956584c264b9caedd347ebb5a12538cfb3b3839a29a292236065254fc3d8efcc57537b7a4678b479b57 + languageName: node + linkType: hard + +"@convertcom/decisions-core@file:../../../decisions-core-sdk/pkg-node::locator=%40convertcom%2Fjs-sdk-data%40workspace%3Apackages%2Fdata": + version: 0.1.0 + resolution: "@convertcom/decisions-core@file:../../../decisions-core-sdk/pkg-node#../../../decisions-core-sdk/pkg-node::hash=0f6de7&locator=%40convertcom%2Fjs-sdk-data%40workspace%3Apackages%2Fdata" + checksum: 10c0/f7077de5f0d68357cd80e6e5c42acbc852288159a517a956584c264b9caedd347ebb5a12538cfb3b3839a29a292236065254fc3d8efcc57537b7a4678b479b57 + languageName: node + linkType: hard + "@convertcom/js-sdk-api@workspace:packages/api": version: 0.0.0-use.local resolution: "@convertcom/js-sdk-api@workspace:packages/api" @@ -2107,11 +2121,11 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-event": ">=2.1.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-event": ">=2.1.3" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-types": ">=3.9.0" + "@convertcom/js-sdk-utils": ">=2.2.3" languageName: unknown linkType: soft @@ -2156,10 +2170,10 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-types": ">=3.9.0" + "@convertcom/js-sdk-utils": ">=2.2.3" languageName: unknown linkType: soft @@ -2170,6 +2184,7 @@ __metadata: "@babel/cli": "npm:^7.24.7" "@babel/core": "npm:^7.24.7" "@babel/preset-env": "npm:^7.24.7" + "@convertcom/decisions-core": "file:../../../decisions-core-sdk/pkg-node" "@eslint/eslintrc": "npm:^3.1.0" "@rollup/plugin-babel": "npm:^6.0.4" "@rollup/plugin-commonjs": "npm:^26.0.1" @@ -2204,14 +2219,14 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-api": ">=2.1.1" - "@convertcom/js-sdk-bucketing": ">=3.1.0" - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-event": ">=2.1.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-rules": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-api": ">=2.1.4" + "@convertcom/js-sdk-bucketing": ">=3.1.2" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-event": ">=2.1.3" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-rules": ">=2.1.3" + "@convertcom/js-sdk-types": ">=3.9.0" + "@convertcom/js-sdk-utils": ">=2.2.3" languageName: unknown linkType: soft @@ -2320,9 +2335,9 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-types": ">=3.9.0" languageName: unknown linkType: soft @@ -2367,10 +2382,10 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-data": ">=3.2.1" - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" + "@convertcom/js-sdk-data": ">=3.3.3" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-types": ">=3.9.0" languageName: unknown linkType: soft @@ -2412,7 +2427,7 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" languageName: unknown linkType: soft @@ -2457,10 +2472,10 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-types": ">=3.9.0" + "@convertcom/js-sdk-utils": ">=2.2.3" languageName: unknown linkType: soft @@ -2505,11 +2520,11 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-data": ">=3.2.1" - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-rules": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" + "@convertcom/js-sdk-data": ">=3.3.3" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-rules": ">=2.1.3" + "@convertcom/js-sdk-types": ">=3.9.0" languageName: unknown linkType: soft @@ -2540,7 +2555,7 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" languageName: unknown linkType: soft @@ -2584,7 +2599,7 @@ __metadata: typescript-eslint: "npm:^7.13.1" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-enums": ">=2.2.0" + "@convertcom/js-sdk-enums": ">=2.3.0" languageName: unknown linkType: soft @@ -2595,6 +2610,7 @@ __metadata: "@babel/cli": "npm:^7.24.7" "@babel/core": "npm:^7.24.7" "@babel/preset-env": "npm:^7.24.7" + "@convertcom/decisions-core": "file:../../../decisions-core-sdk/pkg-node" "@eslint/eslintrc": "npm:^3.1.0" "@jsdoc/salty": "npm:^0.2.8" "@rollup/plugin-babel": "npm:^6.0.4" @@ -2651,17 +2667,17 @@ __metadata: watchify: "npm:^4.0.0" webpack: "npm:^5.92.0" peerDependencies: - "@convertcom/js-sdk-api": ">=2.1.1" - "@convertcom/js-sdk-bucketing": ">=3.1.0" - "@convertcom/js-sdk-data": ">=3.2.1" - "@convertcom/js-sdk-enums": ">=2.2.0" - "@convertcom/js-sdk-event": ">=2.1.0" - "@convertcom/js-sdk-experience": ">=2.2.0" - "@convertcom/js-sdk-logger": ">=2.1.0" - "@convertcom/js-sdk-rules": ">=2.1.0" - "@convertcom/js-sdk-segments": ">=2.1.0" - "@convertcom/js-sdk-types": ">=3.6.0" - "@convertcom/js-sdk-utils": ">=2.2.0" + "@convertcom/js-sdk-api": ">=2.1.4" + "@convertcom/js-sdk-bucketing": ">=3.1.2" + "@convertcom/js-sdk-data": ">=3.3.3" + "@convertcom/js-sdk-enums": ">=2.3.0" + "@convertcom/js-sdk-event": ">=2.1.3" + "@convertcom/js-sdk-experience": ">=2.3.2" + "@convertcom/js-sdk-logger": ">=2.1.2" + "@convertcom/js-sdk-rules": ">=2.1.3" + "@convertcom/js-sdk-segments": ">=2.1.2" + "@convertcom/js-sdk-types": ">=3.9.0" + "@convertcom/js-sdk-utils": ">=2.2.3" languageName: unknown linkType: soft