@strav/flag 0.1.0

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 ADDED
@@ -0,0 +1,66 @@
1
+ # @stravigor/flag
2
+
3
+ Feature flags for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Define, scope, and toggle features with database persistence, in-memory drivers, and per-user/team targeting.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @stravigor/flag
9
+ bun strav install flag
10
+ ```
11
+
12
+ Requires `@stravigor/core` as a peer dependency.
13
+
14
+ ## Setup
15
+
16
+ ```ts
17
+ import { FlagProvider } from '@stravigor/flag'
18
+
19
+ app.use(new FlagProvider())
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { flag } from '@stravigor/flag'
26
+
27
+ // Check if a feature is active
28
+ if (await flag.active('dark-mode')) {
29
+ // feature is enabled
30
+ }
31
+
32
+ // Scoped to a user or team
33
+ if (await flag.for(user).active('beta-dashboard')) {
34
+ // enabled for this user
35
+ }
36
+
37
+ // Rich values
38
+ const limit = await flag.value('upload-limit', 10)
39
+ ```
40
+
41
+ ## Middleware
42
+
43
+ ```ts
44
+ import { ensureFeature } from '@stravigor/flag'
45
+
46
+ router.get('/beta', ensureFeature('beta-dashboard'), betaHandler)
47
+ ```
48
+
49
+ ## Drivers
50
+
51
+ - **Database** — persistent feature flags in `_strav_features`
52
+ - **Array** — in-memory driver for testing
53
+
54
+ ## CLI
55
+
56
+ ```bash
57
+ bun strav flag:setup # Create the features table
58
+ ```
59
+
60
+ ## Documentation
61
+
62
+ See the full [Flag guide](../../guides/flag.md).
63
+
64
+ ## License
65
+
66
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@strav/flag",
3
+ "version": "0.1.0",
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
+ "@strav/kernel": "0.1.0",
22
+ "@strav/database": "0.1.0",
23
+ "@strav/http": "0.1.0",
24
+ "@strav/cli": "0.1.0"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test tests/",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "devDependencies": {
31
+ "commander": "^14.0.3"
32
+ }
33
+ }
@@ -0,0 +1,99 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '@stravigor/cli'
4
+ import FlagManager from '../flag_manager.ts'
5
+
6
+ export function register(program: Command): void {
7
+ program
8
+ .command('flag: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 FlagManager(db, config)
17
+
18
+ console.log(chalk.dim('Creating features table...'))
19
+ await FlagManager.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('flag: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 FlagManager(db, config)
40
+
41
+ if (options?.all || !feature) {
42
+ console.log(chalk.dim('Purging all feature flags...'))
43
+ await FlagManager.purgeAll()
44
+ console.log(chalk.green('All feature flags purged.'))
45
+ } else {
46
+ console.log(chalk.dim(`Purging feature "${feature}"...`))
47
+ await FlagManager.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('flag: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 FlagManager(db, config)
68
+
69
+ const names = await FlagManager.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 FlagManager.store().allFor(name)
79
+ console.log(
80
+ ` ${chalk.cyan(name)} ${chalk.dim(`(${records.length} scope${records.length === 1 ? '' : 's'})`)}`
81
+ )
82
+ for (const r of records) {
83
+ const val =
84
+ typeof r.value === 'boolean'
85
+ ? r.value
86
+ ? chalk.green('active')
87
+ : chalk.red('inactive')
88
+ : chalk.yellow(JSON.stringify(r.value))
89
+ console.log(` ${chalk.dim(r.scope)} → ${val}`)
90
+ }
91
+ }
92
+ } catch (err) {
93
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
94
+ process.exit(1)
95
+ } finally {
96
+ if (db) await shutdown(db)
97
+ }
98
+ })
99
+ }
@@ -0,0 +1,79 @@
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(
37
+ entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>
38
+ ): Promise<void> {
39
+ for (const e of entries) await this.set(e.feature, e.scope, e.value)
40
+ }
41
+
42
+ async forget(feature: string, scope: ScopeKey): Promise<void> {
43
+ this.data.delete(this.key(feature, scope))
44
+ }
45
+
46
+ async purge(feature: string): Promise<void> {
47
+ for (const key of this.data.keys()) {
48
+ if (key.startsWith(`${feature}\0`)) this.data.delete(key)
49
+ }
50
+ }
51
+
52
+ async purgeAll(): Promise<void> {
53
+ this.data.clear()
54
+ }
55
+
56
+ async featureNames(): Promise<string[]> {
57
+ const names = new Set<string>()
58
+ for (const key of this.data.keys()) {
59
+ names.add(key.split('\0')[0]!)
60
+ }
61
+ return [...names]
62
+ }
63
+
64
+ async allFor(feature: string): Promise<StoredFeature[]> {
65
+ const results: StoredFeature[] = []
66
+ for (const [key, entry] of this.data) {
67
+ if (key.startsWith(`${feature}\0`)) {
68
+ results.push({
69
+ feature,
70
+ scope: key.split('\0')[1]!,
71
+ value: entry.value,
72
+ createdAt: entry.createdAt,
73
+ updatedAt: entry.updatedAt,
74
+ })
75
+ }
76
+ }
77
+ return results
78
+ }
79
+ }
@@ -0,0 +1,139 @@
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(
65
+ entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>
66
+ ): Promise<void> {
67
+ if (entries.length === 0) return
68
+ if (entries.length === 1) {
69
+ await this.set(entries[0]!.feature, entries[0]!.scope, entries[0]!.value)
70
+ return
71
+ }
72
+
73
+ await this.sql.begin(async tx => {
74
+ for (const e of entries) {
75
+ const jsonValue = JSON.stringify(e.value)
76
+ await tx`
77
+ INSERT INTO "_strav_features" ("feature", "scope", "value", "created_at", "updated_at")
78
+ VALUES (${e.feature}, ${e.scope}, ${jsonValue}::jsonb, NOW(), NOW())
79
+ ON CONFLICT ("feature", "scope")
80
+ DO UPDATE SET "value" = ${jsonValue}::jsonb, "updated_at" = NOW()
81
+ `
82
+ }
83
+ })
84
+ }
85
+
86
+ async forget(feature: string, scope: ScopeKey): Promise<void> {
87
+ await this.sql`
88
+ DELETE FROM "_strav_features"
89
+ WHERE "feature" = ${feature} AND "scope" = ${scope}
90
+ `
91
+ }
92
+
93
+ async purge(feature: string): Promise<void> {
94
+ await this.sql`
95
+ DELETE FROM "_strav_features"
96
+ WHERE "feature" = ${feature}
97
+ `
98
+ }
99
+
100
+ async purgeAll(): Promise<void> {
101
+ await this.sql`DELETE FROM "_strav_features"`
102
+ }
103
+
104
+ async featureNames(): Promise<string[]> {
105
+ const rows = await this.sql`
106
+ SELECT DISTINCT "feature" FROM "_strav_features"
107
+ ORDER BY "feature"
108
+ `
109
+ return rows.map((r: Record<string, unknown>) => r.feature as string)
110
+ }
111
+
112
+ async allFor(feature: string): Promise<StoredFeature[]> {
113
+ const rows = await this.sql`
114
+ SELECT "feature", "scope", "value", "created_at", "updated_at"
115
+ FROM "_strav_features"
116
+ WHERE "feature" = ${feature}
117
+ ORDER BY "scope"
118
+ `
119
+
120
+ return rows.map((row: Record<string, unknown>) => ({
121
+ feature: row.feature as string,
122
+ scope: row.scope as ScopeKey,
123
+ value: parseValue(row.value),
124
+ createdAt: row.created_at as Date,
125
+ updatedAt: row.updated_at as Date,
126
+ }))
127
+ }
128
+ }
129
+
130
+ function parseValue(raw: unknown): unknown {
131
+ if (typeof raw === 'string') {
132
+ try {
133
+ return JSON.parse(raw)
134
+ } catch {
135
+ return raw
136
+ }
137
+ }
138
+ return raw
139
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { StravError } from '@stravigor/kernel'
2
+
3
+ export class FlagError extends StravError {}
4
+
5
+ export class FeatureNotDefinedError extends FlagError {
6
+ constructor(feature: string) {
7
+ super(`Feature "${feature}" is not defined. Register it with flag.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
+ }
@@ -0,0 +1,346 @@
1
+ import { inject, Configuration, Emitter, ConfigurationError } from '@stravigor/kernel'
2
+ import { Database } from '@stravigor/database'
3
+ import type {
4
+ FlagConfig,
5
+ DriverConfig,
6
+ FeatureResolver,
7
+ FeatureClassConstructor,
8
+ ScopeKey,
9
+ Scopeable,
10
+ } from './types.ts'
11
+ import { GLOBAL_SCOPE } from './types.ts'
12
+ import type { FeatureStore } from './feature_store.ts'
13
+ import { DatabaseDriver } from './drivers/database_driver.ts'
14
+ import { ArrayDriver } from './drivers/array_driver.ts'
15
+ import { FeatureNotDefinedError } from './errors.ts'
16
+ import PendingScopedFeature from './pending_scope.ts'
17
+
18
+ @inject
19
+ export default class FlagManager {
20
+ private static _config: FlagConfig
21
+ private static _db: Database
22
+ private static _stores = new Map<string, FeatureStore>()
23
+ private static _extensions = new Map<string, (config: DriverConfig) => FeatureStore>()
24
+ private static _definitions = new Map<string, FeatureResolver>()
25
+ private static _classFeatures = new Map<string, FeatureClassConstructor>()
26
+ private static _cache = new Map<string, unknown>()
27
+
28
+ constructor(db: Database, config: Configuration) {
29
+ FlagManager._db = db
30
+ FlagManager._config = {
31
+ default: config.get('flag.default', 'database') as string,
32
+ drivers: config.get('flag.drivers', {}) as Record<string, DriverConfig>,
33
+ }
34
+ }
35
+
36
+ // ── Configuration ──────────────────────────────────────────────────
37
+
38
+ static get config(): FlagConfig {
39
+ if (!FlagManager._config) {
40
+ throw new ConfigurationError(
41
+ 'FlagManager not configured. Resolve it through the container first.'
42
+ )
43
+ }
44
+ return FlagManager._config
45
+ }
46
+
47
+ // ── Feature definitions ────────────────────────────────────────────
48
+
49
+ static define(name: string, resolver: FeatureResolver | boolean): void {
50
+ if (typeof resolver === 'boolean') {
51
+ const val = resolver
52
+ FlagManager._definitions.set(name, () => val)
53
+ } else {
54
+ FlagManager._definitions.set(name, resolver)
55
+ }
56
+ }
57
+
58
+ static defineClass(feature: FeatureClassConstructor): void {
59
+ const key = feature.key ?? toKebab(feature.name)
60
+ FlagManager._classFeatures.set(key, feature)
61
+ }
62
+
63
+ /** Get all defined feature names (closures + classes). */
64
+ static defined(): string[] {
65
+ return [...FlagManager._definitions.keys(), ...FlagManager._classFeatures.keys()]
66
+ }
67
+
68
+ // ── Scope helpers ──────────────────────────────────────────────────
69
+
70
+ static serializeScope(scope: Scopeable | null | undefined): ScopeKey {
71
+ if (!scope) return GLOBAL_SCOPE
72
+ const type =
73
+ typeof scope.featureScope === 'function' ? scope.featureScope() : scope.constructor.name
74
+ return `${type}:${scope.id}`
75
+ }
76
+
77
+ // ── Core resolution ────────────────────────────────────────────────
78
+
79
+ static async value(feature: string, scope?: Scopeable | null): Promise<unknown> {
80
+ const scopeKey = FlagManager.serializeScope(scope)
81
+ const cacheKey = FlagManager.cacheKey(feature, scopeKey)
82
+
83
+ // 1. Check in-memory cache
84
+ if (FlagManager._cache.has(cacheKey)) {
85
+ return FlagManager._cache.get(cacheKey)
86
+ }
87
+
88
+ // 2. Check store
89
+ const store = FlagManager.store()
90
+ const stored = await store.get(feature, scopeKey)
91
+ if (stored !== undefined) {
92
+ FlagManager._cache.set(cacheKey, stored)
93
+ return stored
94
+ }
95
+
96
+ // 3. Resolve
97
+ const value = await FlagManager.resolveFeature(feature, scopeKey)
98
+
99
+ // 4. Persist
100
+ await store.set(feature, scopeKey, value)
101
+ FlagManager._cache.set(cacheKey, value)
102
+
103
+ await Emitter.emit('flag:resolved', { feature, scope: scopeKey, value })
104
+
105
+ return value
106
+ }
107
+
108
+ static async active(feature: string, scope?: Scopeable | null): Promise<boolean> {
109
+ return Boolean(await FlagManager.value(feature, scope))
110
+ }
111
+
112
+ static async inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
113
+ return !(await FlagManager.active(feature, scope))
114
+ }
115
+
116
+ static async when<TActive, TInactive>(
117
+ feature: string,
118
+ onActive: (value: unknown) => TActive | Promise<TActive>,
119
+ onInactive: () => TInactive | Promise<TInactive>,
120
+ scope?: Scopeable | null
121
+ ): Promise<TActive | TInactive> {
122
+ const value = await FlagManager.value(feature, scope)
123
+ return value ? onActive(value) : onInactive()
124
+ }
125
+
126
+ // ── Scoped API ─────────────────────────────────────────────────────
127
+
128
+ static for(scope: Scopeable): PendingScopedFeature {
129
+ return new PendingScopedFeature(scope)
130
+ }
131
+
132
+ // ── Manual activation/deactivation ─────────────────────────────────
133
+
134
+ static async activate(feature: string, value?: unknown, scope?: Scopeable | null): Promise<void> {
135
+ const scopeKey = FlagManager.serializeScope(scope)
136
+ const resolved = value !== undefined ? value : true
137
+ await FlagManager.store().set(feature, scopeKey, resolved)
138
+ FlagManager._cache.set(FlagManager.cacheKey(feature, scopeKey), resolved)
139
+ await Emitter.emit('flag:updated', { feature, scope: scopeKey, value: resolved })
140
+ }
141
+
142
+ static async deactivate(feature: string, scope?: Scopeable | null): Promise<void> {
143
+ const scopeKey = FlagManager.serializeScope(scope)
144
+ await FlagManager.store().set(feature, scopeKey, false)
145
+ FlagManager._cache.set(FlagManager.cacheKey(feature, scopeKey), false)
146
+ await Emitter.emit('flag:updated', { feature, scope: scopeKey, value: false })
147
+ }
148
+
149
+ static async activateForEveryone(feature: string, value?: unknown): Promise<void> {
150
+ return FlagManager.activate(feature, value)
151
+ }
152
+
153
+ static async deactivateForEveryone(feature: string): Promise<void> {
154
+ return FlagManager.deactivate(feature)
155
+ }
156
+
157
+ // ── Batch operations ───────────────────────────────────────────────
158
+
159
+ static async values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
160
+ const result = new Map<string, unknown>()
161
+ const scopeKey = FlagManager.serializeScope(scope)
162
+
163
+ // Collect cache hits and misses
164
+ const misses: string[] = []
165
+ for (const f of features) {
166
+ const ck = FlagManager.cacheKey(f, scopeKey)
167
+ if (FlagManager._cache.has(ck)) {
168
+ result.set(f, FlagManager._cache.get(ck))
169
+ } else {
170
+ misses.push(f)
171
+ }
172
+ }
173
+
174
+ if (misses.length === 0) return result
175
+
176
+ // Check store for remaining
177
+ const stored = await FlagManager.store().getMany(misses, scopeKey)
178
+ const stillMissing: string[] = []
179
+
180
+ for (const f of misses) {
181
+ if (stored.has(f)) {
182
+ const val = stored.get(f)
183
+ result.set(f, val)
184
+ FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
185
+ } else {
186
+ stillMissing.push(f)
187
+ }
188
+ }
189
+
190
+ // Resolve any that aren't stored yet
191
+ for (const f of stillMissing) {
192
+ const val = await FlagManager.resolveFeature(f, scopeKey)
193
+ await FlagManager.store().set(f, scopeKey, val)
194
+ FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
195
+ result.set(f, val)
196
+ await Emitter.emit('flag:resolved', { feature: f, scope: scopeKey, value: val })
197
+ }
198
+
199
+ return result
200
+ }
201
+
202
+ /** Get all stored feature names. */
203
+ static async stored(): Promise<string[]> {
204
+ return FlagManager.store().featureNames()
205
+ }
206
+
207
+ // ── Eager loading ──────────────────────────────────────────────────
208
+
209
+ static async load(features: string[], scopes: Scopeable[]): Promise<void> {
210
+ const store = FlagManager.store()
211
+
212
+ for (const scope of scopes) {
213
+ const scopeKey = FlagManager.serializeScope(scope)
214
+ const stored = await store.getMany(features, scopeKey)
215
+
216
+ for (const [f, val] of stored) {
217
+ FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
218
+ }
219
+
220
+ // Resolve any not yet stored
221
+ for (const f of features) {
222
+ if (!stored.has(f)) {
223
+ const val = await FlagManager.resolveFeature(f, scopeKey)
224
+ await store.set(f, scopeKey, val)
225
+ FlagManager._cache.set(FlagManager.cacheKey(f, scopeKey), val)
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ // ── Cleanup ────────────────────────────────────────────────────────
232
+
233
+ static async forget(feature: string, scope?: Scopeable | null): Promise<void> {
234
+ const scopeKey = FlagManager.serializeScope(scope)
235
+ await FlagManager.store().forget(feature, scopeKey)
236
+ FlagManager._cache.delete(FlagManager.cacheKey(feature, scopeKey))
237
+ await Emitter.emit('flag:deleted', { feature, scope: scopeKey })
238
+ }
239
+
240
+ static async purge(feature: string): Promise<void> {
241
+ await FlagManager.store().purge(feature)
242
+ // Clear all cache entries for this feature
243
+ for (const key of FlagManager._cache.keys()) {
244
+ if (key.startsWith(`${feature}\0`)) FlagManager._cache.delete(key)
245
+ }
246
+ await Emitter.emit('flag:deleted', { feature, scope: '*' })
247
+ }
248
+
249
+ static async purgeAll(): Promise<void> {
250
+ await FlagManager.store().purgeAll()
251
+ FlagManager._cache.clear()
252
+ await Emitter.emit('flag:deleted', { feature: '*', scope: '*' })
253
+ }
254
+
255
+ // ── Driver management ──────────────────────────────────────────────
256
+
257
+ static store(name?: string): FeatureStore {
258
+ const key = name ?? FlagManager.config.default
259
+
260
+ let store = FlagManager._stores.get(key)
261
+ if (store) return store
262
+
263
+ const driverConfig = FlagManager.config.drivers[key]
264
+ if (!driverConfig) {
265
+ throw new ConfigurationError(`Flag driver "${key}" is not configured.`)
266
+ }
267
+
268
+ store = FlagManager.createStore(key, driverConfig)
269
+ FlagManager._stores.set(key, store)
270
+ return store
271
+ }
272
+
273
+ static extend(name: string, factory: (config: DriverConfig) => FeatureStore): void {
274
+ FlagManager._extensions.set(name, factory)
275
+ }
276
+
277
+ // ── Cache ──────────────────────────────────────────────────────────
278
+
279
+ static flushCache(): void {
280
+ FlagManager._cache.clear()
281
+ }
282
+
283
+ // ── Table setup ────────────────────────────────────────────────────
284
+
285
+ static async ensureTables(): Promise<void> {
286
+ const store = FlagManager.store()
287
+ if (store instanceof DatabaseDriver) {
288
+ await store.ensureTable()
289
+ }
290
+ }
291
+
292
+ // ── Reset ──────────────────────────────────────────────────────────
293
+
294
+ static reset(): void {
295
+ FlagManager._stores.clear()
296
+ FlagManager._extensions.clear()
297
+ FlagManager._definitions.clear()
298
+ FlagManager._classFeatures.clear()
299
+ FlagManager._cache.clear()
300
+ FlagManager._config = undefined as any
301
+ FlagManager._db = undefined as any
302
+ }
303
+
304
+ // ── Private helpers ────────────────────────────────────────────────
305
+
306
+ private static cacheKey(feature: string, scope: ScopeKey): string {
307
+ return `${feature}\0${scope}`
308
+ }
309
+
310
+ private static async resolveFeature(feature: string, scope: ScopeKey): Promise<unknown> {
311
+ // Try closure definition first
312
+ const resolver = FlagManager._definitions.get(feature)
313
+ if (resolver) return resolver(scope)
314
+
315
+ // Try class-based definition
316
+ const Cls = FlagManager._classFeatures.get(feature)
317
+ if (Cls) return new Cls().resolve(scope)
318
+
319
+ throw new FeatureNotDefinedError(feature)
320
+ }
321
+
322
+ private static createStore(name: string, config: DriverConfig): FeatureStore {
323
+ const driverName = config.driver ?? name
324
+
325
+ const extension = FlagManager._extensions.get(driverName)
326
+ if (extension) return extension(config)
327
+
328
+ switch (driverName) {
329
+ case 'database':
330
+ return new DatabaseDriver(FlagManager._db.sql)
331
+ case 'array':
332
+ return new ArrayDriver()
333
+ default:
334
+ throw new ConfigurationError(
335
+ `Unknown flag driver "${driverName}". Register it with FlagManager.extend().`
336
+ )
337
+ }
338
+ }
339
+ }
340
+
341
+ function toKebab(name: string): string {
342
+ return name
343
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
344
+ .replace(/[\s_]+/g, '-')
345
+ .toLowerCase()
346
+ }
@@ -0,0 +1,33 @@
1
+ import { ServiceProvider } from '@stravigor/kernel'
2
+ import type { Application } from '@stravigor/kernel'
3
+ import FlagManager from './flag_manager.ts'
4
+
5
+ export interface FlagProviderOptions {
6
+ /** Auto-create the features table. Default: `true` */
7
+ ensureTables?: boolean
8
+ }
9
+
10
+ export default class FlagProvider extends ServiceProvider {
11
+ readonly name = 'flag'
12
+ override readonly dependencies = ['config', 'database']
13
+
14
+ constructor(private options?: FlagProviderOptions) {
15
+ super()
16
+ }
17
+
18
+ override register(app: Application): void {
19
+ app.singleton(FlagManager)
20
+ }
21
+
22
+ override async boot(app: Application): Promise<void> {
23
+ app.resolve(FlagManager)
24
+
25
+ if (this.options?.ensureTables !== false) {
26
+ await FlagManager.ensureTables()
27
+ }
28
+ }
29
+
30
+ override shutdown(): void {
31
+ FlagManager.reset()
32
+ }
33
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,109 @@
1
+ import FlagManager from './flag_manager.ts'
2
+ import PendingScopedFeature from './pending_scope.ts'
3
+ import type { FeatureStore } from './feature_store.ts'
4
+ import type { Scopeable, FeatureResolver, FeatureClassConstructor, DriverConfig } from './types.ts'
5
+
6
+ /**
7
+ * Flag helper — the primary convenience API.
8
+ *
9
+ * @example
10
+ * import { flag } from '@stravigor/flag'
11
+ *
12
+ * flag.define('new-checkout', (scope) => scope.startsWith('User:'))
13
+ *
14
+ * if (await flag.active('new-checkout')) { ... }
15
+ */
16
+ export const flag = {
17
+ define(name: string, resolver: FeatureResolver | boolean): void {
18
+ FlagManager.define(name, resolver)
19
+ },
20
+
21
+ defineClass(feature: FeatureClassConstructor): void {
22
+ FlagManager.defineClass(feature)
23
+ },
24
+
25
+ active(feature: string, scope?: Scopeable | null): Promise<boolean> {
26
+ return FlagManager.active(feature, scope)
27
+ },
28
+
29
+ inactive(feature: string, scope?: Scopeable | null): Promise<boolean> {
30
+ return FlagManager.inactive(feature, scope)
31
+ },
32
+
33
+ value(feature: string, scope?: Scopeable | null): Promise<unknown> {
34
+ return FlagManager.value(feature, scope)
35
+ },
36
+
37
+ when<A, I>(
38
+ feature: string,
39
+ onActive: (value: unknown) => A | Promise<A>,
40
+ onInactive: () => I | Promise<I>,
41
+ scope?: Scopeable | null
42
+ ): Promise<A | I> {
43
+ return FlagManager.when(feature, onActive, onInactive, scope)
44
+ },
45
+
46
+ for(scope: Scopeable): PendingScopedFeature {
47
+ return FlagManager.for(scope)
48
+ },
49
+
50
+ activate(feature: string, value?: unknown, scope?: Scopeable | null): Promise<void> {
51
+ return FlagManager.activate(feature, value, scope)
52
+ },
53
+
54
+ deactivate(feature: string, scope?: Scopeable | null): Promise<void> {
55
+ return FlagManager.deactivate(feature, scope)
56
+ },
57
+
58
+ activateForEveryone(feature: string, value?: unknown): Promise<void> {
59
+ return FlagManager.activateForEveryone(feature, value)
60
+ },
61
+
62
+ deactivateForEveryone(feature: string): Promise<void> {
63
+ return FlagManager.deactivateForEveryone(feature)
64
+ },
65
+
66
+ values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
67
+ return FlagManager.values(features, scope)
68
+ },
69
+
70
+ forget(feature: string, scope?: Scopeable | null): Promise<void> {
71
+ return FlagManager.forget(feature, scope)
72
+ },
73
+
74
+ purge(feature: string): Promise<void> {
75
+ return FlagManager.purge(feature)
76
+ },
77
+
78
+ purgeAll(): Promise<void> {
79
+ return FlagManager.purgeAll()
80
+ },
81
+
82
+ load(features: string[], scopes: Scopeable[]): Promise<void> {
83
+ return FlagManager.load(features, scopes)
84
+ },
85
+
86
+ store(name?: string): FeatureStore {
87
+ return FlagManager.store(name)
88
+ },
89
+
90
+ extend(name: string, factory: (config: DriverConfig) => FeatureStore): void {
91
+ FlagManager.extend(name, factory)
92
+ },
93
+
94
+ defined(): string[] {
95
+ return FlagManager.defined()
96
+ },
97
+
98
+ stored(): Promise<string[]> {
99
+ return FlagManager.stored()
100
+ },
101
+
102
+ flushCache(): void {
103
+ FlagManager.flushCache()
104
+ },
105
+
106
+ ensureTables(): Promise<void> {
107
+ return FlagManager.ensureTables()
108
+ },
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Manager
2
+ export { default, default as FlagManager } from './flag_manager.ts'
3
+
4
+ // Provider
5
+ export { default as FlagProvider } from './flag_provider.ts'
6
+ export type { FlagProviderOptions } from './flag_provider.ts'
7
+
8
+ // Helper
9
+ export { flag } 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 { FlagError, FeatureNotDefinedError } from './errors.ts'
26
+
27
+ // Types
28
+ export type {
29
+ FlagConfig,
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/http'
2
+ import FlagManager from '../flag_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 FlagManager.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 FlagManager from './flag_manager.ts'
3
+
4
+ /** Fluent scoped feature check — created by `FlagManager.for(scope)`. */
5
+ export default class PendingScopedFeature {
6
+ constructor(private scope: Scopeable) {}
7
+
8
+ value(feature: string): Promise<unknown> {
9
+ return FlagManager.value(feature, this.scope)
10
+ }
11
+
12
+ active(feature: string): Promise<boolean> {
13
+ return FlagManager.active(feature, this.scope)
14
+ }
15
+
16
+ inactive(feature: string): Promise<boolean> {
17
+ return FlagManager.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 FlagManager.when(feature, onActive, onInactive, this.scope)
26
+ }
27
+
28
+ activate(feature: string, value?: unknown): Promise<void> {
29
+ return FlagManager.activate(feature, value, this.scope)
30
+ }
31
+
32
+ deactivate(feature: string): Promise<void> {
33
+ return FlagManager.deactivate(feature, this.scope)
34
+ }
35
+
36
+ forget(feature: string): Promise<void> {
37
+ return FlagManager.forget(feature, this.scope)
38
+ }
39
+
40
+ values(features: string[]): Promise<Map<string, unknown>> {
41
+ return FlagManager.values(features, this.scope)
42
+ }
43
+
44
+ load(features: string[]): Promise<void> {
45
+ return FlagManager.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 FlagConfig {
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/kernel'
2
+
3
+ export default {
4
+ /** The default feature flag storage driver. */
5
+ default: env('FLAG_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,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }