@strav/flag 0.4.31 → 1.0.0-alpha.42
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 +97 -31
- package/package.json +34 -21
- package/src/console/flag_activate.ts +60 -0
- package/src/console/flag_console_provider.ts +25 -0
- package/src/console/flag_deactivate.ts +37 -0
- package/src/console/flag_list.ts +32 -0
- package/src/console/flag_purge.ts +38 -0
- package/src/console/index.ts +5 -0
- package/src/drivers/memory/index.ts +1 -0
- package/src/drivers/memory/memory_flag_store.ts +94 -0
- package/src/drivers/postgres/apply_flag_migration.ts +32 -0
- package/src/drivers/postgres/index.ts +10 -0
- package/src/drivers/postgres/postgres_flag_provider.ts +49 -0
- package/src/drivers/postgres/postgres_flag_store.ts +132 -0
- package/src/feature_store.ts +17 -10
- package/src/flag_error.ts +29 -0
- package/src/flag_manager.ts +205 -272
- package/src/flag_provider.ts +43 -19
- package/src/http/ensure_feature.ts +56 -0
- package/src/http/index.ts +1 -0
- package/src/index.ts +28 -38
- package/src/pending_scope.ts +26 -14
- package/src/types.ts +60 -36
- package/src/commands/flag_commands.ts +0 -99
- package/src/drivers/array_driver.ts +0 -79
- package/src/drivers/database_driver.ts +0 -139
- package/src/errors.ts +0 -25
- package/src/helpers.ts +0 -109
- package/src/middleware/ensure_feature.ts +0 -36
- package/stubs/config/flag.ts +0 -16
- package/tsconfig.json +0 -5
package/src/flag_manager.ts
CHANGED
|
@@ -1,75 +1,101 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
59
|
+
// ─── Driver access ─────────────────────────────────────────────────
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
//
|
|
66
|
+
// ─── Feature definitions ───────────────────────────────────────────
|
|
50
67
|
|
|
51
|
-
|
|
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
|
|
54
|
-
|
|
77
|
+
const fixed = resolver
|
|
78
|
+
this.definitions.set(name, () => fixed)
|
|
55
79
|
} else {
|
|
56
|
-
|
|
80
|
+
this.definitions.set(name, resolver)
|
|
57
81
|
}
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
|
|
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
|
-
|
|
87
|
+
this.classFeatures.set(key, feature)
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
return [...
|
|
90
|
+
/** Names of every registered feature (closure + class). */
|
|
91
|
+
defined(): string[] {
|
|
92
|
+
return [...this.definitions.keys(), ...this.classFeatures.keys()]
|
|
68
93
|
}
|
|
69
94
|
|
|
70
|
-
//
|
|
95
|
+
// ─── Scope helpers ─────────────────────────────────────────────────
|
|
71
96
|
|
|
72
|
-
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
91
|
-
|
|
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
|
|
114
|
+
return this.serializeScope(scope)
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
//
|
|
117
|
+
// ─── Core resolution ───────────────────────────────────────────────
|
|
101
118
|
|
|
102
|
-
|
|
103
|
-
const scopeKey =
|
|
104
|
-
const cacheKey =
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
return FlagManager._cache.get(cacheKey)
|
|
123
|
+
if (this.cache.has(cacheKey)) {
|
|
124
|
+
return this.cache.get(cacheKey)
|
|
109
125
|
}
|
|
110
126
|
|
|
111
|
-
|
|
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
|
-
|
|
129
|
+
this.cache.set(cacheKey, stored)
|
|
116
130
|
return stored
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
return Boolean(await
|
|
144
|
+
async active(feature: string, scope?: Scopeable | null): Promise<boolean> {
|
|
145
|
+
return Boolean(await this.value(feature, scope))
|
|
133
146
|
}
|
|
134
147
|
|
|
135
|
-
|
|
136
|
-
return !(await
|
|
148
|
+
async inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
|
|
149
|
+
return !(await this.active(feature, scope))
|
|
137
150
|
}
|
|
138
151
|
|
|
139
|
-
|
|
152
|
+
async when<A, I>(
|
|
140
153
|
feature: string,
|
|
141
|
-
onActive: (value: unknown) =>
|
|
142
|
-
onInactive: () =>
|
|
143
|
-
scope?: Scopeable | null
|
|
144
|
-
): Promise<
|
|
145
|
-
const
|
|
146
|
-
return
|
|
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
|
-
//
|
|
162
|
+
// ─── Scoped API ────────────────────────────────────────────────────
|
|
150
163
|
|
|
151
|
-
|
|
152
|
-
return new PendingScopedFeature(scope)
|
|
164
|
+
for(scope: Scopeable): PendingScopedFeature {
|
|
165
|
+
return new PendingScopedFeature(this, scope)
|
|
153
166
|
}
|
|
154
167
|
|
|
155
|
-
//
|
|
168
|
+
// ─── Manual activation ─────────────────────────────────────────────
|
|
156
169
|
|
|
157
170
|
/**
|
|
158
171
|
* Turn a flag on (or assign a rich value).
|
|
159
172
|
*
|
|
160
|
-
* Pass `actor` to
|
|
161
|
-
*
|
|
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
|
-
|
|
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 =
|
|
182
|
+
const scopeKey = this.serializeScope(scope)
|
|
172
183
|
const resolved = value !== undefined ? value : true
|
|
173
|
-
const previous = await
|
|
174
|
-
await
|
|
175
|
-
|
|
176
|
-
await
|
|
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 =
|
|
196
|
-
const previous = await
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
await
|
|
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
|
-
|
|
209
|
-
feature
|
|
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
|
-
|
|
217
|
-
feature
|
|
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
|
-
//
|
|
222
|
+
// ─── Batch ─────────────────────────────────────────────────────────
|
|
224
223
|
|
|
225
|
-
|
|
224
|
+
async values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
|
|
226
225
|
const result = new Map<string, unknown>()
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
|
|
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 =
|
|
236
|
-
if (
|
|
237
|
-
result.set(f,
|
|
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
|
-
|
|
246
|
-
const
|
|
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
|
|
252
|
-
result.set(f,
|
|
253
|
-
|
|
248
|
+
const v = stored.get(f)
|
|
249
|
+
result.set(f, v)
|
|
250
|
+
this.cache.set(this.cacheKey(f, scopeKey), v)
|
|
254
251
|
} else {
|
|
255
|
-
|
|
252
|
+
unresolved.push(f)
|
|
256
253
|
}
|
|
257
254
|
}
|
|
258
255
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
/**
|
|
272
|
-
|
|
273
|
-
return
|
|
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
|
-
//
|
|
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 =
|
|
283
|
-
const stored = await store.getMany(features, scopeKey)
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
293
|
-
await store.set(f, scopeKey,
|
|
294
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
319
|
-
await
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
//
|
|
323
|
+
// ─── Private ───────────────────────────────────────────────────────
|
|
374
324
|
|
|
375
|
-
private
|
|
325
|
+
private cacheKey(feature: string, scope: ScopeKey): string {
|
|
376
326
|
return `${feature}\0${scope}`
|
|
377
327
|
}
|
|
378
328
|
|
|
379
|
-
private
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
392
|
-
|
|
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
|
|