@strav/flag 0.4.30 → 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.
@@ -1,139 +0,0 @@
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 DELETED
@@ -1,25 +0,0 @@
1
- import { StravError } from '@strav/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
- }
10
-
11
- /**
12
- * Thrown when `flag.strictScopes` is enabled and a flag operation is
13
- * called without a scope (or with a null/undefined one). Catches the
14
- * common bug where middleware forgets to pass `ctx.get('user')` and
15
- * the lookup silently evaluates the global flag.
16
- */
17
- export class MissingScopeError extends FlagError {
18
- constructor(feature: string) {
19
- super(
20
- `Feature "${feature}" was evaluated without a scope, but flag.strictScopes is enabled. ` +
21
- `Pass an explicit scope (e.g. flag.for(user).value('${feature}')) or call ` +
22
- `flag.activateForEveryone('${feature}') for genuinely global flags.`
23
- )
24
- }
25
- }
package/src/helpers.ts DELETED
@@ -1,109 +0,0 @@
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 '@strav/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
- }
@@ -1,36 +0,0 @@
1
- import type { Middleware } from '@strav/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
- }
@@ -1,16 +0,0 @@
1
- import { env } from '@strav/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 DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*.ts"],
4
- "exclude": ["node_modules", "tests"]
5
- }