@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 +66 -0
- package/package.json +33 -0
- package/src/commands/flag_commands.ts +99 -0
- package/src/drivers/array_driver.ts +79 -0
- package/src/drivers/database_driver.ts +139 -0
- package/src/errors.ts +9 -0
- package/src/feature_store.ts +33 -0
- package/src/flag_manager.ts +346 -0
- package/src/flag_provider.ts +33 -0
- package/src/helpers.ts +109 -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/flag.ts +16 -0
- package/tsconfig.json +5 -0
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
|
+
}
|