@lovart-open/flags 0.0.1-canary.pr4.7b8e2dd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -0
- package/package.json +52 -0
- package/src/__tests__/flag-store.test.ts +102 -0
- package/src/__tests__/param-store.test.ts +233 -0
- package/src/index.ts +12 -0
- package/src/statsig/client.ts +123 -0
- package/src/statsig/flags.ts +148 -0
- package/src/statsig/index.ts +54 -0
- package/src/statsig/logger.ts +29 -0
- package/src/statsig/params.ts +390 -0
- package/src/statsig/types.ts +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# @lovart-open/flags
|
|
2
|
+
|
|
3
|
+
[](https://github.com/lovartai/statsig/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@lovart-open/flags)
|
|
5
|
+
|
|
6
|
+
Type-safe Feature Flag and Parameter Store library built on Statsig, with a fully synchronous architecture.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @lovart-open/flags
|
|
12
|
+
# or
|
|
13
|
+
pnpm add @lovart-open/flags
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Initialize Statsig
|
|
17
|
+
|
|
18
|
+
Initialize the Statsig client at your app entry point:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { initStatsigClient } from '@lovart-open/flags/statsig';
|
|
22
|
+
|
|
23
|
+
initStatsigClient('your-statsig-client-key', { userID: 'user-123' }, {
|
|
24
|
+
environment: { tier: 'production' },
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Feature Flags
|
|
31
|
+
|
|
32
|
+
## Core Features
|
|
33
|
+
|
|
34
|
+
- **Type-safe**: Full TypeScript type inference and autocomplete
|
|
35
|
+
- **Synchronous**: No loading states, no skeleton screens
|
|
36
|
+
- **Multi-layer priority**: `URL > testOverride > override > remote > fallback(false)`
|
|
37
|
+
|
|
38
|
+
## Define and Create
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createFlagStore, type FlagDefinition } from '@lovart-open/flags/statsig';
|
|
42
|
+
|
|
43
|
+
// 1. Define your flags
|
|
44
|
+
const MY_FLAGS = {
|
|
45
|
+
dark_mode: {
|
|
46
|
+
description: 'Dark mode toggle'
|
|
47
|
+
},
|
|
48
|
+
new_checkout: {
|
|
49
|
+
description: 'New checkout flow',
|
|
50
|
+
testOverride: true, // Force enable in E2E tests
|
|
51
|
+
},
|
|
52
|
+
beta_feature: {
|
|
53
|
+
description: 'Beta feature',
|
|
54
|
+
override: false, // Static override, ignores remote
|
|
55
|
+
},
|
|
56
|
+
} as const satisfies Record<string, FlagDefinition>;
|
|
57
|
+
|
|
58
|
+
// 2. Create type-safe store and hooks
|
|
59
|
+
export const {
|
|
60
|
+
flagStore,
|
|
61
|
+
useFlag,
|
|
62
|
+
useFlagState
|
|
63
|
+
} = createFlagStore(MY_FLAGS);
|
|
64
|
+
|
|
65
|
+
// 3. Export types (optional)
|
|
66
|
+
export type MyFlagKey = keyof typeof MY_FLAGS;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## React Usage
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { useFlag, useFlagState } from './my-flags';
|
|
73
|
+
|
|
74
|
+
function App() {
|
|
75
|
+
// Get boolean value directly
|
|
76
|
+
const isDark = useFlag('dark_mode'); // ✓ autocomplete
|
|
77
|
+
|
|
78
|
+
// Get full state with source info
|
|
79
|
+
const state = useFlagState('new_checkout');
|
|
80
|
+
console.log(state.flag, state.source); // true, 'remote'
|
|
81
|
+
|
|
82
|
+
return isDark ? <DarkTheme /> : <LightTheme />;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Non-React Usage
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { flagStore } from './my-flags';
|
|
90
|
+
|
|
91
|
+
// Get single flag
|
|
92
|
+
const enabled = flagStore.getFlag('dark_mode');
|
|
93
|
+
|
|
94
|
+
// Get snapshot of all flags
|
|
95
|
+
const snapshot = flagStore.snapshot;
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## URL Override for Debugging
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
?ff.dark_mode=1 → Force enable
|
|
102
|
+
?ff.dark_mode=0 → Force disable
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
# Parameter Store
|
|
108
|
+
|
|
109
|
+
## Core Features
|
|
110
|
+
|
|
111
|
+
- **Type-safe**: Zod schema validation + TypeScript inference
|
|
112
|
+
- **Synchronous**: Same as Feature Flags
|
|
113
|
+
- **Multi-layer priority**: `URL > testOverride > override > remote > fallback`
|
|
114
|
+
|
|
115
|
+
## Define and Create
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { z } from 'zod';
|
|
119
|
+
import {
|
|
120
|
+
createParamStore,
|
|
121
|
+
defineParam,
|
|
122
|
+
type ParamStoreDefinition
|
|
123
|
+
} from '@lovart-open/flags/statsig';
|
|
124
|
+
|
|
125
|
+
// 1. Define your param stores
|
|
126
|
+
const MY_PARAMS = {
|
|
127
|
+
homepage_cta: {
|
|
128
|
+
description: 'Homepage CTA button',
|
|
129
|
+
params: {
|
|
130
|
+
text: defineParam({
|
|
131
|
+
schema: z.enum(['Learn More', 'Get Started', 'Sign Up']),
|
|
132
|
+
fallback: 'Learn More',
|
|
133
|
+
description: 'Button text',
|
|
134
|
+
}),
|
|
135
|
+
color: defineParam({
|
|
136
|
+
schema: z.enum(['gray', 'red', 'blue']),
|
|
137
|
+
fallback: 'gray',
|
|
138
|
+
testOverride: 'blue', // Use in E2E tests
|
|
139
|
+
}),
|
|
140
|
+
visible: defineParam({
|
|
141
|
+
schema: z.boolean(),
|
|
142
|
+
fallback: true,
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
pricing: {
|
|
147
|
+
description: 'Pricing config',
|
|
148
|
+
params: {
|
|
149
|
+
discount: defineParam({
|
|
150
|
+
schema: z.number().min(0).max(100),
|
|
151
|
+
fallback: 0,
|
|
152
|
+
}),
|
|
153
|
+
currency: defineParam({
|
|
154
|
+
schema: z.enum(['USD', 'CNY', 'EUR']),
|
|
155
|
+
fallback: 'USD',
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
} as const satisfies Record<string, ParamStoreDefinition<any>>;
|
|
160
|
+
|
|
161
|
+
// 2. Create type-safe store and hooks
|
|
162
|
+
export const {
|
|
163
|
+
paramStore,
|
|
164
|
+
useParam,
|
|
165
|
+
useParamState,
|
|
166
|
+
useParamStore
|
|
167
|
+
} = createParamStore(MY_PARAMS);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## React Usage
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { useParam, useParamStore } from './my-params';
|
|
174
|
+
|
|
175
|
+
function CTAButton() {
|
|
176
|
+
// Get value directly (with full type hints)
|
|
177
|
+
const text = useParam('homepage_cta', 'text'); // 'Learn More' | 'Get Started' | 'Sign Up'
|
|
178
|
+
const color = useParam('homepage_cta', 'color'); // 'gray' | 'red' | 'blue'
|
|
179
|
+
|
|
180
|
+
// Or get entire store handle
|
|
181
|
+
const store = useParamStore('homepage_cta');
|
|
182
|
+
const visible = store.get('visible'); // boolean
|
|
183
|
+
|
|
184
|
+
if (!visible) return null;
|
|
185
|
+
return <button style={{ color }}>{text}</button>;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Non-React Usage
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { paramStore } from './my-params';
|
|
193
|
+
|
|
194
|
+
// Get single param
|
|
195
|
+
const discount = paramStore.getParam('pricing', 'discount'); // number
|
|
196
|
+
|
|
197
|
+
// Get store handle
|
|
198
|
+
const store = paramStore.getStore('pricing');
|
|
199
|
+
store.get('currency'); // 'USD' | 'CNY' | 'EUR'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## URL Override for Debugging
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
# Single param override
|
|
206
|
+
?fp.homepage_cta.text=Get Started
|
|
207
|
+
?fp.pricing.discount=20
|
|
208
|
+
|
|
209
|
+
# Entire store JSON override
|
|
210
|
+
?fp.homepage_cta={"text":"Get Started","visible":false}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
# Advanced Configuration
|
|
216
|
+
|
|
217
|
+
## Custom Logger
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { initStatsigClient, setLogger } from '@lovart-open/flags/statsig';
|
|
221
|
+
|
|
222
|
+
// Option 1: Pass during init
|
|
223
|
+
initStatsigClient('client-xxx', { userID: 'user-123' }, {
|
|
224
|
+
logger: (message) => myLogger.info(message),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Option 2: Set globally
|
|
228
|
+
setLogger((message) => myLogger.info(message));
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## E2E Test Support
|
|
232
|
+
|
|
233
|
+
Configure `isTestEnv` in initialization to enable `testOverride` values:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
initStatsigClient('client-xxx', { userID: 'user-123' }, {
|
|
237
|
+
isTestEnv: () => Boolean(window.__E2E__),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// playwright/cypress tests
|
|
241
|
+
await page.addInitScript(() => {
|
|
242
|
+
window.__E2E__ = true;
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Server-Side Bootstrap (Zero-Network Init)
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
initStatsigClient('client-xxx', { userID: 'user-123' }, {
|
|
250
|
+
bootstrap: {
|
|
251
|
+
data: bootstrapDataFromServer, // Pre-fetched from BFF
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## FlagDefinition Options
|
|
257
|
+
|
|
258
|
+
| Property | Type | Description |
|
|
259
|
+
|----------|------|-------------|
|
|
260
|
+
| `description` | `string` | Human-readable description |
|
|
261
|
+
| `testOverride` | `boolean` | Fixed value in E2E tests |
|
|
262
|
+
| `override` | `boolean` | Static override (priority over remote) |
|
|
263
|
+
| `keep` | `boolean` | Mark as kept locally (no remote needed) |
|
|
264
|
+
|
|
265
|
+
## ParamDefinition Options
|
|
266
|
+
|
|
267
|
+
| Property | Type | Description |
|
|
268
|
+
|----------|------|-------------|
|
|
269
|
+
| `schema` | `z.ZodType` | Zod schema (required) |
|
|
270
|
+
| `fallback` | `T` | Default value (required) |
|
|
271
|
+
| `description` | `string` | Human-readable description |
|
|
272
|
+
| `testOverride` | `T` | Fixed value in E2E tests |
|
|
273
|
+
| `override` | `T` | Static override |
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lovart-open/flags",
|
|
3
|
+
"version": "0.0.1-canary.pr4.7b8e2dd",
|
|
4
|
+
"description": "Feature flag management with multi-layer priority system (client-side)",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./statsig": {
|
|
13
|
+
"types": "./src/statsig/index.ts",
|
|
14
|
+
"default": "./src/statsig/index.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"feature-flag",
|
|
22
|
+
"statsig",
|
|
23
|
+
"parameter-store"
|
|
24
|
+
],
|
|
25
|
+
"author": "huxingyu@liblib.ai",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@statsig/js-client": "^3.30.0",
|
|
29
|
+
"@statsig/react-bindings": "^3.30.2",
|
|
30
|
+
"lodash": "^4.17.21",
|
|
31
|
+
"zod": "^3.23.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@testing-library/react": "^16.3.0",
|
|
35
|
+
"@types/lodash": "^4.17.0",
|
|
36
|
+
"@types/react": "^18.0.0 || ^19.0.0",
|
|
37
|
+
"jsdom": "^23.0.1",
|
|
38
|
+
"typescript": "^5.6.3",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/lovartai/flags.git"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Mock external dependencies
|
|
2
|
+
const mockGetFeatureGate = vi.fn();
|
|
3
|
+
const mockIsTestEnv = vi.fn(() => false);
|
|
4
|
+
vi.mock('../statsig/client', () => ({
|
|
5
|
+
getStatsigClientSync: vi.fn(() => ({
|
|
6
|
+
getFeatureGate: mockGetFeatureGate,
|
|
7
|
+
})),
|
|
8
|
+
isTestEnv: () => mockIsTestEnv(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { createFlagStore, type FlagDefinition } from '../statsig';
|
|
14
|
+
|
|
15
|
+
describe('FlagStore', () => {
|
|
16
|
+
const mockFlags = {
|
|
17
|
+
feature_a: { description: 'Feature A' },
|
|
18
|
+
feature_b: { description: 'Feature B', testOverride: true },
|
|
19
|
+
feature_c: { description: 'Feature C', override: false },
|
|
20
|
+
} as const satisfies Record<string, FlagDefinition>;
|
|
21
|
+
|
|
22
|
+
const { flagStore } = createFlagStore(mockFlags);
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.resetAllMocks();
|
|
26
|
+
mockGetFeatureGate.mockReturnValue({
|
|
27
|
+
value: false,
|
|
28
|
+
idType: '',
|
|
29
|
+
});
|
|
30
|
+
mockIsTestEnv.mockReturnValue(false);
|
|
31
|
+
vi.stubGlobal('location', {
|
|
32
|
+
search: '',
|
|
33
|
+
pathname: '/',
|
|
34
|
+
hash: '',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Initial State', () => {
|
|
43
|
+
it('should initialize with fallback state', () => {
|
|
44
|
+
const state = flagStore.getFlagState('feature_a');
|
|
45
|
+
expect(state.flag).toBe(false);
|
|
46
|
+
expect(state.source).toBe('fallback');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Priority Logic', () => {
|
|
51
|
+
it('Priority 1: URL Override should win', () => {
|
|
52
|
+
vi.stubGlobal('location', {
|
|
53
|
+
search: '?ff.feature_a=1',
|
|
54
|
+
href: 'http://localhost/?ff.feature_a=1',
|
|
55
|
+
});
|
|
56
|
+
const urlState = flagStore.resolve('feature_a', { search: '?ff.feature_a=1' });
|
|
57
|
+
expect(urlState.flag).toBe(true);
|
|
58
|
+
expect(urlState.source).toBe('url');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('Priority 2: Test Override should win over code static override', () => {
|
|
62
|
+
mockIsTestEnv.mockReturnValue(true);
|
|
63
|
+
const state = flagStore.getFlagState('feature_b');
|
|
64
|
+
expect(state.flag).toBe(true);
|
|
65
|
+
expect(state.source).toBe('test');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('Priority 3: Code Static Override should win over remote', () => {
|
|
69
|
+
mockGetFeatureGate.mockReturnValue({ value: true, idType: 'userID' });
|
|
70
|
+
const state = flagStore.getFlagState('feature_c');
|
|
71
|
+
expect(state.flag).toBe(false);
|
|
72
|
+
expect(state.source).toBe('override');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('Priority 4: Remote Value should win over fallback', () => {
|
|
76
|
+
mockGetFeatureGate.mockReturnValue({ value: true, idType: 'userID' });
|
|
77
|
+
const state = flagStore.getFlagState('feature_a');
|
|
78
|
+
expect(state.source).toBe('remote');
|
|
79
|
+
expect(state.flag).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('Type Safety', () => {
|
|
84
|
+
it('should have type-safe keys', () => {
|
|
85
|
+
// These calls should have type hints
|
|
86
|
+
flagStore.getFlag('feature_a');
|
|
87
|
+
flagStore.getFlag('feature_b');
|
|
88
|
+
flagStore.getFlag('feature_c');
|
|
89
|
+
|
|
90
|
+
// Uncommenting below should cause compile error
|
|
91
|
+
// flagStore.getFlag('unknown_key');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Edge Cases', () => {
|
|
96
|
+
it('should support explicit remoteValue passed to resolve', () => {
|
|
97
|
+
const state = flagStore.resolve('feature_a', { gate: { value: true, idType: 'useID' } as any });
|
|
98
|
+
expect(state.flag).toBe(true);
|
|
99
|
+
expect(state.source).toBe('remote');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// Mock statsig
|
|
5
|
+
const mockIsTestEnv = vi.fn(() => false);
|
|
6
|
+
vi.mock('../statsig/client', () => ({
|
|
7
|
+
getStatsigClientSync: vi.fn(() => ({
|
|
8
|
+
getParameterStore: vi.fn(),
|
|
9
|
+
})),
|
|
10
|
+
isTestEnv: () => mockIsTestEnv(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { createParamStore, defineParam, type ParamStoreDefinition, getStatsigClientSync } from '../statsig';
|
|
14
|
+
|
|
15
|
+
describe('ParamStore', () => {
|
|
16
|
+
const testStores = {
|
|
17
|
+
test_store: {
|
|
18
|
+
description: 'Test store',
|
|
19
|
+
params: {
|
|
20
|
+
text: defineParam({ schema: z.string(), fallback: 'default text' }),
|
|
21
|
+
count: defineParam({ schema: z.number(), fallback: 0 }),
|
|
22
|
+
enabled: defineParam({ schema: z.boolean(), fallback: false }),
|
|
23
|
+
color: defineParam({
|
|
24
|
+
schema: z.enum(['red', 'blue', 'green']),
|
|
25
|
+
fallback: 'red' as const,
|
|
26
|
+
testOverride: 'blue' as const,
|
|
27
|
+
}),
|
|
28
|
+
size: defineParam({
|
|
29
|
+
schema: z.number(),
|
|
30
|
+
fallback: 10,
|
|
31
|
+
override: 20,
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
} as const satisfies Record<string, ParamStoreDefinition<any>>;
|
|
36
|
+
|
|
37
|
+
const { paramStore } = createParamStore(testStores);
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.resetAllMocks();
|
|
41
|
+
mockIsTestEnv.mockReturnValue(false);
|
|
42
|
+
vi.stubGlobal('location', { search: '' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getParam / getParamState', () => {
|
|
50
|
+
it('returns fallback value when remote has no config', () => {
|
|
51
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
52
|
+
getParameterStore: vi.fn(() => ({
|
|
53
|
+
get: (_key: string, fallback: unknown) => fallback,
|
|
54
|
+
})),
|
|
55
|
+
} as any);
|
|
56
|
+
|
|
57
|
+
const value = paramStore.getParam('test_store', 'text');
|
|
58
|
+
expect(value).toBe('default text');
|
|
59
|
+
|
|
60
|
+
const state = paramStore.getParamState('test_store', 'text');
|
|
61
|
+
expect(state.value).toBe('default text');
|
|
62
|
+
expect(state.source).toBe('fallback');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns remote value when remote has data', () => {
|
|
66
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
67
|
+
getParameterStore: vi.fn(() => ({
|
|
68
|
+
__configuration: {
|
|
69
|
+
text: { value: 'remote text' },
|
|
70
|
+
},
|
|
71
|
+
get: (key: string) => (key === 'text' ? 'remote text' : undefined),
|
|
72
|
+
})),
|
|
73
|
+
} as any);
|
|
74
|
+
|
|
75
|
+
const state = paramStore.getParamState('test_store', 'text');
|
|
76
|
+
expect(state.value).toBe('remote text');
|
|
77
|
+
expect(state.source).toBe('remote');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('URL override has highest priority', () => {
|
|
81
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
82
|
+
getParameterStore: vi.fn(() => ({
|
|
83
|
+
__configuration: { text: { value: 'remote text' } },
|
|
84
|
+
get: () => 'remote text',
|
|
85
|
+
})),
|
|
86
|
+
} as any);
|
|
87
|
+
|
|
88
|
+
const state = paramStore.getParamState('test_store', 'text', {
|
|
89
|
+
search: '?fp.test_store.text=URL text',
|
|
90
|
+
});
|
|
91
|
+
expect(state.value).toBe('URL text');
|
|
92
|
+
expect(state.source).toBe('url');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('testOverride works in E2E environment', () => {
|
|
96
|
+
mockIsTestEnv.mockReturnValue(true);
|
|
97
|
+
|
|
98
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
99
|
+
getParameterStore: vi.fn(() => ({
|
|
100
|
+
get: () => 'green',
|
|
101
|
+
})),
|
|
102
|
+
} as any);
|
|
103
|
+
|
|
104
|
+
const state = paramStore.getParamState('test_store', 'color');
|
|
105
|
+
expect(state.value).toBe('blue');
|
|
106
|
+
expect(state.source).toBe('test');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('override has priority over remote', () => {
|
|
110
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
111
|
+
getParameterStore: vi.fn(() => ({
|
|
112
|
+
get: () => 30,
|
|
113
|
+
})),
|
|
114
|
+
} as any);
|
|
115
|
+
|
|
116
|
+
const state = paramStore.getParamState('test_store', 'size');
|
|
117
|
+
expect(state.value).toBe(20);
|
|
118
|
+
expect(state.source).toBe('override');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('URL number type coercion', () => {
|
|
122
|
+
const state = paramStore.getParamState('test_store', 'count', {
|
|
123
|
+
search: '?fp.test_store.count=42',
|
|
124
|
+
});
|
|
125
|
+
expect(state.value).toBe(42);
|
|
126
|
+
expect(state.source).toBe('url');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('URL boolean type coercion', () => {
|
|
130
|
+
const state = paramStore.getParamState('test_store', 'enabled', {
|
|
131
|
+
search: '?fp.test_store.enabled=true',
|
|
132
|
+
});
|
|
133
|
+
expect(state.value).toBe(true);
|
|
134
|
+
expect(state.source).toBe('url');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('getStore', () => {
|
|
139
|
+
it('returns handle object with { get, getState }', () => {
|
|
140
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
141
|
+
getParameterStore: vi.fn(() => ({
|
|
142
|
+
get: (_key: string, fallback: unknown) => fallback,
|
|
143
|
+
})),
|
|
144
|
+
} as any);
|
|
145
|
+
|
|
146
|
+
const store = paramStore.getStore('test_store');
|
|
147
|
+
|
|
148
|
+
expect(typeof store.get).toBe('function');
|
|
149
|
+
expect(typeof store.getState).toBe('function');
|
|
150
|
+
|
|
151
|
+
expect(store.get('text')).toBe('default text');
|
|
152
|
+
expect(store.getState('count')).toEqual({ value: 0, source: 'fallback' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('handle reuses same statsigStore', () => {
|
|
156
|
+
const mockGetParameterStore = vi.fn(() => ({
|
|
157
|
+
get: (_key: string, fallback: unknown) => fallback,
|
|
158
|
+
}));
|
|
159
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
160
|
+
getParameterStore: mockGetParameterStore,
|
|
161
|
+
} as any);
|
|
162
|
+
|
|
163
|
+
const store = paramStore.getStore('test_store');
|
|
164
|
+
store.get('text');
|
|
165
|
+
store.get('count');
|
|
166
|
+
store.getState('enabled');
|
|
167
|
+
|
|
168
|
+
// getParameterStore should only be called once (closure reuse)
|
|
169
|
+
expect(mockGetParameterStore).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('Priority Order', () => {
|
|
174
|
+
it('URL > test > override > remote > fallback', () => {
|
|
175
|
+
mockIsTestEnv.mockReturnValue(true);
|
|
176
|
+
|
|
177
|
+
vi.mocked(getStatsigClientSync).mockReturnValue({
|
|
178
|
+
getParameterStore: vi.fn(() => ({
|
|
179
|
+
get: () => 'green',
|
|
180
|
+
})),
|
|
181
|
+
} as any);
|
|
182
|
+
|
|
183
|
+
// color has testOverride='blue', remote='green'
|
|
184
|
+
// URL override should take priority
|
|
185
|
+
const state = paramStore.getParamState('test_store', 'color', {
|
|
186
|
+
search: '?fp.test_store.color=red',
|
|
187
|
+
});
|
|
188
|
+
expect(state.value).toBe('red');
|
|
189
|
+
expect(state.source).toBe('url');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('Error Handling', () => {
|
|
194
|
+
it('throws error for unknown store', () => {
|
|
195
|
+
expect(() => {
|
|
196
|
+
(paramStore as any).getParamState('unknown_store', 'text');
|
|
197
|
+
}).toThrow('[ParamStore] Unknown store: unknown_store');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('throws error for unknown param', () => {
|
|
201
|
+
expect(() => {
|
|
202
|
+
(paramStore as any).getParamState('test_store', 'unknown_param');
|
|
203
|
+
}).toThrow('[ParamStore] Unknown param: test_store.unknown_param');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('logs warning when URL value schema mismatch', () => {
|
|
207
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
208
|
+
|
|
209
|
+
// count expects number, passing non-numeric string 'abc'
|
|
210
|
+
paramStore.getParamState('test_store', 'count', {
|
|
211
|
+
search: '?fp.test_store.count=abc',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Schema mismatch'), 'abc', expect.anything());
|
|
215
|
+
|
|
216
|
+
consoleSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('Type Safety', () => {
|
|
221
|
+
it('should have type-safe store and param keys', () => {
|
|
222
|
+
// These calls should have type hints
|
|
223
|
+
paramStore.getParam('test_store', 'text'); // string
|
|
224
|
+
paramStore.getParam('test_store', 'count'); // number
|
|
225
|
+
paramStore.getParam('test_store', 'enabled'); // boolean
|
|
226
|
+
paramStore.getParam('test_store', 'color'); // 'red' | 'blue' | 'green'
|
|
227
|
+
|
|
228
|
+
// Uncommenting below should cause compile error
|
|
229
|
+
// paramStore.getParam('unknown_store', 'text');
|
|
230
|
+
// paramStore.getParam('test_store', 'unknown_param');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lovart-open/flags
|
|
3
|
+
*
|
|
4
|
+
* This package uses subpath exports. Import from:
|
|
5
|
+
* - @lovart-open/flags/statsig - Statsig-based feature flags and parameter stores
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { createFlagStore, createParamStore } from '@lovart-open/flags/statsig';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// No exports from root path - use subpath imports
|
|
12
|
+
export {};
|