servcraft 0.1.0 → 0.1.3
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/.claude/settings.local.json +30 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/LICENSE +21 -0
- package/README.md +1102 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature Flag Service
|
|
3
|
+
* A/B testing and progressive rollout
|
|
4
|
+
*
|
|
5
|
+
* Persistence:
|
|
6
|
+
* - Flags: Prisma/PostgreSQL (persistent)
|
|
7
|
+
* - Overrides: Prisma/PostgreSQL (persistent)
|
|
8
|
+
* - Stats: Redis with TTL (temporary, for performance)
|
|
9
|
+
* - Events: In-memory circular buffer (runtime only)
|
|
10
|
+
*/
|
|
11
|
+
import { logger } from '../../core/logger.js';
|
|
12
|
+
import { NotFoundError, BadRequestError } from '../../utils/errors.js';
|
|
13
|
+
import { prisma } from '../../database/prisma.js';
|
|
14
|
+
import { getRedis } from '../../database/redis.js';
|
|
15
|
+
import { FeatureFlagRepository } from './feature-flag.repository.js';
|
|
16
|
+
import type {
|
|
17
|
+
FeatureFlag,
|
|
18
|
+
FlagEvaluationContext,
|
|
19
|
+
FlagEvaluationResult,
|
|
20
|
+
FlagStats,
|
|
21
|
+
FlagOverride,
|
|
22
|
+
FlagEvent,
|
|
23
|
+
FeatureFlagConfig,
|
|
24
|
+
FlagListFilters,
|
|
25
|
+
FlagConfig,
|
|
26
|
+
UserAttributeRule,
|
|
27
|
+
} from './types.js';
|
|
28
|
+
|
|
29
|
+
const FLAG_STATS_PREFIX = 'flagstats:';
|
|
30
|
+
const FLAG_STATS_TTL = 86400; // 24 hours
|
|
31
|
+
|
|
32
|
+
export class FeatureFlagService {
|
|
33
|
+
private repository: FeatureFlagRepository;
|
|
34
|
+
private events: FlagEvent[] = [];
|
|
35
|
+
private config: FeatureFlagConfig;
|
|
36
|
+
|
|
37
|
+
constructor(config: FeatureFlagConfig = {}) {
|
|
38
|
+
this.config = {
|
|
39
|
+
defaultEnvironment: 'development',
|
|
40
|
+
analytics: true,
|
|
41
|
+
cacheTtl: 300,
|
|
42
|
+
...config,
|
|
43
|
+
};
|
|
44
|
+
this.repository = new FeatureFlagRepository(prisma);
|
|
45
|
+
|
|
46
|
+
logger.info('Feature flag service initialized');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a feature flag
|
|
51
|
+
*/
|
|
52
|
+
async createFlag(flag: Omit<FeatureFlag, 'createdAt' | 'updatedAt'>): Promise<FeatureFlag> {
|
|
53
|
+
const existing = await this.repository.getByKey(flag.key);
|
|
54
|
+
if (existing) {
|
|
55
|
+
throw new BadRequestError(`Flag with key "${flag.key}" already exists`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const newFlag = await this.repository.create(flag);
|
|
59
|
+
|
|
60
|
+
// Initialize stats in Redis
|
|
61
|
+
await this.initializeStats(flag.key);
|
|
62
|
+
|
|
63
|
+
this.logEvent({
|
|
64
|
+
type: 'created',
|
|
65
|
+
flagKey: flag.key,
|
|
66
|
+
userId: flag.createdBy,
|
|
67
|
+
data: { flag: newFlag },
|
|
68
|
+
timestamp: new Date(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
logger.info({ flagKey: flag.key }, 'Feature flag created');
|
|
72
|
+
|
|
73
|
+
return newFlag;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Update a feature flag
|
|
78
|
+
*/
|
|
79
|
+
async updateFlag(key: string, updates: Partial<FeatureFlag>): Promise<FeatureFlag> {
|
|
80
|
+
const flag = await this.repository.update(key, updates);
|
|
81
|
+
|
|
82
|
+
if (!flag) {
|
|
83
|
+
throw new NotFoundError(`Flag "${key}" not found`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.logEvent({
|
|
87
|
+
type: 'updated',
|
|
88
|
+
flagKey: key,
|
|
89
|
+
data: { updates },
|
|
90
|
+
timestamp: new Date(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
logger.info({ flagKey: key }, 'Feature flag updated');
|
|
94
|
+
|
|
95
|
+
return flag;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Delete a feature flag
|
|
100
|
+
*/
|
|
101
|
+
async deleteFlag(key: string): Promise<void> {
|
|
102
|
+
const deleted = await this.repository.delete(key);
|
|
103
|
+
|
|
104
|
+
if (!deleted) {
|
|
105
|
+
throw new NotFoundError(`Flag "${key}" not found`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Clean up stats
|
|
109
|
+
const redis = getRedis();
|
|
110
|
+
await redis.del(`${FLAG_STATS_PREFIX}${key}`);
|
|
111
|
+
|
|
112
|
+
this.logEvent({
|
|
113
|
+
type: 'deleted',
|
|
114
|
+
flagKey: key,
|
|
115
|
+
timestamp: new Date(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
logger.info({ flagKey: key }, 'Feature flag deleted');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get a feature flag
|
|
123
|
+
*/
|
|
124
|
+
async getFlag(key: string): Promise<FeatureFlag> {
|
|
125
|
+
const flag = await this.repository.getByKey(key);
|
|
126
|
+
|
|
127
|
+
if (!flag) {
|
|
128
|
+
throw new NotFoundError(`Flag "${key}" not found`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return flag;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* List feature flags
|
|
136
|
+
*/
|
|
137
|
+
async listFlags(filters?: FlagListFilters): Promise<FeatureFlag[]> {
|
|
138
|
+
return this.repository.list(filters);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Evaluate a feature flag
|
|
143
|
+
*/
|
|
144
|
+
async evaluateFlag(key: string, context: FlagEvaluationContext): Promise<FlagEvaluationResult> {
|
|
145
|
+
const flag = await this.repository.getByKey(key);
|
|
146
|
+
|
|
147
|
+
if (!flag) {
|
|
148
|
+
throw new NotFoundError(`Flag "${key}" not found`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check environment match
|
|
152
|
+
if (flag.environment && context.environment && flag.environment !== context.environment) {
|
|
153
|
+
return this.createResult(key, false, 'Environment mismatch', flag.strategy);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check for overrides
|
|
157
|
+
const override = await this.getOverride(key, context);
|
|
158
|
+
if (override) {
|
|
159
|
+
return this.createResult(key, override.enabled, 'Override active', flag.strategy);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if flag is disabled
|
|
163
|
+
if (flag.status === 'disabled') {
|
|
164
|
+
return this.createResult(key, false, 'Flag disabled', flag.strategy);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Evaluate based on strategy
|
|
168
|
+
let enabled = false;
|
|
169
|
+
let reason = '';
|
|
170
|
+
|
|
171
|
+
switch (flag.strategy) {
|
|
172
|
+
case 'boolean':
|
|
173
|
+
enabled = flag.config.value ?? false;
|
|
174
|
+
reason = enabled ? 'Boolean: true' : 'Boolean: false';
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case 'percentage':
|
|
178
|
+
enabled = this.evaluatePercentage(flag.config, context);
|
|
179
|
+
reason = `Percentage rollout: ${flag.config.percentage}%`;
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'user-list':
|
|
183
|
+
enabled = this.evaluateUserList(flag.config, context);
|
|
184
|
+
reason = enabled ? 'User in whitelist' : 'User not in whitelist';
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'user-attribute':
|
|
188
|
+
enabled = this.evaluateUserAttributes(flag.config, context);
|
|
189
|
+
reason = enabled ? 'User attributes match' : 'User attributes do not match';
|
|
190
|
+
break;
|
|
191
|
+
|
|
192
|
+
case 'date-range':
|
|
193
|
+
enabled = this.evaluateDateRange(flag.config);
|
|
194
|
+
reason = enabled ? 'Within date range' : 'Outside date range';
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Track statistics
|
|
199
|
+
await this.trackEvaluation(key, enabled, context);
|
|
200
|
+
|
|
201
|
+
// Log event
|
|
202
|
+
this.logEvent({
|
|
203
|
+
type: 'evaluated',
|
|
204
|
+
flagKey: key,
|
|
205
|
+
userId: context.userId,
|
|
206
|
+
data: { enabled, reason, context },
|
|
207
|
+
timestamp: new Date(),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return this.createResult(key, enabled, reason, flag.strategy);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Evaluate multiple flags at once
|
|
215
|
+
*/
|
|
216
|
+
async evaluateFlags(
|
|
217
|
+
keys: string[],
|
|
218
|
+
context: FlagEvaluationContext
|
|
219
|
+
): Promise<Record<string, FlagEvaluationResult>> {
|
|
220
|
+
const results: Record<string, FlagEvaluationResult> = {};
|
|
221
|
+
|
|
222
|
+
for (const key of keys) {
|
|
223
|
+
try {
|
|
224
|
+
results[key] = await this.evaluateFlag(key, context);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logger.error({ key, error }, 'Failed to evaluate flag');
|
|
227
|
+
results[key] = this.createResult(key, false, 'Evaluation error', 'boolean');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if a flag is enabled (simplified)
|
|
236
|
+
*/
|
|
237
|
+
async isEnabled(key: string, context: FlagEvaluationContext): Promise<boolean> {
|
|
238
|
+
const result = await this.evaluateFlag(key, context);
|
|
239
|
+
return result.enabled;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Set override for specific user/session
|
|
244
|
+
*/
|
|
245
|
+
async setOverride(
|
|
246
|
+
flagKey: string,
|
|
247
|
+
targetId: string,
|
|
248
|
+
targetType: 'user' | 'session',
|
|
249
|
+
enabled: boolean,
|
|
250
|
+
expiresAt?: Date
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
// Verify flag exists
|
|
253
|
+
const flag = await this.repository.getByKey(flagKey);
|
|
254
|
+
if (!flag) {
|
|
255
|
+
throw new NotFoundError(`Flag "${flagKey}" not found`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await this.repository.createOverride({
|
|
259
|
+
flagKey,
|
|
260
|
+
targetId,
|
|
261
|
+
targetType,
|
|
262
|
+
enabled,
|
|
263
|
+
expiresAt,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
this.logEvent({
|
|
267
|
+
type: 'override-set',
|
|
268
|
+
flagKey,
|
|
269
|
+
userId: targetType === 'user' ? targetId : undefined,
|
|
270
|
+
data: { targetId, targetType, enabled, expiresAt },
|
|
271
|
+
timestamp: new Date(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
logger.debug({ flagKey, targetId }, 'Override set');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Remove override
|
|
279
|
+
*/
|
|
280
|
+
async removeOverride(flagKey: string, targetId: string): Promise<void> {
|
|
281
|
+
const deleted = await this.repository.deleteOverride(flagKey, targetId);
|
|
282
|
+
|
|
283
|
+
if (deleted) {
|
|
284
|
+
this.logEvent({
|
|
285
|
+
type: 'override-removed',
|
|
286
|
+
flagKey,
|
|
287
|
+
data: { targetId },
|
|
288
|
+
timestamp: new Date(),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
logger.debug({ flagKey, targetId }, 'Override removed');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get flag statistics
|
|
297
|
+
*/
|
|
298
|
+
async getStats(key: string): Promise<FlagStats> {
|
|
299
|
+
const redis = getRedis();
|
|
300
|
+
const statsJson = await redis.get(`${FLAG_STATS_PREFIX}${key}`);
|
|
301
|
+
|
|
302
|
+
if (!statsJson) {
|
|
303
|
+
return {
|
|
304
|
+
totalEvaluations: 0,
|
|
305
|
+
enabledCount: 0,
|
|
306
|
+
disabledCount: 0,
|
|
307
|
+
uniqueUsers: 0,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return JSON.parse(statsJson) as FlagStats;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get all events for a flag
|
|
316
|
+
*/
|
|
317
|
+
async getEvents(flagKey: string, limit = 100): Promise<FlagEvent[]> {
|
|
318
|
+
return this.events.filter((e) => e.flagKey === flagKey).slice(-limit);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ==========================================
|
|
322
|
+
// PRIVATE METHODS
|
|
323
|
+
// ==========================================
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get override for context
|
|
327
|
+
*/
|
|
328
|
+
private async getOverride(
|
|
329
|
+
flagKey: string,
|
|
330
|
+
context: FlagEvaluationContext
|
|
331
|
+
): Promise<FlagOverride | null> {
|
|
332
|
+
// Check user override
|
|
333
|
+
if (context.userId) {
|
|
334
|
+
const userOverride = await this.repository.getOverride(flagKey, context.userId);
|
|
335
|
+
if (userOverride && this.isOverrideValid(userOverride)) {
|
|
336
|
+
return userOverride;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Check session override
|
|
341
|
+
if (context.sessionId) {
|
|
342
|
+
const sessionOverride = await this.repository.getOverride(flagKey, context.sessionId);
|
|
343
|
+
if (sessionOverride && this.isOverrideValid(sessionOverride)) {
|
|
344
|
+
return sessionOverride;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private isOverrideValid(override: FlagOverride): boolean {
|
|
352
|
+
if (!override.expiresAt) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
return new Date() < override.expiresAt;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Evaluate percentage rollout
|
|
360
|
+
*/
|
|
361
|
+
private evaluatePercentage(config: FlagConfig, context: FlagEvaluationContext): boolean {
|
|
362
|
+
if (!config.percentage) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const id = context.userId || context.sessionId || '';
|
|
367
|
+
const hash = this.hashString(id);
|
|
368
|
+
const bucket = hash % 100;
|
|
369
|
+
|
|
370
|
+
return bucket < config.percentage;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Evaluate user list
|
|
375
|
+
*/
|
|
376
|
+
private evaluateUserList(config: FlagConfig, context: FlagEvaluationContext): boolean {
|
|
377
|
+
if (!config.userIds || !context.userId) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return config.userIds.includes(context.userId);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Evaluate user attributes
|
|
386
|
+
*/
|
|
387
|
+
private evaluateUserAttributes(config: FlagConfig, context: FlagEvaluationContext): boolean {
|
|
388
|
+
if (!config.userAttributes || !context.userAttributes) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return config.userAttributes.every((rule) => this.evaluateAttributeRule(rule, context));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private evaluateAttributeRule(rule: UserAttributeRule, context: FlagEvaluationContext): boolean {
|
|
396
|
+
const userValue = context.userAttributes?.[rule.attribute];
|
|
397
|
+
|
|
398
|
+
if (userValue === undefined) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
switch (rule.operator) {
|
|
403
|
+
case 'eq':
|
|
404
|
+
return userValue === rule.value;
|
|
405
|
+
case 'ne':
|
|
406
|
+
return userValue !== rule.value;
|
|
407
|
+
case 'in':
|
|
408
|
+
return (
|
|
409
|
+
Array.isArray(rule.value) &&
|
|
410
|
+
(rule.value as (string | number)[]).includes(userValue as string | number)
|
|
411
|
+
);
|
|
412
|
+
case 'nin':
|
|
413
|
+
return (
|
|
414
|
+
Array.isArray(rule.value) &&
|
|
415
|
+
!(rule.value as (string | number)[]).includes(userValue as string | number)
|
|
416
|
+
);
|
|
417
|
+
case 'gt':
|
|
418
|
+
return typeof userValue === 'number' && userValue > (rule.value as number);
|
|
419
|
+
case 'gte':
|
|
420
|
+
return typeof userValue === 'number' && userValue >= (rule.value as number);
|
|
421
|
+
case 'lt':
|
|
422
|
+
return typeof userValue === 'number' && userValue < (rule.value as number);
|
|
423
|
+
case 'lte':
|
|
424
|
+
return typeof userValue === 'number' && userValue <= (rule.value as number);
|
|
425
|
+
case 'contains':
|
|
426
|
+
return typeof userValue === 'string' && userValue.includes(String(rule.value));
|
|
427
|
+
case 'starts-with':
|
|
428
|
+
return typeof userValue === 'string' && userValue.startsWith(String(rule.value));
|
|
429
|
+
case 'ends-with':
|
|
430
|
+
return typeof userValue === 'string' && userValue.endsWith(String(rule.value));
|
|
431
|
+
default:
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Evaluate date range
|
|
438
|
+
*/
|
|
439
|
+
private evaluateDateRange(config: FlagConfig): boolean {
|
|
440
|
+
if (!config.dateRange) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const now = new Date();
|
|
445
|
+
const start = new Date(config.dateRange.start);
|
|
446
|
+
const end = new Date(config.dateRange.end);
|
|
447
|
+
|
|
448
|
+
return now >= start && now <= end;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Initialize stats for a flag
|
|
453
|
+
*/
|
|
454
|
+
private async initializeStats(key: string): Promise<void> {
|
|
455
|
+
const stats: FlagStats = {
|
|
456
|
+
totalEvaluations: 0,
|
|
457
|
+
enabledCount: 0,
|
|
458
|
+
disabledCount: 0,
|
|
459
|
+
uniqueUsers: 0,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const redis = getRedis();
|
|
463
|
+
await redis.setex(`${FLAG_STATS_PREFIX}${key}`, FLAG_STATS_TTL, JSON.stringify(stats));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Track evaluation statistics
|
|
468
|
+
*/
|
|
469
|
+
private async trackEvaluation(
|
|
470
|
+
key: string,
|
|
471
|
+
enabled: boolean,
|
|
472
|
+
context: FlagEvaluationContext
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
if (!this.config.analytics) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const redis = getRedis();
|
|
480
|
+
const statsJson = await redis.get(`${FLAG_STATS_PREFIX}${key}`);
|
|
481
|
+
|
|
482
|
+
const stats: FlagStats = statsJson
|
|
483
|
+
? JSON.parse(statsJson)
|
|
484
|
+
: {
|
|
485
|
+
totalEvaluations: 0,
|
|
486
|
+
enabledCount: 0,
|
|
487
|
+
disabledCount: 0,
|
|
488
|
+
uniqueUsers: 0,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
stats.totalEvaluations++;
|
|
492
|
+
if (enabled) {
|
|
493
|
+
stats.enabledCount++;
|
|
494
|
+
} else {
|
|
495
|
+
stats.disabledCount++;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (context.userId) {
|
|
499
|
+
stats.uniqueUsers++;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
stats.lastEvaluatedAt = new Date();
|
|
503
|
+
|
|
504
|
+
await redis.setex(`${FLAG_STATS_PREFIX}${key}`, FLAG_STATS_TTL, JSON.stringify(stats));
|
|
505
|
+
} catch (error) {
|
|
506
|
+
logger.warn({ err: error, key }, 'Failed to track flag evaluation');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Create evaluation result
|
|
512
|
+
*/
|
|
513
|
+
private createResult(
|
|
514
|
+
key: string,
|
|
515
|
+
enabled: boolean,
|
|
516
|
+
reason: string,
|
|
517
|
+
strategy: FeatureFlag['strategy']
|
|
518
|
+
): FlagEvaluationResult {
|
|
519
|
+
return {
|
|
520
|
+
key,
|
|
521
|
+
enabled,
|
|
522
|
+
reason,
|
|
523
|
+
strategy,
|
|
524
|
+
evaluatedAt: new Date(),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Log event
|
|
530
|
+
*/
|
|
531
|
+
private logEvent(event: FlagEvent): void {
|
|
532
|
+
this.events.push(event);
|
|
533
|
+
|
|
534
|
+
// Keep only last 1000 events (circular buffer)
|
|
535
|
+
if (this.events.length > 1000) {
|
|
536
|
+
this.events.shift();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Simple string hashing for consistent bucketing
|
|
542
|
+
*/
|
|
543
|
+
private hashString(str: string): number {
|
|
544
|
+
let hash = 0;
|
|
545
|
+
for (let i = 0; i < str.length; i++) {
|
|
546
|
+
const char = str.charCodeAt(i);
|
|
547
|
+
hash = (hash << 5) - hash + char;
|
|
548
|
+
hash = hash & hash;
|
|
549
|
+
}
|
|
550
|
+
return Math.abs(hash);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let featureFlagService: FeatureFlagService | null = null;
|
|
555
|
+
|
|
556
|
+
export function getFeatureFlagService(): FeatureFlagService {
|
|
557
|
+
if (!featureFlagService) {
|
|
558
|
+
featureFlagService = new FeatureFlagService();
|
|
559
|
+
}
|
|
560
|
+
return featureFlagService;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function createFeatureFlagService(config?: FeatureFlagConfig): FeatureFlagService {
|
|
564
|
+
featureFlagService = new FeatureFlagService(config);
|
|
565
|
+
return featureFlagService;
|
|
566
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { FeatureFlagService } from './feature-flag.service.js';
|
|
2
|
+
export { createFeatureFlagRoutes } from './feature-flag.routes.js';
|
|
3
|
+
export type {
|
|
4
|
+
FeatureFlag,
|
|
5
|
+
FlagStatus,
|
|
6
|
+
FlagStrategy,
|
|
7
|
+
FlagEnvironment,
|
|
8
|
+
FlagConfig,
|
|
9
|
+
UserAttributeRule,
|
|
10
|
+
FlagEvaluationContext,
|
|
11
|
+
FlagEvaluationResult,
|
|
12
|
+
FlagVariant,
|
|
13
|
+
FlagStats,
|
|
14
|
+
FlagOverride,
|
|
15
|
+
FlagEvent,
|
|
16
|
+
FeatureFlagConfig,
|
|
17
|
+
FlagListFilters,
|
|
18
|
+
ABTestConfig,
|
|
19
|
+
ABTestResult,
|
|
20
|
+
} from './types.js';
|