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.
Files changed (217) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/LICENSE +21 -0
  9. package/README.md +1102 -1
  10. package/dist/cli/index.cjs +2026 -2168
  11. package/dist/cli/index.cjs.map +1 -1
  12. package/dist/cli/index.js +2026 -2168
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/index.cjs +595 -616
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +114 -52
  17. package/dist/index.d.ts +114 -52
  18. package/dist/index.js +595 -616
  19. package/dist/index.js.map +1 -1
  20. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  21. package/docs/DATABASE_MULTI_ORM.md +399 -0
  22. package/docs/PHASE1_BREAKDOWN.md +346 -0
  23. package/docs/PROGRESS.md +550 -0
  24. package/docs/modules/ANALYTICS.md +226 -0
  25. package/docs/modules/API-VERSIONING.md +252 -0
  26. package/docs/modules/AUDIT.md +192 -0
  27. package/docs/modules/AUTH.md +431 -0
  28. package/docs/modules/CACHE.md +346 -0
  29. package/docs/modules/EMAIL.md +254 -0
  30. package/docs/modules/FEATURE-FLAG.md +291 -0
  31. package/docs/modules/I18N.md +294 -0
  32. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  33. package/docs/modules/MFA.md +266 -0
  34. package/docs/modules/NOTIFICATION.md +311 -0
  35. package/docs/modules/OAUTH.md +237 -0
  36. package/docs/modules/PAYMENT.md +804 -0
  37. package/docs/modules/QUEUE.md +540 -0
  38. package/docs/modules/RATE-LIMIT.md +339 -0
  39. package/docs/modules/SEARCH.md +288 -0
  40. package/docs/modules/SECURITY.md +327 -0
  41. package/docs/modules/SESSION.md +382 -0
  42. package/docs/modules/SWAGGER.md +305 -0
  43. package/docs/modules/UPLOAD.md +296 -0
  44. package/docs/modules/USER.md +505 -0
  45. package/docs/modules/VALIDATION.md +294 -0
  46. package/docs/modules/WEBHOOK.md +270 -0
  47. package/docs/modules/WEBSOCKET.md +691 -0
  48. package/package.json +53 -38
  49. package/prisma/schema.prisma +395 -1
  50. package/src/cli/commands/add-module.ts +520 -87
  51. package/src/cli/commands/db.ts +3 -4
  52. package/src/cli/commands/docs.ts +256 -6
  53. package/src/cli/commands/generate.ts +12 -19
  54. package/src/cli/commands/init.ts +384 -214
  55. package/src/cli/index.ts +0 -4
  56. package/src/cli/templates/repository.ts +6 -1
  57. package/src/cli/templates/routes.ts +6 -21
  58. package/src/cli/utils/docs-generator.ts +6 -7
  59. package/src/cli/utils/env-manager.ts +717 -0
  60. package/src/cli/utils/field-parser.ts +16 -7
  61. package/src/cli/utils/interactive-prompt.ts +223 -0
  62. package/src/cli/utils/template-manager.ts +346 -0
  63. package/src/config/database.config.ts +183 -0
  64. package/src/config/env.ts +0 -10
  65. package/src/config/index.ts +0 -14
  66. package/src/core/server.ts +1 -1
  67. package/src/database/adapters/mongoose.adapter.ts +132 -0
  68. package/src/database/adapters/prisma.adapter.ts +118 -0
  69. package/src/database/connection.ts +190 -0
  70. package/src/database/interfaces/database.interface.ts +85 -0
  71. package/src/database/interfaces/index.ts +7 -0
  72. package/src/database/interfaces/repository.interface.ts +129 -0
  73. package/src/database/models/mongoose/index.ts +7 -0
  74. package/src/database/models/mongoose/payment.schema.ts +347 -0
  75. package/src/database/models/mongoose/user.schema.ts +154 -0
  76. package/src/database/prisma.ts +1 -4
  77. package/src/database/redis.ts +101 -0
  78. package/src/database/repositories/mongoose/index.ts +7 -0
  79. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  80. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  81. package/src/database/seed.ts +6 -1
  82. package/src/index.ts +9 -20
  83. package/src/middleware/security.ts +2 -6
  84. package/src/modules/analytics/analytics.routes.ts +80 -0
  85. package/src/modules/analytics/analytics.service.ts +364 -0
  86. package/src/modules/analytics/index.ts +18 -0
  87. package/src/modules/analytics/types.ts +180 -0
  88. package/src/modules/api-versioning/index.ts +15 -0
  89. package/src/modules/api-versioning/types.ts +86 -0
  90. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  91. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  92. package/src/modules/api-versioning/versioning.service.ts +189 -0
  93. package/src/modules/audit/audit.repository.ts +206 -0
  94. package/src/modules/audit/audit.service.ts +27 -59
  95. package/src/modules/auth/auth.controller.ts +2 -2
  96. package/src/modules/auth/auth.middleware.ts +3 -9
  97. package/src/modules/auth/auth.routes.ts +10 -107
  98. package/src/modules/auth/auth.service.ts +126 -23
  99. package/src/modules/auth/index.ts +3 -4
  100. package/src/modules/cache/cache.service.ts +367 -0
  101. package/src/modules/cache/index.ts +10 -0
  102. package/src/modules/cache/types.ts +44 -0
  103. package/src/modules/email/email.service.ts +3 -10
  104. package/src/modules/email/templates.ts +2 -8
  105. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  106. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  107. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  108. package/src/modules/feature-flag/index.ts +20 -0
  109. package/src/modules/feature-flag/types.ts +192 -0
  110. package/src/modules/i18n/i18n.middleware.ts +186 -0
  111. package/src/modules/i18n/i18n.routes.ts +191 -0
  112. package/src/modules/i18n/i18n.service.ts +456 -0
  113. package/src/modules/i18n/index.ts +18 -0
  114. package/src/modules/i18n/types.ts +118 -0
  115. package/src/modules/media-processing/index.ts +17 -0
  116. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  117. package/src/modules/media-processing/media-processing.service.ts +245 -0
  118. package/src/modules/media-processing/types.ts +156 -0
  119. package/src/modules/mfa/index.ts +20 -0
  120. package/src/modules/mfa/mfa.repository.ts +206 -0
  121. package/src/modules/mfa/mfa.routes.ts +595 -0
  122. package/src/modules/mfa/mfa.service.ts +572 -0
  123. package/src/modules/mfa/totp.ts +150 -0
  124. package/src/modules/mfa/types.ts +57 -0
  125. package/src/modules/notification/index.ts +20 -0
  126. package/src/modules/notification/notification.repository.ts +356 -0
  127. package/src/modules/notification/notification.service.ts +483 -0
  128. package/src/modules/notification/types.ts +119 -0
  129. package/src/modules/oauth/index.ts +20 -0
  130. package/src/modules/oauth/oauth.repository.ts +219 -0
  131. package/src/modules/oauth/oauth.routes.ts +446 -0
  132. package/src/modules/oauth/oauth.service.ts +293 -0
  133. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  134. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  135. package/src/modules/oauth/providers/github.provider.ts +248 -0
  136. package/src/modules/oauth/providers/google.provider.ts +189 -0
  137. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  138. package/src/modules/oauth/types.ts +94 -0
  139. package/src/modules/payment/index.ts +19 -0
  140. package/src/modules/payment/payment.repository.ts +733 -0
  141. package/src/modules/payment/payment.routes.ts +390 -0
  142. package/src/modules/payment/payment.service.ts +354 -0
  143. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  144. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  145. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  146. package/src/modules/payment/types.ts +140 -0
  147. package/src/modules/queue/cron.ts +438 -0
  148. package/src/modules/queue/index.ts +87 -0
  149. package/src/modules/queue/queue.routes.ts +600 -0
  150. package/src/modules/queue/queue.service.ts +842 -0
  151. package/src/modules/queue/types.ts +222 -0
  152. package/src/modules/queue/workers.ts +366 -0
  153. package/src/modules/rate-limit/index.ts +59 -0
  154. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  155. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  156. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  157. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  158. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  159. package/src/modules/rate-limit/types.ts +153 -0
  160. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  161. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  162. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  163. package/src/modules/search/index.ts +21 -0
  164. package/src/modules/search/search.service.ts +234 -0
  165. package/src/modules/search/types.ts +214 -0
  166. package/src/modules/security/index.ts +40 -0
  167. package/src/modules/security/sanitize.ts +223 -0
  168. package/src/modules/security/security-audit.service.ts +388 -0
  169. package/src/modules/security/security.middleware.ts +398 -0
  170. package/src/modules/session/index.ts +3 -0
  171. package/src/modules/session/session.repository.ts +159 -0
  172. package/src/modules/session/session.service.ts +340 -0
  173. package/src/modules/session/types.ts +38 -0
  174. package/src/modules/swagger/index.ts +7 -1
  175. package/src/modules/swagger/schema-builder.ts +16 -4
  176. package/src/modules/swagger/swagger.service.ts +9 -10
  177. package/src/modules/swagger/types.ts +0 -2
  178. package/src/modules/upload/index.ts +14 -0
  179. package/src/modules/upload/types.ts +83 -0
  180. package/src/modules/upload/upload.repository.ts +199 -0
  181. package/src/modules/upload/upload.routes.ts +311 -0
  182. package/src/modules/upload/upload.service.ts +448 -0
  183. package/src/modules/user/index.ts +3 -3
  184. package/src/modules/user/user.controller.ts +15 -9
  185. package/src/modules/user/user.repository.ts +237 -113
  186. package/src/modules/user/user.routes.ts +39 -164
  187. package/src/modules/user/user.service.ts +4 -3
  188. package/src/modules/validation/validator.ts +12 -17
  189. package/src/modules/webhook/index.ts +91 -0
  190. package/src/modules/webhook/retry.ts +196 -0
  191. package/src/modules/webhook/signature.ts +135 -0
  192. package/src/modules/webhook/types.ts +181 -0
  193. package/src/modules/webhook/webhook.repository.ts +358 -0
  194. package/src/modules/webhook/webhook.routes.ts +442 -0
  195. package/src/modules/webhook/webhook.service.ts +457 -0
  196. package/src/modules/websocket/features.ts +504 -0
  197. package/src/modules/websocket/index.ts +106 -0
  198. package/src/modules/websocket/middlewares.ts +298 -0
  199. package/src/modules/websocket/types.ts +181 -0
  200. package/src/modules/websocket/websocket.service.ts +692 -0
  201. package/src/utils/errors.ts +7 -0
  202. package/src/utils/pagination.ts +4 -1
  203. package/tests/helpers/db-check.ts +79 -0
  204. package/tests/integration/auth-redis.test.ts +94 -0
  205. package/tests/integration/cache-redis.test.ts +387 -0
  206. package/tests/integration/mongoose-repositories.test.ts +410 -0
  207. package/tests/integration/payment-prisma.test.ts +637 -0
  208. package/tests/integration/queue-bullmq.test.ts +417 -0
  209. package/tests/integration/user-prisma.test.ts +441 -0
  210. package/tests/integration/websocket-socketio.test.ts +552 -0
  211. package/tests/setup.ts +11 -9
  212. package/vitest.config.ts +3 -8
  213. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  216. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  217. 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';