diff --git a/package.json b/package.json index 61ec17e..2ac3937 100644 --- a/package.json +++ b/package.json @@ -47,5 +47,8 @@ "volta": { "node": "24.12.0", "pnpm": "10.26.2" + }, + "dependencies": { + "@lytics/sdk-kit-plugins": "0.1.2" } } \ No newline at end of file diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 5b17daa..789683b 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@lytics/sdk-kit": "^0.1.1", + "@lytics/sdk-kit-plugins": "^0.1.0", "@prosdevlab/experience-sdk": "workspace:*" }, "devDependencies": { diff --git a/packages/plugins/src/debug/debug.test.ts b/packages/plugins/src/debug/debug.test.ts index cb7adbf..df0ebd9 100644 --- a/packages/plugins/src/debug/debug.test.ts +++ b/packages/plugins/src/debug/debug.test.ts @@ -1,12 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SDK } from '@lytics/sdk-kit'; -import { debugPlugin } from './debug'; +import { debugPlugin, type DebugPlugin } from './debug'; describe('Debug Plugin', () => { - let sdk: SDK; + let sdk: SDK & { debug: DebugPlugin }; beforeEach(() => { - sdk = new SDK({ debug: { enabled: true, console: true, window: true } }); + sdk = new SDK({ debug: { enabled: true, console: true, window: true } }) as SDK & { + debug: DebugPlugin; + }; }); describe('Plugin Registration', () => { diff --git a/packages/plugins/src/frequency/frequency.test.ts b/packages/plugins/src/frequency/frequency.test.ts new file mode 100644 index 0000000..82523d7 --- /dev/null +++ b/packages/plugins/src/frequency/frequency.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SDK } from '@lytics/sdk-kit'; +import { storagePlugin, type StoragePlugin } from '@lytics/sdk-kit-plugins'; +import { frequencyPlugin, type FrequencyPlugin } from './frequency'; +import type { Decision } from '@prosdevlab/experience-sdk'; + +type SDKWithFrequency = SDK & { frequency: FrequencyPlugin; storage: StoragePlugin }; + +describe('Frequency Plugin', () => { + let sdk: SDKWithFrequency; + + beforeEach(() => { + // Use memory storage for tests + sdk = new SDK({ + frequency: { enabled: true }, + storage: { backend: 'memory' }, + }) as SDKWithFrequency; + + // Install plugins + sdk.use(storagePlugin); + sdk.use(frequencyPlugin); + }); + + describe('Plugin Registration', () => { + it('should register frequency plugin', () => { + expect(sdk.frequency).toBeDefined(); + }); + + it('should expose frequency API methods', () => { + expect(sdk.frequency.getImpressionCount).toBeTypeOf('function'); + expect(sdk.frequency.hasReachedCap).toBeTypeOf('function'); + expect(sdk.frequency.recordImpression).toBeTypeOf('function'); + }); + + it('should auto-load storage plugin if not present', () => { + const newSdk = new SDK({ frequency: { enabled: true } }) as SDKWithFrequency; + newSdk.use(frequencyPlugin); + expect(newSdk.storage).toBeDefined(); + }); + }); + + describe('Configuration', () => { + it('should use default config', () => { + const enabled = sdk.get('frequency.enabled'); + const namespace = sdk.get('frequency.namespace'); + + expect(enabled).toBe(true); + expect(namespace).toBe('experiences:frequency'); + }); + + it('should allow custom config', () => { + const customSdk = new SDK({ + frequency: { enabled: false, namespace: 'custom:freq' }, + storage: { backend: 'memory' }, + }) as SDKWithFrequency; + + customSdk.use(storagePlugin); + customSdk.use(frequencyPlugin); + + expect(customSdk.get('frequency.enabled')).toBe(false); + expect(customSdk.get('frequency.namespace')).toBe('custom:freq'); + }); + }); + + describe('Impression Tracking', () => { + it('should initialize impression count at 0', () => { + const count = sdk.frequency.getImpressionCount('welcome-banner'); + expect(count).toBe(0); + }); + + it('should record impressions', () => { + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1); + + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(2); + }); + + it('should track impressions per experience independently', () => { + sdk.frequency.recordImpression('banner-1'); + sdk.frequency.recordImpression('banner-2'); + sdk.frequency.recordImpression('banner-1'); + + expect(sdk.frequency.getImpressionCount('banner-1')).toBe(2); + expect(sdk.frequency.getImpressionCount('banner-2')).toBe(1); + }); + + it('should emit impression-recorded event', () => { + const handler = vi.fn(); + sdk.on('experiences:impression-recorded', handler); + + sdk.frequency.recordImpression('welcome-banner'); + + expect(handler).toHaveBeenCalledWith({ + experienceId: 'welcome-banner', + count: 1, + timestamp: expect.any(Number), + }); + }); + + it('should not record impressions when disabled', () => { + sdk.set('frequency.enabled', false); + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0); + }); + }); + + describe('Session Frequency Caps', () => { + it('should not reach cap below limit', () => { + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(false); + }); + + it('should reach cap at limit', () => { + sdk.frequency.recordImpression('welcome-banner'); + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(true); + }); + + it('should reach cap above limit', () => { + sdk.frequency.recordImpression('welcome-banner'); + sdk.frequency.recordImpression('welcome-banner'); + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(true); + }); + }); + + describe('Time-Based Frequency Caps', () => { + it('should count impressions within day window', () => { + const now = Date.now(); + + // Mock Date.now() for first impression (25 hours ago - outside window) + vi.spyOn(Date, 'now').mockReturnValue(now - 25 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + + // Mock Date.now() for second impression (now - inside window) + vi.spyOn(Date, 'now').mockReturnValue(now); + sdk.frequency.recordImpression('welcome-banner'); + + // Only 1 impression within last 24 hours + expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'day')).toBe(false); + expect(sdk.frequency.hasReachedCap('welcome-banner', 1, 'day')).toBe(true); + + vi.restoreAllMocks(); + }); + + it('should count impressions within week window', () => { + const now = Date.now(); + + // Record 2 impressions 8 days ago (outside week window) + vi.spyOn(Date, 'now').mockReturnValue(now - 8 * 24 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + sdk.frequency.recordImpression('welcome-banner'); + + // Record 1 impression 3 days ago (inside week window) + vi.spyOn(Date, 'now').mockReturnValue(now - 3 * 24 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + + // Current time + vi.spyOn(Date, 'now').mockReturnValue(now); + + // Only 1 impression within last 7 days + expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'week')).toBe(false); + expect(sdk.frequency.hasReachedCap('welcome-banner', 1, 'week')).toBe(true); + + vi.restoreAllMocks(); + }); + + it('should handle multiple impressions within time window', () => { + const now = Date.now(); + + // Record 3 impressions within last day + for (let i = 0; i < 3; i++) { + vi.spyOn(Date, 'now').mockReturnValue(now - i * 60 * 60 * 1000); // Each hour + sdk.frequency.recordImpression('welcome-banner'); + } + + vi.spyOn(Date, 'now').mockReturnValue(now); + + expect(sdk.frequency.hasReachedCap('welcome-banner', 3, 'day')).toBe(true); + expect(sdk.frequency.hasReachedCap('welcome-banner', 4, 'day')).toBe(false); + + vi.restoreAllMocks(); + }); + }); + + describe('Event Integration', () => { + it('should auto-record impression on experiences:evaluated event when show=true', () => { + const decision: Decision = { + show: true, + experienceId: 'welcome-banner', + reasons: ['URL matches'], + trace: [], + context: { + url: 'https://example.com', + timestamp: Date.now(), + }, + metadata: { + evaluatedAt: Date.now(), + totalDuration: 10, + experiencesEvaluated: 1, + }, + }; + + sdk.emit('experiences:evaluated', decision); + + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1); + }); + + it('should not record impression when show=false', () => { + const decision: Decision = { + show: false, + reasons: ['Frequency cap reached'], + trace: [], + context: { + url: 'https://example.com', + timestamp: Date.now(), + }, + metadata: { + evaluatedAt: Date.now(), + totalDuration: 10, + experiencesEvaluated: 1, + }, + }; + + sdk.emit('experiences:evaluated', decision); + + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0); + }); + + it('should not record impression when experienceId is missing', () => { + const decision: Decision = { + show: true, + reasons: ['No matching experience'], + trace: [], + context: { + url: 'https://example.com', + timestamp: Date.now(), + }, + metadata: { + evaluatedAt: Date.now(), + totalDuration: 10, + experiencesEvaluated: 0, + }, + }; + + sdk.emit('experiences:evaluated', decision); + + // Should not throw or record + expect(sdk.frequency.getImpressionCount('any-experience')).toBe(0); + }); + + it('should not auto-record when frequency plugin is disabled', () => { + sdk.set('frequency.enabled', false); + + const decision: Decision = { + show: true, + experienceId: 'welcome-banner', + reasons: ['URL matches'], + trace: [], + context: { + url: 'https://example.com', + timestamp: Date.now(), + }, + metadata: { + evaluatedAt: Date.now(), + totalDuration: 10, + experiencesEvaluated: 1, + }, + }; + + sdk.emit('experiences:evaluated', decision); + + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0); + }); + }); + + describe('Storage Integration', () => { + it('should persist impressions across SDK instances', () => { + // Record impression in first instance + sdk.frequency.recordImpression('welcome-banner'); + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1); + + // Create second instance with same storage backend + const sdk2 = new SDK({ + frequency: { enabled: true }, + storage: { backend: 'memory' }, + }) as SDKWithFrequency; + sdk2.use(storagePlugin); + sdk2.use(frequencyPlugin); + + // Impressions should NOT persist (memory backend is per-instance) + expect(sdk2.frequency.getImpressionCount('welcome-banner')).toBe(0); + }); + + it('should use namespaced storage keys', () => { + sdk.frequency.recordImpression('welcome-banner'); + + // Check storage directly + const storageData = sdk.storage.get('experiences:frequency:welcome-banner'); + expect(storageData).toBeDefined(); + expect((storageData as { count: number }).count).toBe(1); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty experience ID gracefully', () => { + expect(() => sdk.frequency.recordImpression('')).not.toThrow(); + expect(sdk.frequency.getImpressionCount('')).toBe(1); + }); + + it('should handle very large impression counts', () => { + for (let i = 0; i < 1000; i++) { + sdk.frequency.recordImpression('welcome-banner'); + } + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1000); + }); + + it('should clean up old impressions (beyond 7 days)', () => { + const now = Date.now(); + + // Record 5 impressions: 3 old (>7 days), 2 recent + vi.spyOn(Date, 'now').mockReturnValue(now - 10 * 24 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + + vi.spyOn(Date, 'now').mockReturnValue(now - 8 * 24 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + + vi.spyOn(Date, 'now').mockReturnValue(now - 9 * 24 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + + vi.spyOn(Date, 'now').mockReturnValue(now - 2 * 24 * 60 * 60 * 1000); + sdk.frequency.recordImpression('welcome-banner'); + + vi.spyOn(Date, 'now').mockReturnValue(now); + sdk.frequency.recordImpression('welcome-banner'); + + // Total count should be 5, but old impressions cleaned up + expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(5); + + // Check storage for cleaned impressions array + const storageData = sdk.storage.get('experiences:frequency:welcome-banner') as { + impressions: number[]; + }; + // Should only keep impressions from last 7 days (2 recent ones) + expect(storageData.impressions.length).toBe(2); + + vi.restoreAllMocks(); + }); + }); +}); + diff --git a/packages/plugins/src/frequency/frequency.ts b/packages/plugins/src/frequency/frequency.ts new file mode 100644 index 0000000..81b3dcc --- /dev/null +++ b/packages/plugins/src/frequency/frequency.ts @@ -0,0 +1,188 @@ +/** + * Frequency Capping Plugin + * + * Tracks experience impressions and enforces frequency caps. + * Uses sdk-kit's storage plugin for persistence. + */ + +import type { PluginFunction, SDK } from '@lytics/sdk-kit'; +import { storagePlugin, type StoragePlugin } from '@lytics/sdk-kit-plugins'; +import type { Decision } from '@prosdevlab/experience-sdk'; + +export interface FrequencyPluginConfig { + frequency?: { + enabled?: boolean; + namespace?: string; + }; +} + +export interface FrequencyPlugin { + getImpressionCount(experienceId: string): number; + hasReachedCap(experienceId: string, max: number, per: 'session' | 'day' | 'week'): boolean; + recordImpression(experienceId: string): void; +} + +interface ImpressionData { + count: number; + lastImpression: number; + impressions: number[]; +} + +/** + * Frequency Capping Plugin + * + * Automatically tracks impressions and enforces frequency caps. + * Requires storage plugin for persistence. + * + * @example + * ```typescript + * import { createInstance } from '@prosdevlab/experience-sdk'; + * import { frequencyPlugin } from '@prosdevlab/experience-sdk-plugins'; + * + * const sdk = createInstance({ frequency: { enabled: true } }); + * sdk.use(frequencyPlugin); + * ``` + */ +export const frequencyPlugin: PluginFunction = (plugin, instance, config) => { + plugin.ns('frequency'); + + // Set defaults + plugin.defaults({ + frequency: { + enabled: true, + namespace: 'experiences:frequency', + }, + }); + + // Auto-load storage plugin if not already loaded + if (!(instance as SDK & { storage?: StoragePlugin }).storage) { + instance.use(storagePlugin); + } + + const isEnabled = (): boolean => config.get('frequency.enabled') ?? true; + const getNamespace = (): string => config.get('frequency.namespace') ?? 'experiences:frequency'; + + // Helper to get storage key + const getStorageKey = (experienceId: string): string => { + return `${getNamespace()}:${experienceId}`; + }; + + // Helper to get impression data + const getImpressionData = (experienceId: string): ImpressionData => { + const storage = (instance as SDK & { storage: StoragePlugin }).storage; + const data = storage.get(getStorageKey(experienceId)) as ImpressionData | null; + + if (!data) { + return { + count: 0, + lastImpression: 0, + impressions: [], + }; + } + + return data; + }; + + // Helper to save impression data + const saveImpressionData = (experienceId: string, data: ImpressionData): void => { + const storage = (instance as SDK & { storage: StoragePlugin }).storage; + storage.set(getStorageKey(experienceId), data); + }; + + // Get time window in milliseconds + const getTimeWindow = (per: 'session' | 'day' | 'week'): number => { + switch (per) { + case 'session': + return Number.POSITIVE_INFINITY; // Session storage handles this + case 'day': + return 24 * 60 * 60 * 1000; // 24 hours + case 'week': + return 7 * 24 * 60 * 60 * 1000; // 7 days + } + }; + + /** + * Get impression count for an experience + */ + const getImpressionCount = (experienceId: string): number => { + if (!isEnabled()) return 0; + const data = getImpressionData(experienceId); + return data.count; + }; + + /** + * Check if an experience has reached its frequency cap + */ + const hasReachedCap = ( + experienceId: string, + max: number, + per: 'session' | 'day' | 'week' + ): boolean => { + if (!isEnabled()) return false; + + const data = getImpressionData(experienceId); + const timeWindow = getTimeWindow(per); + const now = Date.now(); + + // For session caps, just check total count + if (per === 'session') { + return data.count >= max; + } + + // For time-based caps, count impressions within the window + const recentImpressions = data.impressions.filter( + (timestamp) => now - timestamp < timeWindow + ); + + return recentImpressions.length >= max; + }; + + /** + * Record an impression for an experience + */ + const recordImpression = (experienceId: string): void => { + if (!isEnabled()) return; + + const data = getImpressionData(experienceId); + const now = Date.now(); + + // Update count and add timestamp + data.count += 1; + data.lastImpression = now; + data.impressions.push(now); + + // Keep only recent impressions (last 7 days) + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + data.impressions = data.impressions.filter((ts) => ts > sevenDaysAgo); + + // Save updated data + saveImpressionData(experienceId, data); + + // Emit event + instance.emit('experiences:impression-recorded', { + experienceId, + count: data.count, + timestamp: now, + }); + }; + + // Expose frequency API + plugin.expose({ + frequency: { + getImpressionCount, + hasReachedCap, + recordImpression, + }, + }); + + // Listen to evaluation events and record impressions + if (isEnabled()) { + instance.on('experiences:evaluated', (decision: Decision) => { + // Only record if experience was shown + if (decision.show && decision.experienceId) { + recordImpression(decision.experienceId); + } + }); + } +}; + diff --git a/packages/plugins/src/frequency/index.ts b/packages/plugins/src/frequency/index.ts new file mode 100644 index 0000000..a01b639 --- /dev/null +++ b/packages/plugins/src/frequency/index.ts @@ -0,0 +1,7 @@ +/** + * Frequency Capping Plugin - Barrel Export + */ + +export type { FrequencyPlugin, FrequencyPluginConfig } from './frequency'; +export { frequencyPlugin } from './frequency'; + diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 3fc11ae..9cd3f0d 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -5,3 +5,4 @@ */ export * from './debug'; +export * from './frequency'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51758bd..d8af490 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,16 +7,20 @@ settings: importers: .: + dependencies: + '@lytics/sdk-kit-plugins': + specifier: 0.1.2 + version: 0.1.2(@lytics/sdk-kit@0.1.1(typescript@5.9.3)) devDependencies: '@biomejs/biome': specifier: 2.3.10 version: 2.3.10 '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8(@types/node@25.0.3) + version: 2.29.8(@types/node@24.10.4) '@commitlint/cli': specifier: ^20.2.0 - version: 20.2.0(@types/node@25.0.3)(typescript@5.9.3) + version: 20.2.0(@types/node@24.10.4)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^20.2.0 version: 20.2.0 @@ -25,7 +29,7 @@ importers: version: 24.0.0 '@vitest/coverage-v8': specifier: ^4.0.16 - version: 4.0.16(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)) + version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)(jiti@2.6.1)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -40,7 +44,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1) + version: 4.0.16(@types/node@24.10.4)(jiti@2.6.1) packages/core: dependencies: @@ -66,6 +70,9 @@ importers: '@lytics/sdk-kit': specifier: ^0.1.1 version: 0.1.1(typescript@5.9.3) + '@lytics/sdk-kit-plugins': + specifier: ^0.1.0 + version: 0.1.2(@lytics/sdk-kit@0.1.1(typescript@5.9.3)) '@prosdevlab/experience-sdk': specifier: workspace:* version: link:../core @@ -78,7 +85,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1) + version: 4.0.16(@types/node@24.10.4)(jiti@2.6.1) packages: @@ -466,6 +473,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lytics/sdk-kit-plugins@0.1.2': + resolution: {integrity: sha512-b1yp4jvi+xeaoyNYyTDXLr08QnIRvnQtpxW/0CM1KrkgVB27ZxGpE9nyWy3IRTVapmyu1GuWBoAWNJg4DKihHg==} + peerDependencies: + '@lytics/sdk-kit': ^0.1.1 + '@lytics/sdk-kit@0.1.1': resolution: {integrity: sha512-IDNeiQpuTwAeCh7H8WjxApos6RN7GSe1cW9ncBs+yg9Ja9Ekcxo6xC1U5O0uR4uxrkm84TMwN8HiP8Q74xi/CA==} peerDependencies: @@ -626,9 +638,6 @@ packages: '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} - '@types/node@25.0.3': - resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} - '@vitest/coverage-v8@4.0.16': resolution: {integrity: sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==} peerDependencies: @@ -1710,7 +1719,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8(@types/node@25.0.3)': + '@changesets/cli@2.29.8(@types/node@24.10.4)': dependencies: '@changesets/apply-release-plan': 7.0.14 '@changesets/assemble-release-plan': 6.0.9 @@ -1726,7 +1735,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@25.0.3) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.4) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -1825,11 +1834,11 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@commitlint/cli@20.2.0(@types/node@25.0.3)(typescript@5.9.3)': + '@commitlint/cli@20.2.0(@types/node@24.10.4)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.2.0 '@commitlint/lint': 20.2.0 - '@commitlint/load': 20.2.0(@types/node@25.0.3)(typescript@5.9.3) + '@commitlint/load': 20.2.0(@types/node@24.10.4)(typescript@5.9.3) '@commitlint/read': 20.2.0 '@commitlint/types': 20.2.0 tinyexec: 1.0.2 @@ -1876,7 +1885,7 @@ snapshots: '@commitlint/rules': 20.2.0 '@commitlint/types': 20.2.0 - '@commitlint/load@20.2.0(@types/node@25.0.3)(typescript@5.9.3)': + '@commitlint/load@20.2.0(@types/node@24.10.4)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.2.0 '@commitlint/execute-rule': 20.0.0 @@ -1884,7 +1893,7 @@ snapshots: '@commitlint/types': 20.2.0 chalk: 5.6.2 cosmiconfig: 9.0.0(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@24.10.4)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -2013,12 +2022,12 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@inquirer/external-editor@1.0.3(@types/node@25.0.3)': + '@inquirer/external-editor@1.0.3(@types/node@24.10.4)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.1 optionalDependencies: - '@types/node': 25.0.3 + '@types/node': 24.10.4 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -2034,6 +2043,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lytics/sdk-kit-plugins@0.1.2(@lytics/sdk-kit@0.1.1(typescript@5.9.3))': + dependencies: + '@lytics/sdk-kit': 0.1.1(typescript@5.9.3) + '@lytics/sdk-kit@0.1.1(typescript@5.9.3)': optionalDependencies: typescript: 5.9.3 @@ -2155,11 +2168,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.0.3': - dependencies: - undici-types: 7.16.0 - - '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1))': + '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4)(jiti@2.6.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.16 @@ -2172,7 +2181,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(jiti@2.6.1) + vitest: 4.0.16(@types/node@24.10.4)(jiti@2.6.1) transitivePeerDependencies: - supports-color @@ -2185,13 +2194,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1))': + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1) '@vitest/pretty-format@4.0.16': dependencies: @@ -2324,9 +2333,9 @@ snapshots: meow: 12.1.1 split2: 4.2.0 - cosmiconfig-typescript-loader@6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@24.10.4)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 25.0.3 + '@types/node': 24.10.4 cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -2987,23 +2996,10 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 - vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1): - dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.54.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.0.3 - fsevents: 2.3.3 - jiti: 2.6.1 - vitest@4.0.16(@types/node@24.10.4)(jiti@2.6.1): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -3037,43 +3033,6 @@ snapshots: - tsx - yaml - vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1): - dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 - es-module-lexer: 1.7.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.0.3 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - which@2.0.2: dependencies: isexe: 2.0.0