@stravigor/banner 0.4.4
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/package.json +27 -0
- package/src/banner_manager.ts +354 -0
- package/src/banner_provider.ts +33 -0
- package/src/commands/banner_commands.ts +94 -0
- package/src/drivers/array_driver.ts +77 -0
- package/src/drivers/database_driver.ts +122 -0
- package/src/errors.ts +9 -0
- package/src/feature_store.ts +33 -0
- package/src/helpers.ts +114 -0
- package/src/index.ts +38 -0
- package/src/middleware/ensure_feature.ts +36 -0
- package/src/pending_scope.ts +47 -0
- package/src/types.ts +52 -0
- package/stubs/config/banner.ts +16 -0
- package/tsconfig.json +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/banner",
|
|
3
|
+
"version": "0.4.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Feature flags for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"strav": {
|
|
12
|
+
"commands": "src/commands"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src/",
|
|
16
|
+
"stubs/",
|
|
17
|
+
"package.json",
|
|
18
|
+
"tsconfig.json"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@stravigor/core": "0.4.3"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test tests/",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { inject } from '@stravigor/core/core'
|
|
2
|
+
import Configuration from '@stravigor/core/config/configuration'
|
|
3
|
+
import Database from '@stravigor/core/database/database'
|
|
4
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
5
|
+
import { ConfigurationError } from '@stravigor/core/exceptions/errors'
|
|
6
|
+
import type {
|
|
7
|
+
BannerConfig,
|
|
8
|
+
DriverConfig,
|
|
9
|
+
FeatureResolver,
|
|
10
|
+
FeatureClassConstructor,
|
|
11
|
+
ScopeKey,
|
|
12
|
+
Scopeable,
|
|
13
|
+
} from './types.ts'
|
|
14
|
+
import { GLOBAL_SCOPE } from './types.ts'
|
|
15
|
+
import type { FeatureStore } from './feature_store.ts'
|
|
16
|
+
import { DatabaseDriver } from './drivers/database_driver.ts'
|
|
17
|
+
import { ArrayDriver } from './drivers/array_driver.ts'
|
|
18
|
+
import { FeatureNotDefinedError } from './errors.ts'
|
|
19
|
+
import PendingScopedFeature from './pending_scope.ts'
|
|
20
|
+
|
|
21
|
+
@inject
|
|
22
|
+
export default class BannerManager {
|
|
23
|
+
private static _config: BannerConfig
|
|
24
|
+
private static _db: Database
|
|
25
|
+
private static _stores = new Map<string, FeatureStore>()
|
|
26
|
+
private static _extensions = new Map<string, (config: DriverConfig) => FeatureStore>()
|
|
27
|
+
private static _definitions = new Map<string, FeatureResolver>()
|
|
28
|
+
private static _classFeatures = new Map<string, FeatureClassConstructor>()
|
|
29
|
+
private static _cache = new Map<string, unknown>()
|
|
30
|
+
|
|
31
|
+
constructor(db: Database, config: Configuration) {
|
|
32
|
+
BannerManager._db = db
|
|
33
|
+
BannerManager._config = {
|
|
34
|
+
default: config.get('banner.default', 'database') as string,
|
|
35
|
+
drivers: config.get('banner.drivers', {}) as Record<string, DriverConfig>,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Configuration ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
static get config(): BannerConfig {
|
|
42
|
+
if (!BannerManager._config) {
|
|
43
|
+
throw new ConfigurationError(
|
|
44
|
+
'BannerManager not configured. Resolve it through the container first.'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return BannerManager._config
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Feature definitions ────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
static define(name: string, resolver: FeatureResolver | boolean): void {
|
|
53
|
+
if (typeof resolver === 'boolean') {
|
|
54
|
+
const val = resolver
|
|
55
|
+
BannerManager._definitions.set(name, () => val)
|
|
56
|
+
} else {
|
|
57
|
+
BannerManager._definitions.set(name, resolver)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static defineClass(feature: FeatureClassConstructor): void {
|
|
62
|
+
const key = feature.key ?? toKebab(feature.name)
|
|
63
|
+
BannerManager._classFeatures.set(key, feature)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Get all defined feature names (closures + classes). */
|
|
67
|
+
static defined(): string[] {
|
|
68
|
+
return [
|
|
69
|
+
...BannerManager._definitions.keys(),
|
|
70
|
+
...BannerManager._classFeatures.keys(),
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Scope helpers ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
static serializeScope(scope: Scopeable | null | undefined): ScopeKey {
|
|
77
|
+
if (!scope) return GLOBAL_SCOPE
|
|
78
|
+
const type =
|
|
79
|
+
typeof scope.featureScope === 'function'
|
|
80
|
+
? scope.featureScope()
|
|
81
|
+
: scope.constructor.name
|
|
82
|
+
return `${type}:${scope.id}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Core resolution ────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
static async value(feature: string, scope?: Scopeable | null): Promise<unknown> {
|
|
88
|
+
const scopeKey = BannerManager.serializeScope(scope)
|
|
89
|
+
const cacheKey = BannerManager.cacheKey(feature, scopeKey)
|
|
90
|
+
|
|
91
|
+
// 1. Check in-memory cache
|
|
92
|
+
if (BannerManager._cache.has(cacheKey)) {
|
|
93
|
+
return BannerManager._cache.get(cacheKey)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Check store
|
|
97
|
+
const store = BannerManager.store()
|
|
98
|
+
const stored = await store.get(feature, scopeKey)
|
|
99
|
+
if (stored !== undefined) {
|
|
100
|
+
BannerManager._cache.set(cacheKey, stored)
|
|
101
|
+
return stored
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Resolve
|
|
105
|
+
const value = await BannerManager.resolveFeature(feature, scopeKey)
|
|
106
|
+
|
|
107
|
+
// 4. Persist
|
|
108
|
+
await store.set(feature, scopeKey, value)
|
|
109
|
+
BannerManager._cache.set(cacheKey, value)
|
|
110
|
+
|
|
111
|
+
await Emitter.emit('banner:resolved', { feature, scope: scopeKey, value })
|
|
112
|
+
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static async active(feature: string, scope?: Scopeable | null): Promise<boolean> {
|
|
117
|
+
return Boolean(await BannerManager.value(feature, scope))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static async inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
|
|
121
|
+
return !await BannerManager.active(feature, scope)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static async when<TActive, TInactive>(
|
|
125
|
+
feature: string,
|
|
126
|
+
onActive: (value: unknown) => TActive | Promise<TActive>,
|
|
127
|
+
onInactive: () => TInactive | Promise<TInactive>,
|
|
128
|
+
scope?: Scopeable | null
|
|
129
|
+
): Promise<TActive | TInactive> {
|
|
130
|
+
const value = await BannerManager.value(feature, scope)
|
|
131
|
+
return value ? onActive(value) : onInactive()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Scoped API ─────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
static for(scope: Scopeable): PendingScopedFeature {
|
|
137
|
+
return new PendingScopedFeature(scope)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Manual activation/deactivation ─────────────────────────────────
|
|
141
|
+
|
|
142
|
+
static async activate(feature: string, value?: unknown, scope?: Scopeable | null): Promise<void> {
|
|
143
|
+
const scopeKey = BannerManager.serializeScope(scope)
|
|
144
|
+
const resolved = value !== undefined ? value : true
|
|
145
|
+
await BannerManager.store().set(feature, scopeKey, resolved)
|
|
146
|
+
BannerManager._cache.set(BannerManager.cacheKey(feature, scopeKey), resolved)
|
|
147
|
+
await Emitter.emit('banner:updated', { feature, scope: scopeKey, value: resolved })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static async deactivate(feature: string, scope?: Scopeable | null): Promise<void> {
|
|
151
|
+
const scopeKey = BannerManager.serializeScope(scope)
|
|
152
|
+
await BannerManager.store().set(feature, scopeKey, false)
|
|
153
|
+
BannerManager._cache.set(BannerManager.cacheKey(feature, scopeKey), false)
|
|
154
|
+
await Emitter.emit('banner:updated', { feature, scope: scopeKey, value: false })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
static async activateForEveryone(feature: string, value?: unknown): Promise<void> {
|
|
158
|
+
return BannerManager.activate(feature, value)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static async deactivateForEveryone(feature: string): Promise<void> {
|
|
162
|
+
return BannerManager.deactivate(feature)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Batch operations ───────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
static async values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
|
|
168
|
+
const result = new Map<string, unknown>()
|
|
169
|
+
const scopeKey = BannerManager.serializeScope(scope)
|
|
170
|
+
|
|
171
|
+
// Collect cache hits and misses
|
|
172
|
+
const misses: string[] = []
|
|
173
|
+
for (const f of features) {
|
|
174
|
+
const ck = BannerManager.cacheKey(f, scopeKey)
|
|
175
|
+
if (BannerManager._cache.has(ck)) {
|
|
176
|
+
result.set(f, BannerManager._cache.get(ck))
|
|
177
|
+
} else {
|
|
178
|
+
misses.push(f)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (misses.length === 0) return result
|
|
183
|
+
|
|
184
|
+
// Check store for remaining
|
|
185
|
+
const stored = await BannerManager.store().getMany(misses, scopeKey)
|
|
186
|
+
const stillMissing: string[] = []
|
|
187
|
+
|
|
188
|
+
for (const f of misses) {
|
|
189
|
+
if (stored.has(f)) {
|
|
190
|
+
const val = stored.get(f)
|
|
191
|
+
result.set(f, val)
|
|
192
|
+
BannerManager._cache.set(BannerManager.cacheKey(f, scopeKey), val)
|
|
193
|
+
} else {
|
|
194
|
+
stillMissing.push(f)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Resolve any that aren't stored yet
|
|
199
|
+
for (const f of stillMissing) {
|
|
200
|
+
const val = await BannerManager.resolveFeature(f, scopeKey)
|
|
201
|
+
await BannerManager.store().set(f, scopeKey, val)
|
|
202
|
+
BannerManager._cache.set(BannerManager.cacheKey(f, scopeKey), val)
|
|
203
|
+
result.set(f, val)
|
|
204
|
+
await Emitter.emit('banner:resolved', { feature: f, scope: scopeKey, value: val })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Get all stored feature names. */
|
|
211
|
+
static async stored(): Promise<string[]> {
|
|
212
|
+
return BannerManager.store().featureNames()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Eager loading ──────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
static async load(features: string[], scopes: Scopeable[]): Promise<void> {
|
|
218
|
+
const store = BannerManager.store()
|
|
219
|
+
|
|
220
|
+
for (const scope of scopes) {
|
|
221
|
+
const scopeKey = BannerManager.serializeScope(scope)
|
|
222
|
+
const stored = await store.getMany(features, scopeKey)
|
|
223
|
+
|
|
224
|
+
for (const [f, val] of stored) {
|
|
225
|
+
BannerManager._cache.set(BannerManager.cacheKey(f, scopeKey), val)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Resolve any not yet stored
|
|
229
|
+
for (const f of features) {
|
|
230
|
+
if (!stored.has(f)) {
|
|
231
|
+
const val = await BannerManager.resolveFeature(f, scopeKey)
|
|
232
|
+
await store.set(f, scopeKey, val)
|
|
233
|
+
BannerManager._cache.set(BannerManager.cacheKey(f, scopeKey), val)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Cleanup ────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
static async forget(feature: string, scope?: Scopeable | null): Promise<void> {
|
|
242
|
+
const scopeKey = BannerManager.serializeScope(scope)
|
|
243
|
+
await BannerManager.store().forget(feature, scopeKey)
|
|
244
|
+
BannerManager._cache.delete(BannerManager.cacheKey(feature, scopeKey))
|
|
245
|
+
await Emitter.emit('banner:deleted', { feature, scope: scopeKey })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
static async purge(feature: string): Promise<void> {
|
|
249
|
+
await BannerManager.store().purge(feature)
|
|
250
|
+
// Clear all cache entries for this feature
|
|
251
|
+
for (const key of BannerManager._cache.keys()) {
|
|
252
|
+
if (key.startsWith(`${feature}\0`)) BannerManager._cache.delete(key)
|
|
253
|
+
}
|
|
254
|
+
await Emitter.emit('banner:deleted', { feature, scope: '*' })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static async purgeAll(): Promise<void> {
|
|
258
|
+
await BannerManager.store().purgeAll()
|
|
259
|
+
BannerManager._cache.clear()
|
|
260
|
+
await Emitter.emit('banner:deleted', { feature: '*', scope: '*' })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Driver management ──────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
static store(name?: string): FeatureStore {
|
|
266
|
+
const key = name ?? BannerManager.config.default
|
|
267
|
+
|
|
268
|
+
let store = BannerManager._stores.get(key)
|
|
269
|
+
if (store) return store
|
|
270
|
+
|
|
271
|
+
const driverConfig = BannerManager.config.drivers[key]
|
|
272
|
+
if (!driverConfig) {
|
|
273
|
+
throw new ConfigurationError(`Banner driver "${key}" is not configured.`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
store = BannerManager.createStore(key, driverConfig)
|
|
277
|
+
BannerManager._stores.set(key, store)
|
|
278
|
+
return store
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
static extend(name: string, factory: (config: DriverConfig) => FeatureStore): void {
|
|
282
|
+
BannerManager._extensions.set(name, factory)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Cache ──────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
static flushCache(): void {
|
|
288
|
+
BannerManager._cache.clear()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Table setup ────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
static async ensureTables(): Promise<void> {
|
|
294
|
+
const store = BannerManager.store()
|
|
295
|
+
if (store instanceof DatabaseDriver) {
|
|
296
|
+
await store.ensureTable()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Reset ──────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
static reset(): void {
|
|
303
|
+
BannerManager._stores.clear()
|
|
304
|
+
BannerManager._extensions.clear()
|
|
305
|
+
BannerManager._definitions.clear()
|
|
306
|
+
BannerManager._classFeatures.clear()
|
|
307
|
+
BannerManager._cache.clear()
|
|
308
|
+
BannerManager._config = undefined as any
|
|
309
|
+
BannerManager._db = undefined as any
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Private helpers ────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
private static cacheKey(feature: string, scope: ScopeKey): string {
|
|
315
|
+
return `${feature}\0${scope}`
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private static async resolveFeature(feature: string, scope: ScopeKey): Promise<unknown> {
|
|
319
|
+
// Try closure definition first
|
|
320
|
+
const resolver = BannerManager._definitions.get(feature)
|
|
321
|
+
if (resolver) return resolver(scope)
|
|
322
|
+
|
|
323
|
+
// Try class-based definition
|
|
324
|
+
const Cls = BannerManager._classFeatures.get(feature)
|
|
325
|
+
if (Cls) return new Cls().resolve(scope)
|
|
326
|
+
|
|
327
|
+
throw new FeatureNotDefinedError(feature)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private static createStore(name: string, config: DriverConfig): FeatureStore {
|
|
331
|
+
const driverName = config.driver ?? name
|
|
332
|
+
|
|
333
|
+
const extension = BannerManager._extensions.get(driverName)
|
|
334
|
+
if (extension) return extension(config)
|
|
335
|
+
|
|
336
|
+
switch (driverName) {
|
|
337
|
+
case 'database':
|
|
338
|
+
return new DatabaseDriver(BannerManager._db.sql)
|
|
339
|
+
case 'array':
|
|
340
|
+
return new ArrayDriver()
|
|
341
|
+
default:
|
|
342
|
+
throw new ConfigurationError(
|
|
343
|
+
`Unknown banner driver "${driverName}". Register it with BannerManager.extend().`
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function toKebab(name: string): string {
|
|
350
|
+
return name
|
|
351
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
352
|
+
.replace(/[\s_]+/g, '-')
|
|
353
|
+
.toLowerCase()
|
|
354
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/core/core'
|
|
2
|
+
import type { Application } from '@stravigor/core/core'
|
|
3
|
+
import BannerManager from './banner_manager.ts'
|
|
4
|
+
|
|
5
|
+
export interface BannerProviderOptions {
|
|
6
|
+
/** Auto-create the features table. Default: `true` */
|
|
7
|
+
ensureTables?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default class BannerProvider extends ServiceProvider {
|
|
11
|
+
readonly name = 'banner'
|
|
12
|
+
override readonly dependencies = ['config', 'database']
|
|
13
|
+
|
|
14
|
+
constructor(private options?: BannerProviderOptions) {
|
|
15
|
+
super()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override register(app: Application): void {
|
|
19
|
+
app.singleton(BannerManager)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override async boot(app: Application): Promise<void> {
|
|
23
|
+
app.resolve(BannerManager)
|
|
24
|
+
|
|
25
|
+
if (this.options?.ensureTables !== false) {
|
|
26
|
+
await BannerManager.ensureTables()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override shutdown(): void {
|
|
31
|
+
BannerManager.reset()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { bootstrap, shutdown } from '@stravigor/core/cli/bootstrap'
|
|
4
|
+
import BannerManager from '../banner_manager.ts'
|
|
5
|
+
|
|
6
|
+
export function register(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command('banner:setup')
|
|
9
|
+
.description('Create the feature flag storage table')
|
|
10
|
+
.action(async () => {
|
|
11
|
+
let db
|
|
12
|
+
try {
|
|
13
|
+
const { db: database, config } = await bootstrap()
|
|
14
|
+
db = database
|
|
15
|
+
|
|
16
|
+
new BannerManager(db, config)
|
|
17
|
+
|
|
18
|
+
console.log(chalk.dim('Creating features table...'))
|
|
19
|
+
await BannerManager.ensureTables()
|
|
20
|
+
console.log(chalk.green('Features table created successfully.'))
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
23
|
+
process.exit(1)
|
|
24
|
+
} finally {
|
|
25
|
+
if (db) await shutdown(db)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command('banner:purge [feature]')
|
|
31
|
+
.description('Purge stored feature flag values')
|
|
32
|
+
.option('--all', 'Purge all features')
|
|
33
|
+
.action(async (feature?: string, options?: { all?: boolean }) => {
|
|
34
|
+
let db
|
|
35
|
+
try {
|
|
36
|
+
const { db: database, config } = await bootstrap()
|
|
37
|
+
db = database
|
|
38
|
+
|
|
39
|
+
new BannerManager(db, config)
|
|
40
|
+
|
|
41
|
+
if (options?.all || !feature) {
|
|
42
|
+
console.log(chalk.dim('Purging all feature flags...'))
|
|
43
|
+
await BannerManager.purgeAll()
|
|
44
|
+
console.log(chalk.green('All feature flags purged.'))
|
|
45
|
+
} else {
|
|
46
|
+
console.log(chalk.dim(`Purging feature "${feature}"...`))
|
|
47
|
+
await BannerManager.purge(feature)
|
|
48
|
+
console.log(chalk.green(`Feature "${feature}" purged.`))
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
52
|
+
process.exit(1)
|
|
53
|
+
} finally {
|
|
54
|
+
if (db) await shutdown(db)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
program
|
|
59
|
+
.command('banner:list')
|
|
60
|
+
.description('List all stored feature flags')
|
|
61
|
+
.action(async () => {
|
|
62
|
+
let db
|
|
63
|
+
try {
|
|
64
|
+
const { db: database, config } = await bootstrap()
|
|
65
|
+
db = database
|
|
66
|
+
|
|
67
|
+
new BannerManager(db, config)
|
|
68
|
+
|
|
69
|
+
const names = await BannerManager.stored()
|
|
70
|
+
|
|
71
|
+
if (names.length === 0) {
|
|
72
|
+
console.log(chalk.dim('No stored feature flags.'))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(chalk.bold(`Stored feature flags (${names.length}):\n`))
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const records = await BannerManager.store().allFor(name)
|
|
79
|
+
console.log(` ${chalk.cyan(name)} ${chalk.dim(`(${records.length} scope${records.length === 1 ? '' : 's'})`)}`)
|
|
80
|
+
for (const r of records) {
|
|
81
|
+
const val = typeof r.value === 'boolean'
|
|
82
|
+
? (r.value ? chalk.green('active') : chalk.red('inactive'))
|
|
83
|
+
: chalk.yellow(JSON.stringify(r.value))
|
|
84
|
+
console.log(` ${chalk.dim(r.scope)} → ${val}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
89
|
+
process.exit(1)
|
|
90
|
+
} finally {
|
|
91
|
+
if (db) await shutdown(db)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FeatureStore } from '../feature_store.ts'
|
|
2
|
+
import type { ScopeKey, StoredFeature } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/** In-memory feature store for testing. */
|
|
5
|
+
export class ArrayDriver implements FeatureStore {
|
|
6
|
+
readonly name = 'array'
|
|
7
|
+
private data = new Map<string, { value: unknown; createdAt: Date; updatedAt: Date }>()
|
|
8
|
+
|
|
9
|
+
private key(feature: string, scope: ScopeKey): string {
|
|
10
|
+
return `${feature}\0${scope}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async get(feature: string, scope: ScopeKey): Promise<unknown | undefined> {
|
|
14
|
+
return this.data.get(this.key(feature, scope))?.value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>> {
|
|
18
|
+
const result = new Map<string, unknown>()
|
|
19
|
+
for (const feature of features) {
|
|
20
|
+
const entry = this.data.get(this.key(feature, scope))
|
|
21
|
+
if (entry !== undefined) result.set(feature, entry.value)
|
|
22
|
+
}
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async set(feature: string, scope: ScopeKey, value: unknown): Promise<void> {
|
|
27
|
+
const existing = this.data.get(this.key(feature, scope))
|
|
28
|
+
const now = new Date()
|
|
29
|
+
this.data.set(this.key(feature, scope), {
|
|
30
|
+
value,
|
|
31
|
+
createdAt: existing?.createdAt ?? now,
|
|
32
|
+
updatedAt: now,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async setMany(entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>): Promise<void> {
|
|
37
|
+
for (const e of entries) await this.set(e.feature, e.scope, e.value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async forget(feature: string, scope: ScopeKey): Promise<void> {
|
|
41
|
+
this.data.delete(this.key(feature, scope))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async purge(feature: string): Promise<void> {
|
|
45
|
+
for (const key of this.data.keys()) {
|
|
46
|
+
if (key.startsWith(`${feature}\0`)) this.data.delete(key)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async purgeAll(): Promise<void> {
|
|
51
|
+
this.data.clear()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async featureNames(): Promise<string[]> {
|
|
55
|
+
const names = new Set<string>()
|
|
56
|
+
for (const key of this.data.keys()) {
|
|
57
|
+
names.add(key.split('\0')[0])
|
|
58
|
+
}
|
|
59
|
+
return [...names]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async allFor(feature: string): Promise<StoredFeature[]> {
|
|
63
|
+
const results: StoredFeature[] = []
|
|
64
|
+
for (const [key, entry] of this.data) {
|
|
65
|
+
if (key.startsWith(`${feature}\0`)) {
|
|
66
|
+
results.push({
|
|
67
|
+
feature,
|
|
68
|
+
scope: key.split('\0')[1],
|
|
69
|
+
value: entry.value,
|
|
70
|
+
createdAt: entry.createdAt,
|
|
71
|
+
updatedAt: entry.updatedAt,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return results
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
import type { FeatureStore } from '../feature_store.ts'
|
|
3
|
+
import type { ScopeKey, StoredFeature } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
/** PostgreSQL-backed feature store using `_strav_features`. */
|
|
6
|
+
export class DatabaseDriver implements FeatureStore {
|
|
7
|
+
readonly name = 'database'
|
|
8
|
+
|
|
9
|
+
constructor(private sql: SQL) {}
|
|
10
|
+
|
|
11
|
+
async ensureTable(): Promise<void> {
|
|
12
|
+
await this.sql`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS "_strav_features" (
|
|
14
|
+
"id" BIGSERIAL PRIMARY KEY,
|
|
15
|
+
"feature" VARCHAR(255) NOT NULL,
|
|
16
|
+
"scope" VARCHAR(255) NOT NULL,
|
|
17
|
+
"value" JSONB NOT NULL DEFAULT 'true',
|
|
18
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
19
|
+
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
20
|
+
)
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
await this.sql`
|
|
24
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "idx_strav_features_lookup"
|
|
25
|
+
ON "_strav_features" ("feature", "scope")
|
|
26
|
+
`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get(feature: string, scope: ScopeKey): Promise<unknown | undefined> {
|
|
30
|
+
const rows = await this.sql`
|
|
31
|
+
SELECT "value" FROM "_strav_features"
|
|
32
|
+
WHERE "feature" = ${feature} AND "scope" = ${scope}
|
|
33
|
+
LIMIT 1
|
|
34
|
+
`
|
|
35
|
+
if (rows.length === 0) return undefined
|
|
36
|
+
return parseValue(rows[0].value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>> {
|
|
40
|
+
if (features.length === 0) return new Map()
|
|
41
|
+
|
|
42
|
+
const rows = await this.sql`
|
|
43
|
+
SELECT "feature", "value" FROM "_strav_features"
|
|
44
|
+
WHERE "feature" IN ${this.sql(features)} AND "scope" = ${scope}
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
const result = new Map<string, unknown>()
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
result.set(row.feature as string, parseValue(row.value))
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async set(feature: string, scope: ScopeKey, value: unknown): Promise<void> {
|
|
55
|
+
const jsonValue = JSON.stringify(value)
|
|
56
|
+
await this.sql`
|
|
57
|
+
INSERT INTO "_strav_features" ("feature", "scope", "value", "created_at", "updated_at")
|
|
58
|
+
VALUES (${feature}, ${scope}, ${jsonValue}::jsonb, NOW(), NOW())
|
|
59
|
+
ON CONFLICT ("feature", "scope")
|
|
60
|
+
DO UPDATE SET "value" = ${jsonValue}::jsonb, "updated_at" = NOW()
|
|
61
|
+
`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async setMany(entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>): Promise<void> {
|
|
65
|
+
if (entries.length === 0) return
|
|
66
|
+
for (const e of entries) await this.set(e.feature, e.scope, e.value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async forget(feature: string, scope: ScopeKey): Promise<void> {
|
|
70
|
+
await this.sql`
|
|
71
|
+
DELETE FROM "_strav_features"
|
|
72
|
+
WHERE "feature" = ${feature} AND "scope" = ${scope}
|
|
73
|
+
`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async purge(feature: string): Promise<void> {
|
|
77
|
+
await this.sql`
|
|
78
|
+
DELETE FROM "_strav_features"
|
|
79
|
+
WHERE "feature" = ${feature}
|
|
80
|
+
`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async purgeAll(): Promise<void> {
|
|
84
|
+
await this.sql`DELETE FROM "_strav_features"`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async featureNames(): Promise<string[]> {
|
|
88
|
+
const rows = await this.sql`
|
|
89
|
+
SELECT DISTINCT "feature" FROM "_strav_features"
|
|
90
|
+
ORDER BY "feature"
|
|
91
|
+
`
|
|
92
|
+
return rows.map((r: Record<string, unknown>) => r.feature as string)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async allFor(feature: string): Promise<StoredFeature[]> {
|
|
96
|
+
const rows = await this.sql`
|
|
97
|
+
SELECT "feature", "scope", "value", "created_at", "updated_at"
|
|
98
|
+
FROM "_strav_features"
|
|
99
|
+
WHERE "feature" = ${feature}
|
|
100
|
+
ORDER BY "scope"
|
|
101
|
+
`
|
|
102
|
+
|
|
103
|
+
return rows.map((row: Record<string, unknown>) => ({
|
|
104
|
+
feature: row.feature as string,
|
|
105
|
+
scope: row.scope as ScopeKey,
|
|
106
|
+
value: parseValue(row.value),
|
|
107
|
+
createdAt: row.created_at as Date,
|
|
108
|
+
updatedAt: row.updated_at as Date,
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseValue(raw: unknown): unknown {
|
|
114
|
+
if (typeof raw === 'string') {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(raw)
|
|
117
|
+
} catch {
|
|
118
|
+
return raw
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return raw
|
|
122
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/core/exceptions/strav_error'
|
|
2
|
+
|
|
3
|
+
export class BannerError extends StravError {}
|
|
4
|
+
|
|
5
|
+
export class FeatureNotDefinedError extends BannerError {
|
|
6
|
+
constructor(feature: string) {
|
|
7
|
+
super(`Feature "${feature}" is not defined. Register it with banner.define().`)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ScopeKey, StoredFeature } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/** Contract that every feature flag storage driver must implement. */
|
|
4
|
+
export interface FeatureStore {
|
|
5
|
+
readonly name: string
|
|
6
|
+
|
|
7
|
+
/** Retrieve the stored value. Returns `undefined` if not yet resolved. */
|
|
8
|
+
get(feature: string, scope: ScopeKey): Promise<unknown | undefined>
|
|
9
|
+
|
|
10
|
+
/** Retrieve stored values for multiple features for a single scope. */
|
|
11
|
+
getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>>
|
|
12
|
+
|
|
13
|
+
/** Store a resolved value (upsert). */
|
|
14
|
+
set(feature: string, scope: ScopeKey, value: unknown): Promise<void>
|
|
15
|
+
|
|
16
|
+
/** Store multiple resolved values at once. */
|
|
17
|
+
setMany(entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>): Promise<void>
|
|
18
|
+
|
|
19
|
+
/** Remove the stored value for a feature+scope pair. */
|
|
20
|
+
forget(feature: string, scope: ScopeKey): Promise<void>
|
|
21
|
+
|
|
22
|
+
/** Remove ALL stored values for a feature (all scopes). */
|
|
23
|
+
purge(feature: string): Promise<void>
|
|
24
|
+
|
|
25
|
+
/** Remove all stored values for all features. */
|
|
26
|
+
purgeAll(): Promise<void>
|
|
27
|
+
|
|
28
|
+
/** List all distinct feature names that have stored values. */
|
|
29
|
+
featureNames(): Promise<string[]>
|
|
30
|
+
|
|
31
|
+
/** List all stored records for a feature. */
|
|
32
|
+
allFor(feature: string): Promise<StoredFeature[]>
|
|
33
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import BannerManager from './banner_manager.ts'
|
|
2
|
+
import PendingScopedFeature from './pending_scope.ts'
|
|
3
|
+
import type { FeatureStore } from './feature_store.ts'
|
|
4
|
+
import type {
|
|
5
|
+
Scopeable,
|
|
6
|
+
FeatureResolver,
|
|
7
|
+
FeatureClassConstructor,
|
|
8
|
+
DriverConfig,
|
|
9
|
+
} from './types.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Banner helper — the primary convenience API.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { banner } from '@stravigor/banner'
|
|
16
|
+
*
|
|
17
|
+
* banner.define('new-checkout', (scope) => scope.startsWith('User:'))
|
|
18
|
+
*
|
|
19
|
+
* if (await banner.active('new-checkout')) { ... }
|
|
20
|
+
*/
|
|
21
|
+
export const banner = {
|
|
22
|
+
define(name: string, resolver: FeatureResolver | boolean): void {
|
|
23
|
+
BannerManager.define(name, resolver)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
defineClass(feature: FeatureClassConstructor): void {
|
|
27
|
+
BannerManager.defineClass(feature)
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
active(feature: string, scope?: Scopeable | null): Promise<boolean> {
|
|
31
|
+
return BannerManager.active(feature, scope)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
|
|
35
|
+
return BannerManager.inactive(feature, scope)
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
value(feature: string, scope?: Scopeable | null): Promise<unknown> {
|
|
39
|
+
return BannerManager.value(feature, scope)
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
when<A, I>(
|
|
43
|
+
feature: string,
|
|
44
|
+
onActive: (value: unknown) => A | Promise<A>,
|
|
45
|
+
onInactive: () => I | Promise<I>,
|
|
46
|
+
scope?: Scopeable | null
|
|
47
|
+
): Promise<A | I> {
|
|
48
|
+
return BannerManager.when(feature, onActive, onInactive, scope)
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
for(scope: Scopeable): PendingScopedFeature {
|
|
52
|
+
return BannerManager.for(scope)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
activate(feature: string, value?: unknown, scope?: Scopeable | null): Promise<void> {
|
|
56
|
+
return BannerManager.activate(feature, value, scope)
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
deactivate(feature: string, scope?: Scopeable | null): Promise<void> {
|
|
60
|
+
return BannerManager.deactivate(feature, scope)
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
activateForEveryone(feature: string, value?: unknown): Promise<void> {
|
|
64
|
+
return BannerManager.activateForEveryone(feature, value)
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
deactivateForEveryone(feature: string): Promise<void> {
|
|
68
|
+
return BannerManager.deactivateForEveryone(feature)
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
|
|
72
|
+
return BannerManager.values(features, scope)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
forget(feature: string, scope?: Scopeable | null): Promise<void> {
|
|
76
|
+
return BannerManager.forget(feature, scope)
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
purge(feature: string): Promise<void> {
|
|
80
|
+
return BannerManager.purge(feature)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
purgeAll(): Promise<void> {
|
|
84
|
+
return BannerManager.purgeAll()
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
load(features: string[], scopes: Scopeable[]): Promise<void> {
|
|
88
|
+
return BannerManager.load(features, scopes)
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
store(name?: string): FeatureStore {
|
|
92
|
+
return BannerManager.store(name)
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
extend(name: string, factory: (config: DriverConfig) => FeatureStore): void {
|
|
96
|
+
BannerManager.extend(name, factory)
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
defined(): string[] {
|
|
100
|
+
return BannerManager.defined()
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
stored(): Promise<string[]> {
|
|
104
|
+
return BannerManager.stored()
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
flushCache(): void {
|
|
108
|
+
BannerManager.flushCache()
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
ensureTables(): Promise<void> {
|
|
112
|
+
return BannerManager.ensureTables()
|
|
113
|
+
},
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Manager
|
|
2
|
+
export { default, default as BannerManager } from './banner_manager.ts'
|
|
3
|
+
|
|
4
|
+
// Provider
|
|
5
|
+
export { default as BannerProvider } from './banner_provider.ts'
|
|
6
|
+
export type { BannerProviderOptions } from './banner_provider.ts'
|
|
7
|
+
|
|
8
|
+
// Helper
|
|
9
|
+
export { banner } from './helpers.ts'
|
|
10
|
+
|
|
11
|
+
// Store interface
|
|
12
|
+
export type { FeatureStore } from './feature_store.ts'
|
|
13
|
+
|
|
14
|
+
// Drivers
|
|
15
|
+
export { DatabaseDriver } from './drivers/database_driver.ts'
|
|
16
|
+
export { ArrayDriver } from './drivers/array_driver.ts'
|
|
17
|
+
|
|
18
|
+
// Scoped API
|
|
19
|
+
export { default as PendingScopedFeature } from './pending_scope.ts'
|
|
20
|
+
|
|
21
|
+
// Middleware
|
|
22
|
+
export { ensureFeature } from './middleware/ensure_feature.ts'
|
|
23
|
+
|
|
24
|
+
// Errors
|
|
25
|
+
export { BannerError, FeatureNotDefinedError } from './errors.ts'
|
|
26
|
+
|
|
27
|
+
// Types
|
|
28
|
+
export type {
|
|
29
|
+
BannerConfig,
|
|
30
|
+
DriverConfig,
|
|
31
|
+
Scopeable,
|
|
32
|
+
ScopeKey,
|
|
33
|
+
StoredFeature,
|
|
34
|
+
FeatureResolver,
|
|
35
|
+
FeatureClass,
|
|
36
|
+
FeatureClassConstructor,
|
|
37
|
+
} from './types.ts'
|
|
38
|
+
export { GLOBAL_SCOPE } from './types.ts'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Middleware } from '@stravigor/core/http/middleware'
|
|
2
|
+
import BannerManager from '../banner_manager.ts'
|
|
3
|
+
import type { Scopeable } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route protection middleware — returns 403 if the feature is not active.
|
|
7
|
+
*
|
|
8
|
+
* Uses `ctx.get('user')` as the default scope if available.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* router.group({ middleware: [auth(), ensureFeature('beta-ui')] }, (r) => { ... })
|
|
12
|
+
*
|
|
13
|
+
* // With custom scope extractor
|
|
14
|
+
* r.get('/team/:id', compose([ensureFeature('analytics', (ctx) => ctx.get('team'))], handler))
|
|
15
|
+
*/
|
|
16
|
+
export function ensureFeature(
|
|
17
|
+
feature: string,
|
|
18
|
+
scopeExtractor?: (ctx: Parameters<Middleware>[0]) => Scopeable | null
|
|
19
|
+
): Middleware {
|
|
20
|
+
return async (ctx, next) => {
|
|
21
|
+
const scope = scopeExtractor
|
|
22
|
+
? scopeExtractor(ctx)
|
|
23
|
+
: (ctx.get('user') as Scopeable | undefined) ?? null
|
|
24
|
+
|
|
25
|
+
const isActive = await BannerManager.active(feature, scope)
|
|
26
|
+
|
|
27
|
+
if (!isActive) {
|
|
28
|
+
return new Response(JSON.stringify({ error: 'Feature not available' }), {
|
|
29
|
+
status: 403,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return next()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Scopeable } from './types.ts'
|
|
2
|
+
import BannerManager from './banner_manager.ts'
|
|
3
|
+
|
|
4
|
+
/** Fluent scoped feature check — created by `BannerManager.for(scope)`. */
|
|
5
|
+
export default class PendingScopedFeature {
|
|
6
|
+
constructor(private scope: Scopeable) {}
|
|
7
|
+
|
|
8
|
+
value(feature: string): Promise<unknown> {
|
|
9
|
+
return BannerManager.value(feature, this.scope)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
active(feature: string): Promise<boolean> {
|
|
13
|
+
return BannerManager.active(feature, this.scope)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
inactive(feature: string): Promise<boolean> {
|
|
17
|
+
return BannerManager.inactive(feature, this.scope)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
when<A, I>(
|
|
21
|
+
feature: string,
|
|
22
|
+
onActive: (value: unknown) => A | Promise<A>,
|
|
23
|
+
onInactive: () => I | Promise<I>
|
|
24
|
+
): Promise<A | I> {
|
|
25
|
+
return BannerManager.when(feature, onActive, onInactive, this.scope)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
activate(feature: string, value?: unknown): Promise<void> {
|
|
29
|
+
return BannerManager.activate(feature, value, this.scope)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
deactivate(feature: string): Promise<void> {
|
|
33
|
+
return BannerManager.deactivate(feature, this.scope)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
forget(feature: string): Promise<void> {
|
|
37
|
+
return BannerManager.forget(feature, this.scope)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
values(features: string[]): Promise<Map<string, unknown>> {
|
|
41
|
+
return BannerManager.values(features, this.scope)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
load(features: string[]): Promise<void> {
|
|
45
|
+
return BannerManager.load(features, [this.scope])
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ── Scope ────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** Any object that can be used as a feature flag scope. */
|
|
4
|
+
export interface Scopeable {
|
|
5
|
+
id: string | number
|
|
6
|
+
/** Optional type discriminator. Defaults to constructor.name. */
|
|
7
|
+
featureScope?: () => string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Serialized scope string, e.g. 'User:42', '__global__'. */
|
|
11
|
+
export type ScopeKey = string
|
|
12
|
+
|
|
13
|
+
/** The global scope sentinel. */
|
|
14
|
+
export const GLOBAL_SCOPE = '__global__'
|
|
15
|
+
|
|
16
|
+
// ── Feature definitions ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** A closure that resolves a feature value for the given scope. */
|
|
19
|
+
export type FeatureResolver<T = unknown> = (scope: ScopeKey) => T | Promise<T>
|
|
20
|
+
|
|
21
|
+
/** A class-based feature with a `resolve` method. */
|
|
22
|
+
export interface FeatureClass {
|
|
23
|
+
readonly key?: string
|
|
24
|
+
resolve(scope: ScopeKey): unknown | Promise<unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FeatureClassConstructor {
|
|
28
|
+
key?: string
|
|
29
|
+
new (): FeatureClass
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Stored values ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export interface StoredFeature {
|
|
35
|
+
feature: string
|
|
36
|
+
scope: ScopeKey
|
|
37
|
+
value: unknown
|
|
38
|
+
createdAt: Date
|
|
39
|
+
updatedAt: Date
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Configuration ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface BannerConfig {
|
|
45
|
+
default: string
|
|
46
|
+
drivers: Record<string, DriverConfig>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DriverConfig {
|
|
50
|
+
driver: string
|
|
51
|
+
[key: string]: unknown
|
|
52
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { env } from '@stravigor/core/helpers'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
/** The default feature flag storage driver. */
|
|
5
|
+
default: env('BANNER_DRIVER', 'database'),
|
|
6
|
+
|
|
7
|
+
drivers: {
|
|
8
|
+
database: {
|
|
9
|
+
driver: 'database',
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
array: {
|
|
13
|
+
driver: 'array',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
}
|
package/tsconfig.json
ADDED