@strav/flag 0.4.31 → 1.0.0-alpha.43

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.
@@ -1,75 +1,101 @@
1
- import { inject, Configuration, Emitter, ConfigurationError } from '@strav/kernel'
2
- import { Database } from '@strav/database'
3
- import type {
4
- FlagConfig,
5
- DriverConfig,
6
- FeatureResolver,
7
- FeatureClassConstructor,
8
- FlagActor,
9
- ScopeKey,
10
- Scopeable,
11
- } from './types.ts'
12
- import { GLOBAL_SCOPE } from './types.ts'
1
+ /**
2
+ * `FlagManager` the primary `@strav/flag` surface. Instances are
3
+ * resolved from the container; static state would defeat per-app
4
+ * isolation (test parallelism, multi-tenant test suites). Same shape
5
+ * as `Cache` / `Broadcaster` — singleton-via-container, not
6
+ * singleton-via-module.
7
+ *
8
+ * Resolution flow for `value(feature, scope)`:
9
+ * 1. Check the in-process cache (`feature\0scope` key).
10
+ * 2. Check the persistent `FeatureStore`. Cache + return on hit.
11
+ * 3. Run the registered resolver (closure or class). Persist the
12
+ * result so subsequent reads short-circuit at step 2.
13
+ *
14
+ * Resolver execution is one-shot: the FIRST evaluation against a
15
+ * given scope persists, and subsequent reads of the same `(feature,
16
+ * scope)` return the stored value verbatim — even if the resolver's
17
+ * logic later changes. That's deliberate (rollouts must be stable);
18
+ * apps that want re-evaluation should `forget(feature, scope)` or
19
+ * `purge(feature)` after the resolver change.
20
+ */
21
+
22
+ import type { EventBus } from '@strav/kernel'
13
23
  import type { FeatureStore } from './feature_store.ts'
