@strav/flag 0.4.31 → 1.0.0-alpha.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -31
- package/package.json +34 -21
- package/src/console/flag_activate.ts +60 -0
- package/src/console/flag_console_provider.ts +25 -0
- package/src/console/flag_deactivate.ts +37 -0
- package/src/console/flag_list.ts +32 -0
- package/src/console/flag_purge.ts +38 -0
- package/src/console/index.ts +5 -0
- package/src/drivers/memory/index.ts +1 -0
- package/src/drivers/memory/memory_flag_store.ts +94 -0
- package/src/drivers/postgres/apply_flag_migration.ts +32 -0
- package/src/drivers/postgres/index.ts +10 -0
- package/src/drivers/postgres/postgres_flag_provider.ts +49 -0
- package/src/drivers/postgres/postgres_flag_store.ts +132 -0
- package/src/feature_store.ts +17 -10
- package/src/flag_error.ts +29 -0
- package/src/flag_manager.ts +205 -272
- package/src/flag_provider.ts +43 -19
- package/src/http/ensure_feature.ts +56 -0
- package/src/http/index.ts +1 -0
- package/src/index.ts +28 -38
- package/src/pending_scope.ts +26 -14
- package/src/types.ts +60 -36
- package/src/commands/flag_commands.ts +0 -99
- package/src/drivers/array_driver.ts +0 -79
- package/src/drivers/database_driver.ts +0 -139
- package/src/errors.ts +0 -25
- package/src/helpers.ts +0 -109
- package/src/middleware/ensure_feature.ts +0 -36
- package/stubs/config/flag.ts +0 -16
- package/tsconfig.json +0 -5
package/src/flag_provider.ts
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `FlagProvider` — registers `FlagManager` under its own token, backed
|
|
3
|
+
* by the in-process `MemoryFlagStore` by default. Apps that need
|
|
4
|
+
* cross-process persistence swap in `PostgresFlagProvider` from
|
|
5
|
+
* `@strav/flag/postgres` — both register under the same `FlagManager`
|
|
6
|
+
* token, so consumers don't change their inject.
|
|
7
|
+
*
|
|
8
|
+
* `define` lets callers register feature resolvers up-front in the
|
|
9
|
+
* same place they wire the provider:
|
|
10
|
+
*
|
|
11
|
+
* new FlagProvider({
|
|
12
|
+
* define(flags) {
|
|
13
|
+
* flags.define('beta-ui', (scope) => scope.startsWith('User:'))
|
|
14
|
+
* },
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* Resolvers can also be added later (e.g. from another provider's
|
|
18
|
+
* `boot()`) via `app.resolve(FlagManager).define(...)`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { type Application, ConfigRepository, EventBus, ServiceProvider } from '@strav/kernel'
|
|
22
|
+
import { MemoryFlagStore } from './drivers/memory/memory_flag_store.ts'
|
|
23
|
+
import { FlagManager } from './flag_manager.ts'
|
|
24
|
+
import type { FlagConfig } from './types.ts'
|
|
4
25
|
|
|
5
26
|
export interface FlagProviderOptions {
|
|
6
|
-
/**
|
|
7
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Hook invoked at boot, after the manager is created. Use it to
|
|
29
|
+
* register flag resolvers.
|
|
30
|
+
*/
|
|
31
|
+
define?: (flags: FlagManager) => void | Promise<void>
|
|
8
32
|
}
|
|
9
33
|
|
|
10
|
-
export
|
|
11
|
-
readonly name = 'flag'
|
|
12
|
-
override readonly dependencies = ['config'
|
|
34
|
+
export class FlagProvider extends ServiceProvider {
|
|
35
|
+
override readonly name = 'flag'
|
|
36
|
+
override readonly dependencies = ['config']
|
|
13
37
|
|
|
14
|
-
constructor(private options
|
|
38
|
+
constructor(private readonly options: FlagProviderOptions = {}) {
|
|
15
39
|
super()
|
|
16
40
|
}
|
|
17
41
|
|
|
18
42
|
override register(app: Application): void {
|
|
19
|
-
app.singleton(FlagManager)
|
|
43
|
+
app.singleton(FlagManager, (c) => {
|
|
44
|
+
const cfg = (c.resolve(ConfigRepository).get('flag') as FlagConfig | undefined) ?? {}
|
|
45
|
+
return new FlagManager({
|
|
46
|
+
store: new MemoryFlagStore(),
|
|
47
|
+
events: c.resolve(EventBus),
|
|
48
|
+
config: cfg,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
20
51
|
}
|
|
21
52
|
|
|
22
53
|
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()
|
|
54
|
+
const flags = app.resolve(FlagManager)
|
|
55
|
+
if (this.options.define) await this.options.define(flags)
|
|
32
56
|
}
|
|
33
57
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ensureFeature` — route guard that 403s when a flag is inactive.
|
|
3
|
+
*
|
|
4
|
+
* router.get('/beta', ensureFeature('beta-ui'), betaHandler)
|
|
5
|
+
*
|
|
6
|
+
* router.group(
|
|
7
|
+
* { middleware: [auth(), ensureFeature('analytics', (ctx) => ctx.auth?.user ?? null)] },
|
|
8
|
+
* (r) => { ... }
|
|
9
|
+
* )
|
|
10
|
+
*
|
|
11
|
+
* Scope source: by default the middleware reads `ctx.auth?.user` if
|
|
12
|
+
* the `@strav/auth` augmentation is installed and falls back to the
|
|
13
|
+
* global scope otherwise. Pass `scopeFrom` for per-route logic (team,
|
|
14
|
+
* tenant, custom subject).
|
|
15
|
+
*
|
|
16
|
+
* Returns a `Response` directly instead of throwing — the response is
|
|
17
|
+
* symmetrical for HTML and JSON callers (both get a 403), and the
|
|
18
|
+
* caller doesn't need to wire a custom exception handler.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { HttpContext, MiddlewareFn } from '@strav/http'
|
|
22
|
+
import { FlagManager } from '../flag_manager.ts'
|
|
23
|
+
import type { Scopeable } from '../types.ts'
|
|
24
|
+
|
|
25
|
+
export interface EnsureFeatureOptions {
|
|
26
|
+
/** Custom value when the feature is denied. Default: `{ error: 'Feature not available' }`. */
|
|
27
|
+
body?: unknown
|
|
28
|
+
/** Custom status. Default: `403`. */
|
|
29
|
+
status?: number
|
|
30
|
+
/** Where to read the scope from. Default: `ctx.auth?.user ?? null`. */
|
|
31
|
+
scopeFrom?: (ctx: HttpContext) => Scopeable | null | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ensureFeature(feature: string, options: EnsureFeatureOptions = {}): MiddlewareFn {
|
|
35
|
+
return async (ctx, next) => {
|
|
36
|
+
const flags = ctx.container.resolve(FlagManager)
|
|
37
|
+
const scope = options.scopeFrom ? options.scopeFrom(ctx) : defaultScope(ctx)
|
|
38
|
+
const active = await flags.active(feature, scope)
|
|
39
|
+
if (active) return next()
|
|
40
|
+
return new Response(
|
|
41
|
+
JSON.stringify(options.body ?? { error: 'Feature not available', feature }),
|
|
42
|
+
{
|
|
43
|
+
status: options.status ?? 403,
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultScope(ctx: HttpContext): Scopeable | null {
|
|
51
|
+
// `ctx.auth` only exists when `@strav/auth` is loaded — the
|
|
52
|
+
// augmentation is declared there. Read defensively so this
|
|
53
|
+
// middleware works in apps without auth wired.
|
|
54
|
+
const auth = (ctx as unknown as { auth?: { user?: Scopeable | null } }).auth
|
|
55
|
+
return auth?.user ?? null
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type EnsureFeatureOptions, ensureFeature } from './ensure_feature.ts'
|
package/src/index.ts
CHANGED
|
@@ -1,40 +1,30 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
// Public API of @strav/flag.
|
|
2
|
+
//
|
|
3
|
+
// Root barrel exports the manager + types + errors + the in-memory
|
|
4
|
+
// store and its provider. Backends and integrations ship under
|
|
5
|
+
// subpaths:
|
|
6
|
+
// - `@strav/flag/memory` (re-exports the in-memory store)
|
|
7
|
+
// - `@strav/flag/postgres` (Postgres backplane + migration helper)
|
|
8
|
+
// - `@strav/flag/http` (ensureFeature route guard)
|
|
9
|
+
// - `@strav/flag/console` (flag:* CLI commands)
|
|
10
|
+
|
|
11
|
+
export { MemoryFlagStore } from './drivers/memory/memory_flag_store.ts'
|
|
12
12
|
export type { FeatureStore } from './feature_store.ts'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export {
|
|
16
|
-
export {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
DriverConfig,
|
|
31
|
-
Scopeable,
|
|
32
|
-
ScopeKey,
|
|
33
|
-
StoredFeature,
|
|
34
|
-
FeatureResolver,
|
|
35
|
-
FeatureClass,
|
|
36
|
-
FeatureClassConstructor,
|
|
37
|
-
FlagActor,
|
|
38
|
-
FlagUpdatedEvent,
|
|
13
|
+
export { FeatureNotDefinedError, FlagError, MissingScopeError } from './flag_error.ts'
|
|
14
|
+
export { FlagManager, type FlagManagerOptions } from './flag_manager.ts'
|
|
15
|
+
export { FlagProvider, type FlagProviderOptions } from './flag_provider.ts'
|
|
16
|
+
export { PendingScopedFeature } from './pending_scope.ts'
|
|
17
|
+
export {
|
|
18
|
+
type FeatureClass,
|
|
19
|
+
type FeatureClassConstructor,
|
|
20
|
+
type FeatureResolver,
|
|
21
|
+
type FlagActor,
|
|
22
|
+
type FlagConfig,
|
|
23
|
+
type FlagDeletedEvent,
|
|
24
|
+
type FlagResolvedEvent,
|
|
25
|
+
type FlagUpdatedEvent,
|
|
26
|
+
GLOBAL_SCOPE,
|
|
27
|
+
type Scopeable,
|
|
28
|
+
type ScopeKey,
|
|
29
|
+
type StoredFeature,
|
|
39
30
|
} from './types.ts'
|
|
40
|
-
export { GLOBAL_SCOPE } from './types.ts'
|
package/src/pending_scope.ts
CHANGED
|
@@ -1,47 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PendingScopedFeature` — the fluent return value of
|
|
3
|
+
* `FlagManager.for(scope)`. Lets call sites read several features for
|
|
4
|
+
* the same scope without repeating the scope argument.
|
|
5
|
+
*
|
|
6
|
+
* const flags = app.resolve(FlagManager).for(ctx.auth.user)
|
|
7
|
+
* if (await flags.active('beta-ui')) { ... }
|
|
8
|
+
* const limit = await flags.value('upload-limit')
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FlagManager } from './flag_manager.ts'
|
|
1
12
|
import type { FlagActor, Scopeable } from './types.ts'
|
|
2
|
-
import FlagManager from './flag_manager.ts'
|
|
3
13
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
14
|
+
export class PendingScopedFeature {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly manager: FlagManager,
|
|
17
|
+
private readonly scope: Scopeable,
|
|
18
|
+
) {}
|
|
7
19
|
|
|
8
20
|
value(feature: string): Promise<unknown> {
|
|
9
|
-
return
|
|
21
|
+
return this.manager.value(feature, this.scope)
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
active(feature: string): Promise<boolean> {
|
|
13
|
-
return
|
|
25
|
+
return this.manager.active(feature, this.scope)
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
inactive(feature: string): Promise<boolean> {
|
|
17
|
-
return
|
|
29
|
+
return this.manager.inactive(feature, this.scope)
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
when<A, I>(
|
|
21
33
|
feature: string,
|
|
22
34
|
onActive: (value: unknown) => A | Promise<A>,
|
|
23
|
-
onInactive: () => I | Promise<I
|
|
35
|
+
onInactive: () => I | Promise<I>,
|
|
24
36
|
): Promise<A | I> {
|
|
25
|
-
return
|
|
37
|
+
return this.manager.when(feature, onActive, onInactive, this.scope)
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
activate(feature: string, value?: unknown, actor?: FlagActor | null): Promise<void> {
|
|
29
|
-
return
|
|
41
|
+
return this.manager.activate(feature, value, this.scope, actor)
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
deactivate(feature: string, actor?: FlagActor | null): Promise<void> {
|
|
33
|
-
return
|
|
45
|
+
return this.manager.deactivate(feature, this.scope, actor)
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
forget(feature: string): Promise<void> {
|
|
37
|
-
return
|
|
49
|
+
return this.manager.forget(feature, this.scope)
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
values(features: string[]): Promise<Map<string, unknown>> {
|
|
41
|
-
return
|
|
53
|
+
return this.manager.values(features, this.scope)
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
load(features: string[]): Promise<void> {
|
|
45
|
-
return
|
|
57
|
+
return this.manager.load(features, [this.scope])
|
|
46
58
|
}
|
|
47
59
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Public types for `@strav/flag`.
|
|
3
|
+
*
|
|
4
|
+
* Feature flags are evaluated against a *scope* — usually the
|
|
5
|
+
* authenticated user, sometimes a team / workspace / tenant, sometimes
|
|
6
|
+
* nothing at all (the global scope). The scope is serialized to a
|
|
7
|
+
* stable string key and used as the lookup discriminator both in the
|
|
8
|
+
* in-process cache and the persistent store.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Scope ────────────────────────────────────────────────────────────────
|
|
2
12
|
|
|
3
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Anything that can serve as a flag scope. The shape is intentionally
|
|
15
|
+
* loose — any object with an `id` works (`User`, `Team`, `Tenant`,
|
|
16
|
+
* `ApiKey`, etc.). Pass `featureScope()` to override the type prefix
|
|
17
|
+
* when the constructor name is unstable (minified bundles, mixins).
|
|
18
|
+
*/
|
|
4
19
|
export interface Scopeable {
|
|
5
20
|
id: string | number
|
|
6
|
-
/** Optional
|
|
21
|
+
/** Optional discriminator. Defaults to `constructor.name`. */
|
|
7
22
|
featureScope?: () => string
|
|
8
23
|
}
|
|
9
24
|
|
|
10
|
-
/** Serialized scope string, e.g.
|
|
25
|
+
/** Serialized scope string, e.g. `User:42`, `Team:7`, `__global__`. */
|
|
11
26
|
export type ScopeKey = string
|
|
12
27
|
|
|
13
|
-
/**
|
|
28
|
+
/** Sentinel for the global (no scope) bucket. */
|
|
14
29
|
export const GLOBAL_SCOPE = '__global__'
|
|
15
30
|
|
|
16
|
-
//
|
|
31
|
+
// ─── Feature definitions ──────────────────────────────────────────────────
|
|
17
32
|
|
|
18
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* A closure that resolves a feature value for the given scope key.
|
|
35
|
+
* The return value is whatever the app wants — a `boolean` for binary
|
|
36
|
+
* flags, a `string`/`number`/object for A-B variants or rich config.
|
|
37
|
+
*/
|
|
19
38
|
export type FeatureResolver<T = unknown> = (scope: ScopeKey) => T | Promise<T>
|
|
20
39
|
|
|
21
40
|
/** A class-based feature with a `resolve` method. */
|
|
@@ -29,7 +48,7 @@ export interface FeatureClassConstructor {
|
|
|
29
48
|
new (): FeatureClass
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
//
|
|
51
|
+
// ─── Stored values ────────────────────────────────────────────────────────
|
|
33
52
|
|
|
34
53
|
export interface StoredFeature {
|
|
35
54
|
feature: string
|
|
@@ -39,53 +58,58 @@ export interface StoredFeature {
|
|
|
39
58
|
updatedAt: Date
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
//
|
|
61
|
+
// ─── Configuration ────────────────────────────────────────────────────────
|
|
43
62
|
|
|
44
63
|
export interface FlagConfig {
|
|
45
|
-
default: string
|
|
46
|
-
drivers: Record<string, DriverConfig>
|
|
47
64
|
/**
|
|
48
|
-
* When true
|
|
49
|
-
*
|
|
50
|
-
* of silently falling back to the
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
65
|
+
* When `true`, the read path (`value` / `active` / `inactive` /
|
|
66
|
+
* `when` / `values` / `forget`) called without a scope throws
|
|
67
|
+
* `MissingScopeError` instead of silently falling back to the
|
|
68
|
+
* global value. Defends against the common bug where middleware
|
|
69
|
+
* forgets to pass `ctx.auth.user` and a per-user flag silently
|
|
70
|
+
* evaluates the global flag for everyone.
|
|
71
|
+
*
|
|
72
|
+
* The write path keeps loose semantics — `activate('flag')` with no
|
|
73
|
+
* scope still writes the global value. Callers that want explicit
|
|
74
|
+
* global writes should prefer `activateForEveryone()`.
|
|
75
|
+
*
|
|
76
|
+
* Default: `false` (backward-compatible). Turn on in new apps.
|
|
55
77
|
*/
|
|
56
|
-
strictScopes
|
|
78
|
+
strictScopes?: boolean
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
driver: string
|
|
61
|
-
[key: string]: unknown
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ── Actor ────────────────────────────────────────────────────────────────
|
|
81
|
+
// ─── Actor ────────────────────────────────────────────────────────────────
|
|
65
82
|
|
|
66
83
|
/**
|
|
67
|
-
* Who initiated a flag write. Optional
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* audit-integration pattern.
|
|
84
|
+
* Who initiated a flag write. Optional but recommended for audit. The
|
|
85
|
+
* actor is carried through to the `flag:updated` event so an audit
|
|
86
|
+
* hook can record who flipped what.
|
|
71
87
|
*/
|
|
72
88
|
export interface FlagActor {
|
|
73
89
|
type: string
|
|
74
90
|
id: string | number
|
|
75
91
|
}
|
|
76
92
|
|
|
77
|
-
//
|
|
93
|
+
// ─── Events ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface FlagResolvedEvent {
|
|
96
|
+
feature: string
|
|
97
|
+
scope: ScopeKey
|
|
98
|
+
value: unknown
|
|
99
|
+
}
|
|
78
100
|
|
|
79
|
-
/**
|
|
80
|
-
* Payload for the `flag:updated` Emitter event. Fired when `activate()`
|
|
81
|
-
* or `deactivate()` writes to the store.
|
|
82
|
-
*/
|
|
83
101
|
export interface FlagUpdatedEvent {
|
|
84
102
|
feature: string
|
|
85
103
|
scope: ScopeKey
|
|
86
104
|
value: unknown
|
|
87
|
-
/** Previous stored value
|
|
105
|
+
/** Previous stored value, or `undefined` if the flag had no entry. */
|
|
88
106
|
previous: unknown
|
|
89
|
-
/**
|
|
107
|
+
/** Caller-supplied actor, `null` when not provided. */
|
|
90
108
|
actor: FlagActor | null
|
|
91
109
|
}
|
|
110
|
+
|
|
111
|
+
export interface FlagDeletedEvent {
|
|
112
|
+
feature: string
|
|
113
|
+
/** Specific scope, or `*` for purge / purgeAll. */
|
|
114
|
+
scope: ScopeKey | '*'
|
|
115
|
+
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import type { Command } from 'commander'
|
|
2
|
-
import chalk from 'chalk'
|
|
3
|
-
import { bootstrap, shutdown } from '@strav/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
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
}
|