@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 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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }