@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PostgresFlagStore` — cross-process feature flag store backed by a
|
|
3
|
+
* single `strav_flags` table (key: `(feature, scope)`). Values live in
|
|
4
|
+
* a `jsonb` column so booleans, numbers, strings, and rich variant
|
|
5
|
+
* payloads all round-trip.
|
|
6
|
+
*
|
|
7
|
+
* Bun's SQL driver hydrates jsonb scalars natively but returns
|
|
8
|
+
* jsonb objects/arrays as their JSON-encoded text — the read path
|
|
9
|
+
* `JSON.parse`s strings opportunistically and falls back to the raw
|
|
10
|
+
* string when parsing fails (so a string-scalar stored as `"hello"`
|
|
11
|
+
* round-trips correctly without us claiming `hello` is an object).
|
|
12
|
+
* Same pattern as `@strav/cache`'s Postgres driver.
|
|
13
|
+
*
|
|
14
|
+
* The runtime peer-dep on `@strav/database` is *optional* — apps
|
|
15
|
+
* using `MemoryFlagStore` shouldn't pull `@strav/database` into their
|
|
16
|
+
* bundle. The minimum surface is declared inline as
|
|
17
|
+
* `PostgresFlagDatabase`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { FeatureStore } from '../../feature_store.ts'
|
|
21
|
+
import type { ScopeKey, StoredFeature } from '../../types.ts'
|
|
22
|
+
|
|
23
|
+
export interface PostgresFlagDatabase {
|
|
24
|
+
query<T = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T[]>
|
|
25
|
+
execute(sql: string, params?: readonly unknown[]): Promise<number>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PostgresFlagStoreOptions {
|
|
29
|
+
db: PostgresFlagDatabase
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TABLE = '"strav_flags"'
|
|
33
|
+
|
|
34
|
+
export class PostgresFlagStore implements FeatureStore {
|
|
35
|
+
readonly name = 'postgres'
|
|
36
|
+
private readonly db: PostgresFlagDatabase
|
|
37
|
+
|
|
38
|
+
constructor(options: PostgresFlagStoreOptions) {
|
|
39
|
+
this.db = options.db
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async get(feature: string, scope: ScopeKey): Promise<unknown | undefined> {
|
|
43
|
+
const rows = await this.db.query<{ value: unknown }>(
|
|
44
|
+
`SELECT "value" FROM ${TABLE} WHERE "feature" = $1 AND "scope" = $2 LIMIT 1`,
|
|
45
|
+
[feature, scope],
|
|
46
|
+
)
|
|
47
|
+
if (rows.length === 0) return undefined
|
|
48
|
+
return hydrate(rows[0]?.value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>> {
|
|
52
|
+
const out = new Map<string, unknown>()
|
|
53
|
+
if (features.length === 0) return out
|
|
54
|
+
const placeholders = features.map((_, i) => `$${i + 2}`).join(', ')
|
|
55
|
+
const rows = await this.db.query<{ feature: string; value: unknown }>(
|
|
56
|
+
`SELECT "feature", "value" FROM ${TABLE}
|
|
57
|
+
WHERE "scope" = $1 AND "feature" IN (${placeholders})`,
|
|
58
|
+
[scope, ...features],
|
|
59
|
+
)
|
|
60
|
+
for (const r of rows) out.set(r.feature, hydrate(r.value))
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async set(feature: string, scope: ScopeKey, value: unknown): Promise<void> {
|
|
65
|
+
const json = JSON.stringify(value)
|
|
66
|
+
await this.db.execute(
|
|
67
|
+
`INSERT INTO ${TABLE} ("feature", "scope", "value", "created_at", "updated_at")
|
|
68
|
+
VALUES ($1, $2, $3::jsonb, now(), now())
|
|
69
|
+
ON CONFLICT ("feature", "scope")
|
|
70
|
+
DO UPDATE SET "value" = $3::jsonb, "updated_at" = now()`,
|
|
71
|
+
[feature, scope, json],
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async setMany(
|
|
76
|
+
entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
for (const e of entries) await this.set(e.feature, e.scope, e.value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async forget(feature: string, scope: ScopeKey): Promise<void> {
|
|
82
|
+
await this.db.execute(`DELETE FROM ${TABLE} WHERE "feature" = $1 AND "scope" = $2`, [
|
|
83
|
+
feature,
|
|
84
|
+
scope,
|
|
85
|
+
])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async purge(feature: string): Promise<void> {
|
|
89
|
+
await this.db.execute(`DELETE FROM ${TABLE} WHERE "feature" = $1`, [feature])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async purgeAll(): Promise<void> {
|
|
93
|
+
await this.db.execute(`DELETE FROM ${TABLE}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async featureNames(): Promise<string[]> {
|
|
97
|
+
const rows = await this.db.query<{ feature: string }>(
|
|
98
|
+
`SELECT DISTINCT "feature" FROM ${TABLE} ORDER BY "feature"`,
|
|
99
|
+
)
|
|
100
|
+
return rows.map((r) => r.feature)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async allFor(feature: string): Promise<StoredFeature[]> {
|
|
104
|
+
const rows = await this.db.query<{
|
|
105
|
+
feature: string
|
|
106
|
+
scope: string
|
|
107
|
+
value: unknown
|
|
108
|
+
created_at: Date
|
|
109
|
+
updated_at: Date
|
|
110
|
+
}>(
|
|
111
|
+
`SELECT "feature", "scope", "value", "created_at", "updated_at"
|
|
112
|
+
FROM ${TABLE} WHERE "feature" = $1 ORDER BY "scope"`,
|
|
113
|
+
[feature],
|
|
114
|
+
)
|
|
115
|
+
return rows.map((r) => ({
|
|
116
|
+
feature: r.feature,
|
|
117
|
+
scope: r.scope,
|
|
118
|
+
value: hydrate(r.value),
|
|
119
|
+
createdAt: r.created_at,
|
|
120
|
+
updatedAt: r.updated_at,
|
|
121
|
+
}))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hydrate(raw: unknown): unknown {
|
|
126
|
+
if (typeof raw !== 'string') return raw
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(raw)
|
|
129
|
+
} catch {
|
|
130
|
+
return raw
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/feature_store.ts
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FeatureStore` — contract every flag storage driver implements.
|
|
3
|
+
*
|
|
4
|
+
* The store is purely a persistence layer: it doesn't know about
|
|
5
|
+
* resolvers, the in-process cache, or scope semantics. The
|
|
6
|
+
* `FlagManager` composes the higher-level behavior on top.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import type { ScopeKey, StoredFeature } from './types.ts'
|
|
2
10
|
|
|
3
|
-
/** Contract that every feature flag storage driver must implement. */
|
|
4
11
|
export interface FeatureStore {
|
|
5
12
|
readonly name: string
|
|
6
13
|
|
|
7
|
-
/** Retrieve
|
|
14
|
+
/** Retrieve a stored value. `undefined` if the pair has never been resolved. */
|
|
8
15
|
get(feature: string, scope: ScopeKey): Promise<unknown | undefined>
|
|
9
16
|
|
|
10
|
-
/**
|
|
17
|
+
/** Batch-read multiple features for a single scope. */
|
|
11
18
|
getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>>
|
|
12
19
|
|
|
13
|
-
/**
|
|
20
|
+
/** Upsert a value for `(feature, scope)`. */
|
|
14
21
|
set(feature: string, scope: ScopeKey, value: unknown): Promise<void>
|
|
15
22
|
|
|
16
|
-
/**
|
|
23
|
+
/** Batch upsert. Drivers SHOULD do this in a single transaction. */
|
|
17
24
|
setMany(entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>): Promise<void>
|
|
18
25
|
|
|
19
|
-
/**
|
|
26
|
+
/** Drop a single `(feature, scope)` entry. */
|
|
20
27
|
forget(feature: string, scope: ScopeKey): Promise<void>
|
|
21
28
|
|
|
22
|
-
/**
|
|
29
|
+
/** Drop every entry for `feature` across all scopes. */
|
|
23
30
|
purge(feature: string): Promise<void>
|
|
24
31
|
|
|
25
|
-
/**
|
|
32
|
+
/** Drop every entry for every feature. */
|
|
26
33
|
purgeAll(): Promise<void>
|
|
27
34
|
|
|
28
|
-
/**
|
|
35
|
+
/** Distinct names of features that have at least one stored entry. */
|
|
29
36
|
featureNames(): Promise<string[]>
|
|
30
37
|
|
|
31
|
-
/**
|
|
38
|
+
/** All stored records for `feature` (every scope it's been resolved against). */
|
|
32
39
|
allFor(feature: string): Promise<StoredFeature[]>
|
|
33
40
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { StravError } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
export class FlagError extends StravError {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message, { code: 'flag-error', status: 500 })
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class FeatureNotDefinedError extends FlagError {
|
|
10
|
+
constructor(feature: string) {
|
|
11
|
+
super(`Feature "${feature}" is not defined. Register it with flagManager.define().`)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when `flag.strictScopes` is enabled and a flag is evaluated
|
|
17
|
+
* without a scope. Catches the common bug where a per-user flag
|
|
18
|
+
* silently resolves the global value because the caller forgot to
|
|
19
|
+
* pass `ctx.auth.user`.
|
|
20
|
+
*/
|
|
21
|
+
export class MissingScopeError extends FlagError {
|
|
22
|
+
constructor(feature: string) {
|
|
23
|
+
super(
|
|
24
|
+
`Feature "${feature}" was evaluated without a scope, but flag.strictScopes is enabled. ` +
|
|
25
|
+
`Pass an explicit scope (e.g. flags.for(user).value('${feature}')) or call ` +
|
|
26
|
+
`flags.activateForEveryone('${feature}') for genuinely global flags.`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|