14
- import { DatabaseDriver } from './drivers/database_driver.ts'
15
- import { ArrayDriver } from './drivers/array_driver.ts'
16
- import { FeatureNotDefinedError, MissingScopeError } from './errors.ts'
17
- import PendingScopedFeature from './pending_scope.ts'
18
-
19
- @inject
20
- export default class FlagManager {
21
- private static _config: FlagConfig
22
- private static _db: Database
23
- private static _stores = new Map<string, FeatureStore>()
24
- private static _extensions = new Map<string, (config: DriverConfig) => FeatureStore>()
25
- private static _definitions = new Map<string, FeatureResolver>()
26
- private static _classFeatures = new Map<string, FeatureClassConstructor>()
27
- private static _cache = new Map<string, unknown>()
28
-
29
- constructor(db: Database, config: Configuration) {
30
- FlagManager._db = db
31
- FlagManager._config = {
32
- default: config.get('flag.default', 'database') as string,
33
- drivers: config.get('flag.drivers', {}) as Record<string, DriverConfig>,
34
- strictScopes: config.get('flag.strictScopes', false) as boolean,
35
- }
24
+ import { FeatureNotDefinedError, MissingScopeError } from './flag_error.ts'
25
+ import { PendingScopedFeature } from './pending_scope.ts'
26
+ import {
27
+ type FeatureClassConstructor,
28
+ type FeatureResolver,
29
+ type FlagActor,
30
+ type FlagConfig,
31
+ type FlagDeletedEvent,
32
+ type FlagResolvedEvent,
33
+ type FlagUpdatedEvent,
34
+ GLOBAL_SCOPE,
35
+ type Scopeable,
36
+ type ScopeKey,
37
+ } from './types.ts'
38
+
39
+ export interface FlagManagerOptions {
40
+ store: FeatureStore
41
+ events?: EventBus
42
+ config?: FlagConfig
43
+ }
44
+
45
+ export class FlagManager {
46
+ private readonly store: FeatureStore
47
+ private readonly events: EventBus | undefined
48
+ private readonly config: FlagConfig
49
+ private readonly definitions = new Map<string, FeatureResolver>()
50
+ private readonly classFeatures = new Map<string, FeatureClassConstructor>()
51
+ private readonly cache = new Map<string, unknown>()
52
+
53
+ constructor(options: FlagManagerOptions) {
54
+ this.store = options.store
55
+ this.events = options.events
56
+ this.config = options.config ?? {}
36
57
  }
37
58
 
38
- // ── Configuration ──────────────────────────────────────────────────
59
+ // ─── Driver access ─────────────────────────────────────────────────
39
60
 
40
- static get config(): FlagConfig {
41
- if (!FlagManager._config) {
42
- throw new ConfigurationError(
43
- 'FlagManager not configured. Resolve it through the container first.'
44
- )
45
- }
46
- return FlagManager._config
61
+ /** The underlying store. Useful for migrations / diagnostics. */
62
+ driver(): FeatureStore {
63
+ return this.store
47
64
  }
48
65
 
49
- // ── Feature definitions ────────────────────────────────────────────
66
+ // ─── Feature definitions ───────────────────────────────────────────
50
67
 
51
- static define(name: string, resolver: FeatureResolver | boolean): void {
68
+ /**
69
+ * Register a feature.
70
+ *
71
+ * flags.define('beta-ui', true) // always on
72
+ * flags.define('beta-ui', false) // always off
73
+ * flags.define('beta-ui', (scope) => scope.startsWith('User:42'))
74
+ */
75
+ define(name: string, resolver: FeatureResolver | boolean): void {
52
76
  if (typeof resolver === 'boolean') {
53
- const val = resolver
54
- FlagManager._definitions.set(name, () => val)
77
+ const fixed = resolver
78
+ this.definitions.set(name, () => fixed)
55
79
  } else {
56
- FlagManager._definitions.set(name, resolver)
80
+ this.definitions.set(name, resolver)
57
81
  }
58
82
  }
59
83
 
60
- static defineClass(feature: FeatureClassConstructor): void {
84
+ /** Register a class-based feature. The class is `new`'d per evaluation. */
85
+ defineClass(feature: FeatureClassConstructor): void {
61
86
  const key = feature.key ?? toKebab(feature.name)
62
- FlagManager._classFeatures.set(key, feature)
87
+ this.classFeatures.set(key, feature)
63
88
  }
64
89
 
65
- /** Get all defined feature names (closures + classes). */
66
- static defined(): string[] {
67
- return [...FlagManager._definitions.keys(), ...FlagManager._classFeatures.keys()]
90
+ /** Names of every registered feature (closure + class). */
91
+ defined(): string[] {
92
+ return [...this.definitions.keys(), ...this.classFeatures.keys()]
68
93
  }
69
94
 
70
- // ── Scope helpers ──────────────────────────────────────────────────
95
+ // ─── Scope helpers ─────────────────────────────────────────────────
71
96
 
72
- static serializeScope(scope: Scopeable | null | undefined): ScopeKey {
97
+ /** Serialize a scope to its store/cache key. */
98
+ serializeScope(scope: Scopeable | null | undefined): ScopeKey {
73
99
  if (!scope) return GLOBAL_SCOPE
74
100
  const type =
75
101
  typeof scope.featureScope === 'function' ? scope.featureScope() : scope.constructor.name
@@ -77,103 +103,88 @@ export default class FlagManager {
77
103
  }
78
104
 
79
105
  /**
80
- * Resolve a scope to its key, applying the `strictScopes` policy on
81
- * the READ path. A null/undefined scope under strict mode raises
82
- * `MissingScopeError` so a forgotten request-context lookup doesn't
83
- * silently return the global value.
84
- *
85
- * The WRITE path keeps the loose semantics — `activate(feature)`
86
- * with no scope still writes to the global. Callers that want
87
- * explicit-global writes should prefer `activateForEveryone()` /
88
- * `deactivateForEveryone()`.
106
+ * Resolve a scope on the READ path. If `strictScopes` is enabled and
107
+ * no scope was passed, throw the silent-global-fallback bug is a
108
+ * real production hazard.
89
109
  */
90
- private static resolveScopeStrict(
91
- feature: string,
92
- scope: Scopeable | null | undefined
93
- ): ScopeKey {
94
- if (!scope && FlagManager._config?.strictScopes) {
110
+ private resolveScopeStrict(feature: string, scope: Scopeable | null | undefined): ScopeKey {
111
+ if (!scope && this.config.strictScopes === true) {
95
112
  throw new MissingScopeError(feature)
96
113
  }
97
- return FlagManager.serializeScope(scope)
114
+ return this.serializeScope(scope)
98
115
  }
99
116
 
100
- // ── Core resolution ────────────────────────────────────────────────
117
+ // ─── Core resolution ───────────────────────────────────────────────
101
118
 
102
- static async value(feature: string, scope?: Scopeable | null): Promise<unknown> {
103
- const scopeKey = FlagManager.resolveScopeStrict(feature, scope)
104
- const cacheKey = FlagManager.cacheKey(feature, scopeKey)
119
+ async value(feature: string, scope?: Scopeable | null): Promise<unknown> {
120
+ const scopeKey = this.resolveScopeStrict(feature, scope)
121
+ const cacheKey = this.cacheKey(feature, scopeKey)
105
122
 
106
- // 1. Check in-memory cache
107
- if (FlagManager._cache.has(cacheKey)) {
108
- return FlagManager._cache.get(cacheKey)
123
+ if (this.cache.has(cacheKey)) {
124
+ return this.cache.get(cacheKey)
109
125
  }
110
126
 
111
- // 2. Check store
112
- const store = FlagManager.store()
113
- const stored = await store.get(feature, scopeKey)
127
+ const stored = await this.store.get(feature, scopeKey)
114
128
  if (stored !== undefined) {
115
- FlagManager._cache.set(cacheKey, stored)
129
+ this.cache.set(cacheKey, stored)
116
130
  return stored
117
131
  }
118
132
 
119
- // 3. Resolve
120
- const value = await FlagManager.resolveFeature(feature, scopeKey)
121
-
122
- // 4. Persist
123
- await store.set(feature, scopeKey, value)
124
- FlagManager._cache.set(cacheKey, value)
125
-
126
- await Emitter.emit('flag:resolved', { feature, scope: scopeKey, value })
127
-
128
- return value
133
+ const resolved = await this.resolveFeature(feature, scopeKey)
134
+ await this.store.set(feature, scopeKey, resolved)
135
+ this.cache.set(cacheKey, resolved)
136
+ await this.emit<FlagResolvedEvent>('flag:resolved', {
137
+ feature,
138
+ scope: scopeKey,
139
+ value: resolved,
140
+ })
141
+ return resolved
129
142
  }
130
143
 
131
- static async active(feature: string, scope?: Scopeable | null): Promise<boolean> {
132
- return Boolean(await FlagManager.value(feature, scope))
144
+ async active(feature: string, scope?: Scopeable | null): Promise<boolean> {
145
+ return Boolean(await this.value(feature, scope))
133
146
  }
134
147
 
135
- static async inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
136
- return !(await FlagManager.active(feature, scope))
148
+ async inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
149
+ return !(await this.active(feature, scope))
137
150
  }
138
151
 
139
- static async when<TActive, TInactive>(
152
+ async when<A, I>(
140
153
  feature: string,
141
- onActive: (value: unknown) => TActive | Promise<TActive>,
142
- onInactive: () => TInactive | Promise<TInactive>,
143
- scope?: Scopeable | null
144
- ): Promise<TActive | TInactive> {
145
- const value = await FlagManager.value(feature, scope)
146
- return value ? onActive(value) : onInactive()
154
+ onActive: (value: unknown) => A | Promise<A>,
155
+ onInactive: () => I | Promise<I>,
156
+ scope?: Scopeable | null,
157
+ ): Promise<A | I> {
158
+ const v = await this.value(feature, scope)
159
+ return v ? onActive(v) : onInactive()
147
160
  }
148
161
 
149
- // ── Scoped API ─────────────────────────────────────────────────────
162
+ // ─── Scoped API ────────────────────────────────────────────────────
150
163
 
151
- static for(scope: Scopeable): PendingScopedFeature {
152
- return new PendingScopedFeature(scope)
164
+ for(scope: Scopeable): PendingScopedFeature {
165
+ return new PendingScopedFeature(this, scope)
153
166
  }
154
167
 
155
- // ── Manual activation/deactivation ─────────────────────────────────
168
+ // ─── Manual activation ─────────────────────────────────────────────
156
169
 
157
170
  /**
158
171
  * Turn a flag on (or assign a rich value).
159
172
  *
160
- * Pass `actor` to record who made the change the value is included
161
- * in the `flag:updated` event payload so an audit hook can wire it
162
- * through to `@strav/audit`. See the package CLAUDE.md for the
163
- * recommended one-liner pattern.
173
+ * Pass `actor` to attribute the change in the `flag:updated` event
174
+ * so an audit subscriber can record who flipped what.
164
175
  */
165
- static async activate(
176
+ async activate(
166
177
  feature: string,
167
178
  value?: unknown,
168
179
  scope?: Scopeable | null,
169
- actor?: FlagActor | null
180
+ actor?: FlagActor | null,
170
181
  ): Promise<void> {
171
- const scopeKey = FlagManager.serializeScope(scope)
182
+ const scopeKey = this.serializeScope(scope)
172
183
  const resolved = value !== undefined ? value : true
173
- const previous = await FlagManager.store().get(feature, scopeKey)
174
- await FlagManager.store().set(feature, scopeKey, resolved)
175
- FlagManager._cache.set(FlagManager.cacheKey(feature, scopeKey), resolved)
176
- await Emitter.emit('flag:updated', {
184
+ const previous = await this.store.get(feature, scopeKey)
185
+ await this.store.set(feature, scopeKey, resolved)
186
+ this.cache.set(this.cacheKey(feature, scopeKey), resolved)
187
+ await this.emit<FlagUpdatedEvent>('flag:updated', {
177
188
  feature,
178
189
  scope: scopeKey,
179
190
  value: resolved,
@@ -182,21 +193,16 @@ export default class FlagManager {
182
193
  })
183
194
  }
184
195
 
185
- /**
186
- * Turn a flag off.
187
- *
188
- * Pass `actor` to record who made the change — see {@link activate}.
189
- */
190
- static async deactivate(
196
+ async deactivate(
191
197
  feature: string,
192
198
  scope?: Scopeable | null,
193
- actor?: FlagActor | null
199
+ actor?: FlagActor | null,
194
200
  ): Promise<void> {
195
- const scopeKey = FlagManager.serializeScope(scope)
196
- const previous = await FlagManager.store().get(feature, scopeKey)
197
- await FlagManager.store().set(feature, scopeKey, false)
198
- FlagManager._cache.set(FlagManager.cacheKey(feature, scopeKey), false)
199
- await Emitter.emit('flag:updated', {
201
+ const scopeKey = this.serializeScope(scope)
202
+ const previous = await this.store.get(feature, scopeKey)
203
+ await this.store.set(feature, scopeKey, false)
204
+ this.cache.set(this.cacheKey(feature, scopeKey), false)
205
+ await this.emit<FlagUpdatedEvent>('flag:updated', {
200
206
  feature,
201
207
  scope: scopeKey,
202
208
  value: false,
@@ -205,205 +211,132 @@ export default class FlagManager {
205
211
  })
206
212
  }
207
213
 
208
- static async activateForEveryone(
209
- feature: string,
210
- value?: unknown,
211
- actor?: FlagActor | null
212
- ): Promise<void> {
213
- return FlagManager.activate(feature, value, null, actor)
214
+ activateForEveryone(feature: string, value?: unknown, actor?: FlagActor | null): Promise<void> {
215
+ return this.activate(feature, value, null, actor)
214
216
  }
215
217
 
216
- static async deactivateForEveryone(
217
- feature: string,
218
- actor?: FlagActor | null
219
- ): Promise<void> {
220
- return FlagManager.deactivate(feature, null, actor)
218
+ deactivateForEveryone(feature: string, actor?: FlagActor | null): Promise<void> {
219
+ return this.deactivate(feature, null, actor)
221
220
  }
222
221
 
223
- // ── Batch operations ───────────────────────────────────────────────
222
+ // ─── Batch ─────────────────────────────────────────────────────────
224
223
 
225
- static async values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
224
+ async values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
226
225
  const result = new Map<string, unknown>()
227
- // Use the first feature name in the strict-scope error message — every
228
- // batch call shares the same scope so the missing-scope diagnosis is
229
- // identical regardless of which feature trips the check.
230
- const scopeKey = FlagManager.resolveScopeStrict(features[0] ?? '<batch>', scope)
226
+ if (features.length === 0) return result
227
+
228
+ // strict-scope check uses the first feature name for the error
229
+ // message every entry shares the same scope, the diagnosis is
230
+ // identical regardless of which one trips it.
231
+ const scopeKey = this.resolveScopeStrict(features[0] as string, scope)
231
232
 
232
- // Collect cache hits and misses
233
233
  const misses: string[] = []
234
234
  for (const f of features) {
235
- const ck = FlagManager.cacheKey(f, scopeKey)
236
- if (FlagManager._cache.has(ck)) {
237
- result.set(f, FlagManager._cache.get(ck))
235
+ const ck = this.cacheKey(f, scopeKey)
236
+ if (this.cache.has(ck)) {
237
+ result.set(f, this.cache.get(ck))
238
238
  } else {
239
239
  misses.push(f)
240
240
  }
241
241
  }
242
-
243
242
  if (misses.length === 0) return result
244
243
 
245
- // Check store for remaining
246
- const stored = await FlagManager.store().getMany(misses, scopeKey)
247
- const stillMissing: string[] = []
248
-
244
+ const stored = await this.store.getMany(misses, scopeKey)
245
+ const unresolved: string[] = []
249
246
  for (const f of misses) {
250
247
  if (stored.has(f)) {
251
- const val = stored.get(f)
252
- result.set(f, val)
253
- FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
248
+ const v = stored.get(f)
249
+ result.set(f, v)
250
+ this.cache.set(this.cacheKey(f, scopeKey), v)
254
251
  } else {
255
- stillMissing.push(f)
252
+ unresolved.push(f)
256
253
  }
257
254
  }
258
255
 
259
- // Resolve any that aren't stored yet
260
- for (const f of stillMissing) {
261
- const val = await FlagManager.resolveFeature(f, scopeKey)
262
- await FlagManager.store().set(f, scopeKey, val)
263
- FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
264
- result.set(f, val)
265
- await Emitter.emit('flag:resolved', { feature: f, scope: scopeKey, value: val })
256
+ for (const f of unresolved) {
257
+ const v = await this.resolveFeature(f, scopeKey)
258
+ await this.store.set(f, scopeKey, v)
259
+ this.cache.set(this.cacheKey(f, scopeKey), v)
260
+ result.set(f, v)
261
+ await this.emit<FlagResolvedEvent>('flag:resolved', { feature: f, scope: scopeKey, value: v })
266
262
  }
267
-
268
263
  return result
269
264
  }
270
265
 
271
- /** Get all stored feature names. */
272
- static async stored(): Promise<string[]> {
273
- return FlagManager.store().featureNames()
266
+ /** Distinct names of features that have at least one stored entry. */
267
+ stored(): Promise<string[]> {
268
+ return this.store.featureNames()
274
269
  }
275
270
 
276
- // ── Eager loading ──────────────────────────────────────────────────
277
-
278
- static async load(features: string[], scopes: Scopeable[]): Promise<void> {
279
- const store = FlagManager.store()
271
+ // ─── Eager load ────────────────────────────────────────────────────
280
272
 
273
+ /**
274
+ * Resolve `features × scopes` up-front and prime the cache. Useful
275
+ * at request start when subsequent code paths read several flags.
276
+ */
277
+ async load(features: string[], scopes: Scopeable[]): Promise<void> {
281
278
  for (const scope of scopes) {
282
- const scopeKey = FlagManager.serializeScope(scope)
283
- const stored = await store.getMany(features, scopeKey)
284
-
285
- for (const [f, val] of stored) {
286
- FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
279
+ const scopeKey = this.serializeScope(scope)
280
+ const stored = await this.store.getMany(features, scopeKey)
281
+ for (const [f, v] of stored) {
282
+ this.cache.set(this.cacheKey(f, scopeKey), v)
287
283
  }
288
-
289
- // Resolve any not yet stored
290
284
  for (const f of features) {
291
285
  if (!stored.has(f)) {
292
- const val = await FlagManager.resolveFeature(f, scopeKey)
293
- await store.set(f, scopeKey, val)
294
- FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
286
+ const v = await this.resolveFeature(f, scopeKey)
287
+ await this.store.set(f, scopeKey, v)
288
+ this.cache.set(this.cacheKey(f, scopeKey), v)
295
289
  }
296
290
  }
297
291
  }
298
292
  }
299
293
 
300
- // ── Cleanup ────────────────────────────────────────────────────────
301
-
302
- static async forget(feature: string, scope?: Scopeable | null): Promise<void> {
303
- const scopeKey = FlagManager.resolveScopeStrict(feature, scope)
304
- await FlagManager.store().forget(feature, scopeKey)
305
- FlagManager._cache.delete(FlagManager.cacheKey(feature, scopeKey))
306
- await Emitter.emit('flag:deleted', { feature, scope: scopeKey })
307
- }
294
+ // ─── Cleanup ───────────────────────────────────────────────────────
308
295
 
309
- static async purge(feature: string): Promise<void> {
310
- await FlagManager.store().purge(feature)
311
- // Clear all cache entries for this feature
312
- for (const key of FlagManager._cache.keys()) {
313
- if (key.startsWith(`${feature}\0`)) FlagManager._cache.delete(key)
314
- }
315
- await Emitter.emit('flag:deleted', { feature, scope: '*' })
296
+ async forget(feature: string, scope?: Scopeable | null): Promise<void> {
297
+ const scopeKey = this.resolveScopeStrict(feature, scope)
298
+ await this.store.forget(feature, scopeKey)
299
+ this.cache.delete(this.cacheKey(feature, scopeKey))
300
+ await this.emit<FlagDeletedEvent>('flag:deleted', { feature, scope: scopeKey })
316
301
  }
317
302
 
318
- static async purgeAll(): Promise<void> {
319
- await FlagManager.store().purgeAll()
320
- FlagManager._cache.clear()
321
- await Emitter.emit('flag:deleted', { feature: '*', scope: '*' })
322
- }
323
-
324
- // ── Driver management ──────────────────────────────────────────────
325
-
326
- static store(name?: string): FeatureStore {
327
- const key = name ?? FlagManager.config.default
328
-
329
- let store = FlagManager._stores.get(key)
330
- if (store) return store
331
-
332
- const driverConfig = FlagManager.config.drivers[key]
333
- if (!driverConfig) {
334
- throw new ConfigurationError(`Flag driver "${key}" is not configured.`)
303
+ async purge(feature: string): Promise<void> {
304
+ await this.store.purge(feature)
305
+ const prefix = `${feature}\0`
306
+ for (const key of this.cache.keys()) {
307
+ if (key.startsWith(prefix)) this.cache.delete(key)
335
308
  }
336
-
337
- store = FlagManager.createStore(key, driverConfig)
338
- FlagManager._stores.set(key, store)
339
- return store
340
- }
341
-
342
- static extend(name: string, factory: (config: DriverConfig) => FeatureStore): void {
343
- FlagManager._extensions.set(name, factory)
344
- }
345
-
346
- // ── Cache ──────────────────────────────────────────────────────────
347
-
348
- static flushCache(): void {
349
- FlagManager._cache.clear()
309
+ await this.emit<FlagDeletedEvent>('flag:deleted', { feature, scope: '*' })
350
310
  }
351
311
 
352
- // ── Table setup ────────────────────────────────────────────────────
353
-
354
- static async ensureTables(): Promise<void> {
355
- const store = FlagManager.store()
356
- if (store instanceof DatabaseDriver) {
357
- await store.ensureTable()
358
- }
312
+ async purgeAll(): Promise<void> {
313
+ await this.store.purgeAll()
314
+ this.cache.clear()
315
+ await this.emit<FlagDeletedEvent>('flag:deleted', { feature: '*', scope: '*' })
359
316
  }
360
317
 
361
- // ── Reset ──────────────────────────────────────────────────────────
362
-
363
- static reset(): void {
364
- FlagManager._stores.clear()
365
- FlagManager._extensions.clear()
366
- FlagManager._definitions.clear()
367
- FlagManager._classFeatures.clear()
368
- FlagManager._cache.clear()
369
- FlagManager._config = undefined as any
370
- FlagManager._db = undefined as any
318
+ /** Drop the in-process cache only. The store is untouched. */
319
+ flushCache(): void {
320
+ this.cache.clear()
371
321
  }
372
322
 
373
- // ── Private helpers ────────────────────────────────────────────────
323
+ // ─── Private ───────────────────────────────────────────────────────
374
324
 
375
- private static cacheKey(feature: string, scope: ScopeKey): string {
325
+ private cacheKey(feature: string, scope: ScopeKey): string {
376
326
  return `${feature}\0${scope}`
377
327
  }
378
328
 
379
- private static async resolveFeature(feature: string, scope: ScopeKey): Promise<unknown> {
380
- // Try closure definition first
381
- const resolver = FlagManager._definitions.get(feature)
382
- if (resolver) return resolver(scope)
383
-
384
- // Try class-based definition
385
- const Cls = FlagManager._classFeatures.get(feature)
329
+ private async resolveFeature(feature: string, scope: ScopeKey): Promise<unknown> {
330
+ const fn = this.definitions.get(feature)
331
+ if (fn) return fn(scope)
332
+ const Cls = this.classFeatures.get(feature)
386
333
  if (Cls) return new Cls().resolve(scope)
387
-
388
334
  throw new FeatureNotDefinedError(feature)
389
335
  }
390
336
 
391
- private static createStore(name: string, config: DriverConfig): FeatureStore {
392
- const driverName = config.driver ?? name
393
-
394
- const extension = FlagManager._extensions.get(driverName)
395
- if (extension) return extension(config)
396
-
397
- switch (driverName) {
398
- case 'database':
399
- return new DatabaseDriver(FlagManager._db.sql)
400
- case 'array':
401
- return new ArrayDriver()
402
- default:
403
- throw new ConfigurationError(
404
- `Unknown flag driver "${driverName}". Register it with FlagManager.extend().`
405
- )
406
- }
337
+ private async emit<T>(event: string, payload: T): Promise<void> {
338
+ if (!this.events) return
339
+ await this.events.emit(event, payload)
407
340
  }
408
341
  }
409
342