├── .dockerignore ├── .gitignore ├── bun.lockb ├── tsconfig.json ├── packages ├── core │ ├── src │ │ ├── utils.ts │ │ ├── error.ts │ │ ├── drizzle │ │ │ └── index.ts │ │ ├── feature-flag │ │ │ ├── feature-flag.sql.ts │ │ │ └── index.ts │ │ └── feature-flag-evaluation │ │ │ └── index.ts │ ├── tsconfig.json │ ├── drizzle.config.ts │ ├── package.json │ └── sst-env.d.ts ├── functions │ ├── src │ │ ├── common.ts │ │ ├── feature-flag-admin.ts │ │ ├── feature-flag-evaluation.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── package.json │ └── sst-env.d.ts ├── interval │ ├── tsconfig.json │ ├── Dockerfile │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── routes │ │ │ └── feature-flags.ts │ └── sst-env.d.ts └── load-testing │ ├── tsconfig.json │ ├── package.json │ ├── sst-env.d.ts │ └── src │ ├── test_evaluation_api.ts │ └── test_evaluation_api_complex.ts ├── package.json ├── sst-env.d.ts ├── LICENSE ├── sst.config.ts └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .sst 2 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sst 2 | node_modules 3 | .vscode 4 | 5 | # Personal 6 | notes 7 | .env -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bensenescu/sst-feature-flag/HEAD/bun.lockb -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.sst/platform/tsconfig.json", 3 | "compilerOptions": { 4 | "skipLibCheck": true 5 | }, 6 | "exclude": ["packages/"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const isJsonString = (val: string) => { 2 | try { 3 | JSON.parse(val); 4 | } catch (err) { 5 | return false; 6 | } 7 | return true; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/functions/src/common.ts: -------------------------------------------------------------------------------- 1 | import { z } from "@hono/zod-openapi"; 2 | 3 | export function Result(schema: T) { 4 | return z.object({ 5 | result: schema, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /packages/interval/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "noUncheckedIndexedAccess": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/load-testing/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "noUncheckedIndexedAccess": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/error.ts: -------------------------------------------------------------------------------- 1 | export class VisibleError extends Error { 2 | constructor( 3 | public kind: "input" | "auth", 4 | public code: string, 5 | public message: string, 6 | ) { 7 | super(message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "jsx": "react-jsx", 6 | "moduleResolution": "bundler", 7 | "noUncheckedIndexedAccess": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "jsx": "react-jsx", 6 | "moduleResolution": "bundler", 7 | "noUncheckedIndexedAccess": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/interval/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1.1.24 2 | 3 | WORKDIR /app 4 | RUN mkdir packages 5 | COPY package.json ./ 6 | COPY packages/*/package.json ./packages/ 7 | COPY packages ./packages 8 | RUN bun install 9 | WORKDIR /app/packages/interval 10 | CMD bun run ./src/index.ts -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sst-feature-flag", 3 | "version": "0.0.0", 4 | "sideEffects": false, 5 | "scripts": {}, 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "devDependencies": { 10 | "typescript": "^5.0.0" 11 | }, 12 | "dependencies": { 13 | "sst": "^3.1.18" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sst-feature-flag/functions", 3 | "type": "module", 4 | "sideEffects": false, 5 | "scripts": {}, 6 | "devDependencies": {}, 7 | "dependencies": { 8 | "@hono/zod-openapi": "^0.16.2", 9 | "@tsconfig/node20": "^20.1.4", 10 | "hono": "^4.6.3", 11 | "sst": "*", 12 | "zod": "^3.23.8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/load-testing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sst-feature-flag/load-testing", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "sideEffects": false, 6 | "scripts": { 7 | "typecheck": "tsc --noEmit" 8 | }, 9 | "dependencies": { 10 | "@sst-feature-flag/core": "workspace:*", 11 | "@types/k6": "^0.54.0", 12 | "k6": "^0.0.0", 13 | "sst": "*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/interval/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sst-feature-flag/interval", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "sideEffects": false, 6 | "scripts": { 7 | "typecheck": "tsc --noEmit", 8 | "dev": "bun --watch ./src/index.ts" 9 | }, 10 | "dependencies": { 11 | "@interval/sdk": "1.5.0", 12 | "sst": "*", 13 | "@sst-feature-flag/core": "workspace:*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/interval/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Interval } from "@interval/sdk"; 2 | import { Resource } from "sst"; 3 | import FeatureFlags from "./routes/feature-flags"; 4 | 5 | const interval = new Interval({ 6 | apiKey: Resource.IntervalApiKey.value, 7 | routes: { 8 | featureFlags: FeatureFlags, 9 | }, 10 | endpoint: Resource.IntervalServerEndpoint.value, 11 | }); 12 | 13 | interval.listen(); 14 | -------------------------------------------------------------------------------- /packages/core/src/drizzle/index.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from "sst"; 2 | import { drizzle } from "drizzle-orm/aws-data-api/pg"; 3 | import { RDSDataClient } from "@aws-sdk/client-rds-data"; 4 | 5 | const client = new RDSDataClient({}); 6 | 7 | export const db = drizzle(client, { 8 | database: Resource.FeatureFlagPostgres.database, 9 | secretArn: Resource.FeatureFlagPostgres.secretArn, 10 | resourceArn: Resource.FeatureFlagPostgres.clusterArn, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/core/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | // TODO: why isn't this being imported properly? 2 | import { Resource } from "sst"; 3 | 4 | import { defineConfig } from "drizzle-kit"; 5 | 6 | export default defineConfig({ 7 | driver: "aws-data-api", 8 | dialect: "postgresql", 9 | dbCredentials: { 10 | database: Resource.FeatureFlagPostgres.database, 11 | secretArn: Resource.FeatureFlagPostgres.secretArn, 12 | resourceArn: Resource.FeatureFlagPostgres.clusterArn, 13 | }, 14 | // Pick up all our schema files 15 | schema: ["./src/**/*.sql.ts"], 16 | out: "./migrations", 17 | }); 18 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sst-feature-flag/core", 3 | "type": "module", 4 | "sideEffects": false, 5 | "exports": { 6 | "./*": "./src/*.ts" 7 | }, 8 | "scripts": { 9 | "db": "sst shell drizzle-kit", 10 | "db:studio": "sst shell drizzle-kit studio" 11 | }, 12 | "devDependencies": { 13 | "bun-types": "latest" 14 | }, 15 | "dependencies": { 16 | "@aws-sdk/client-rds-data": "^3.654.0", 17 | "@openfeature/server-sdk": "^1.15.1", 18 | "@tsconfig/node20": "^20.1.4", 19 | "drizzle-kit": "^0.21.4", 20 | "drizzle-orm": "^0.30.10", 21 | "sst": "*", 22 | "zod": "^3.23.8" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import "sst" 5 | export {} 6 | declare module "sst" { 7 | export interface Resource { 8 | "FeatureFlagApi": { 9 | "name": string 10 | "type": "sst.aws.Function" 11 | "url": string 12 | } 13 | "FeatureFlagPostgres": { 14 | "clusterArn": string 15 | "database": string 16 | "host": string 17 | "password": string 18 | "port": number 19 | "secretArn": string 20 | "type": "sst.aws.Postgres" 21 | "username": string 22 | } 23 | "FeatureFlagVpc": { 24 | "type": "sst.aws.Vpc" 25 | } 26 | "IntervalApiKey": { 27 | "type": "sst.sst.Secret" 28 | "value": string 29 | } 30 | "IntervalApp": { 31 | "service": string 32 | "type": "sst.aws.Service" 33 | } 34 | "IntervalServerEndpoint": { 35 | "type": "sst.sst.Secret" 36 | "value": string 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/core/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import "sst" 5 | export {} 6 | declare module "sst" { 7 | export interface Resource { 8 | "FeatureFlagApi": { 9 | "name": string 10 | "type": "sst.aws.Function" 11 | "url": string 12 | } 13 | "FeatureFlagPostgres": { 14 | "clusterArn": string 15 | "database": string 16 | "host": string 17 | "password": string 18 | "port": number 19 | "secretArn": string 20 | "type": "sst.aws.Postgres" 21 | "username": string 22 | } 23 | "FeatureFlagVpc": { 24 | "type": "sst.aws.Vpc" 25 | } 26 | "IntervalApiKey": { 27 | "type": "sst.sst.Secret" 28 | "value": string 29 | } 30 | "IntervalApp": { 31 | "service": string 32 | "type": "sst.aws.Service" 33 | } 34 | "IntervalServerEndpoint": { 35 | "type": "sst.sst.Secret" 36 | "value": string 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/functions/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import "sst" 5 | export {} 6 | declare module "sst" { 7 | export interface Resource { 8 | "FeatureFlagApi": { 9 | "name": string 10 | "type": "sst.aws.Function" 11 | "url": string 12 | } 13 | "FeatureFlagPostgres": { 14 | "clusterArn": string 15 | "database": string 16 | "host": string 17 | "password": string 18 | "port": number 19 | "secretArn": string 20 | "type": "sst.aws.Postgres" 21 | "username": string 22 | } 23 | "FeatureFlagVpc": { 24 | "type": "sst.aws.Vpc" 25 | } 26 | "IntervalApiKey": { 27 | "type": "sst.sst.Secret" 28 | "value": string 29 | } 30 | "IntervalApp": { 31 | "service": string 32 | "type": "sst.aws.Service" 33 | } 34 | "IntervalServerEndpoint": { 35 | "type": "sst.sst.Secret" 36 | "value": string 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/interval/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import "sst" 5 | export {} 6 | declare module "sst" { 7 | export interface Resource { 8 | "FeatureFlagApi": { 9 | "name": string 10 | "type": "sst.aws.Function" 11 | "url": string 12 | } 13 | "FeatureFlagPostgres": { 14 | "clusterArn": string 15 | "database": string 16 | "host": string 17 | "password": string 18 | "port": number 19 | "secretArn": string 20 | "type": "sst.aws.Postgres" 21 | "username": string 22 | } 23 | "FeatureFlagVpc": { 24 | "type": "sst.aws.Vpc" 25 | } 26 | "IntervalApiKey": { 27 | "type": "sst.sst.Secret" 28 | "value": string 29 | } 30 | "IntervalApp": { 31 | "service": string 32 | "type": "sst.aws.Service" 33 | } 34 | "IntervalServerEndpoint": { 35 | "type": "sst.sst.Secret" 36 | "value": string 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/load-testing/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /* This file is auto-generated by SST. Do not edit. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import "sst" 5 | export {} 6 | declare module "sst" { 7 | export interface Resource { 8 | "FeatureFlagApi": { 9 | "name": string 10 | "type": "sst.aws.Function" 11 | "url": string 12 | } 13 | "FeatureFlagPostgres": { 14 | "clusterArn": string 15 | "database": string 16 | "host": string 17 | "password": string 18 | "port": number 19 | "secretArn": string 20 | "type": "sst.aws.Postgres" 21 | "username": string 22 | } 23 | "FeatureFlagVpc": { 24 | "type": "sst.aws.Vpc" 25 | } 26 | "IntervalApiKey": { 27 | "type": "sst.sst.Secret" 28 | "value": string 29 | } 30 | "IntervalApp": { 31 | "service": string 32 | "type": "sst.aws.Service" 33 | } 34 | "IntervalServerEndpoint": { 35 | "type": "sst.sst.Secret" 36 | "value": string 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2024 Ben Senescu 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /packages/functions/src/feature-flag-admin.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlag } from "@sst-feature-flag/core/feature-flag/index"; 2 | import { Hono } from "hono"; 3 | import { z } from "zod"; 4 | 5 | export module FeatureFlagAdminApi { 6 | export const route = new Hono() 7 | .get("/list", async (c) => { 8 | const { items } = await FeatureFlag.list({ isStatic: true }); 9 | return c.json({ 10 | result: items, 11 | }); 12 | }) 13 | .post("/", async (c) => { 14 | const input = await c.req.json(); 15 | const parsedInput = z 16 | .object({ 17 | flagKey: z.string(), 18 | description: z.string(), 19 | type: z.enum(["BOOLEAN", "STRING", "NUMBER", "STRUCTURED"]), 20 | isStatic: z.boolean(), 21 | staticValue: z.any().optional(), 22 | }) 23 | .parse(input); 24 | 25 | const valueMap = FeatureFlag.generateValueMap( 26 | parsedInput.type, 27 | parsedInput.staticValue, 28 | ); 29 | 30 | await FeatureFlag.create({ 31 | flagKey: parsedInput.flagKey, 32 | description: parsedInput.description, 33 | valueType: parsedInput.type, 34 | isStatic: parsedInput.isStatic, 35 | ...valueMap, 36 | }); 37 | 38 | return c.text("OK"); 39 | }) 40 | .delete("/:flagKey", async (c) => { 41 | const { flagKey } = c.req.param(); 42 | await FeatureFlag.__delete_test_flag(flagKey); 43 | return c.text("OK"); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export default $config({ 3 | app(input) { 4 | return { 5 | name: "sst-feature-flag", 6 | removal: input?.stage === "production" ? "retain" : "remove", 7 | home: "aws", 8 | }; 9 | }, 10 | async run() { 11 | const intervalApiKey = new sst.Secret( 12 | "IntervalApiKey", 13 | "GET_API_KEY_FROM_INTERVAL_DASHBOARD", 14 | ); 15 | const intervalServerEndpoint = new sst.Secret( 16 | "IntervalServerEndpoint", 17 | // This is the default endpoint for running Interval Server locally. If you're self hosting, set that as the value instead using `sst secret set` 18 | "wss://interval-sandbox.com/websocket", 19 | ); 20 | 21 | // TODO: Should we allow people to optionally specify a vpc? 22 | const vpc = new sst.aws.Vpc("FeatureFlagVpc"); 23 | const db = new sst.aws.Postgres("FeatureFlagPostgres", { vpc }); 24 | 25 | 26 | const api = new sst.aws.Function("FeatureFlagApi", { 27 | handler: "./packages/functions/src/index.handler", 28 | link: [db], 29 | url: true, 30 | streaming: !$dev, 31 | }); 32 | const cluster = new sst.aws.Cluster("IntervalCluster", { vpc }); 33 | 34 | cluster.addService("IntervalApp", { 35 | link: [db, intervalApiKey, intervalServerEndpoint], 36 | image: { 37 | dockerfile: "packages/interval/Dockerfile", 38 | }, 39 | dev: { 40 | command: "bun dev", 41 | }, 42 | }); 43 | 44 | return { 45 | Api: api.url, 46 | }; 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /packages/load-testing/src/test_evaluation_api.ts: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | import { check, sleep } from "k6"; 3 | 4 | export const options = { 5 | stages: [ 6 | // { duration: '2s', target: 1 }, 7 | { duration: "10s", target: 100 }, 8 | ], 9 | }; 10 | 11 | const FLAG_KEY = "k9-bool"; 12 | // This will be in output of `sst dev` and `sst deploy`. It is the domain that this API is deployed to. 13 | const baseUrl = "ENTER_API_URL_HERE"; 14 | const params = { 15 | headers: { 16 | "Content-Type": "application/json", 17 | }, 18 | }; 19 | 20 | export async function setup() { 21 | await http.post( 22 | `${baseUrl}/feature-flag/admin`, 23 | JSON.stringify({ 24 | flagKey: FLAG_KEY, 25 | description: "Test flag", 26 | type: "BOOLEAN", 27 | isStatic: true, 28 | staticValue: true, 29 | }), 30 | params, 31 | ); 32 | } 33 | 34 | export default async function () { 35 | const payload = JSON.stringify({ 36 | flagKey: FLAG_KEY, 37 | defaultValue: false, 38 | context: {}, 39 | }); 40 | 41 | const res = await http.post( 42 | `${baseUrl}/feature-flag/evaluate`, 43 | payload, 44 | params, 45 | ); 46 | 47 | check(res, { 48 | "success evaluation": (r) => { 49 | const isSuccess = r.status === 200; 50 | const isExpectedResponse = 51 | r.body === JSON.stringify({ value: true, reason: "STATIC" }); 52 | if (!isSuccess || !isExpectedResponse) { 53 | console.log(`STATUS: ${r.status} BODY: ${r.body}`); 54 | } 55 | return isSuccess && isExpectedResponse; 56 | }, 57 | }); 58 | sleep(1); 59 | } 60 | 61 | export async function teardown() { 62 | await http.del(`${baseUrl}/feature-flag/admin/${FLAG_KEY}`); 63 | } 64 | -------------------------------------------------------------------------------- /packages/functions/src/feature-flag-evaluation.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlagEvaluation } from "@sst-feature-flag/core/feature-flag-evaluation/index"; 2 | import { FeatureFlag } from "@sst-feature-flag/core/feature-flag/index"; 3 | import { Hono } from "hono"; 4 | import { z } from "zod"; 5 | 6 | export module FeatureFlagEvaluationApi { 7 | export const route = new Hono().post("/", async (c) => { 8 | const body = await c.req.json(); 9 | const { flagKey, defaultValue, context } = z 10 | .object({ 11 | flagKey: z.string(), 12 | defaultValue: z.any(), 13 | context: z.any(), 14 | }) 15 | .parse(body); 16 | 17 | if (!flagKey) { 18 | return c.text("flagKey is required", 400); 19 | } 20 | 21 | const evalDecision = await FeatureFlagEvaluation.genericFlagEvaluation( 22 | flagKey, 23 | defaultValue, 24 | context, 25 | ); 26 | // const { items } = await FeatureFlag.list({ isStatic: true }); 27 | return c.json(evalDecision); 28 | // return c.json({ 29 | // result: items.map(item => ({ 30 | // ...item, 31 | // booleanValue: item.booleanValue ?? undefined, 32 | // stringValue: item.stringValue ?? undefined, 33 | // numberValue: item.numberValue ?? undefined, 34 | // structuredValue: item.structuredValue ?? undefined, 35 | // createdAt: Number(item.createdAt), 36 | // updatedAt: Number(item.updatedAt), 37 | // })) 38 | // }) 39 | }); 40 | // .post("/", async (c) => { 41 | // const input = await c.req.json(); 42 | // const [flag] = await FeatureFlag.create(input); 43 | 44 | // const responseFlag = { 45 | // ...flag, 46 | // // TODO: Figure out how to unify these types. I think Drizzle is converting the types under the hood. 47 | // createdAt: Number(flag.createdAt), 48 | // updatedAt: Number(flag.updatedAt), 49 | // booleanValue: flag.booleanValue ?? undefined, 50 | // stringValue: flag.stringValue ?? undefined, 51 | // numberValue: flag.numberValue ?? undefined, 52 | // structuredValue: flag.structuredValue ?? undefined 53 | // }; 54 | // return c.json(responseFlag, 201); 55 | // }) 56 | } 57 | -------------------------------------------------------------------------------- /packages/core/src/feature-flag/feature-flag.sql.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pgTable, 3 | text, 4 | timestamp, 5 | serial, 6 | index, 7 | unique, 8 | boolean, 9 | jsonb, 10 | numeric, 11 | integer, 12 | } from "drizzle-orm/pg-core"; 13 | // export const featureModeEnum = pgEnum("mode", [ 14 | // "ENABLED", 15 | // "DISABLED", 16 | // "PREVIEW", 17 | // ]); 18 | 19 | export const featureFlagTable = pgTable( 20 | "feature_flag", 21 | { 22 | id: serial("id").primaryKey(), 23 | flagKey: text("flag_key").notNull().unique(), 24 | description: text("description").notNull(), 25 | valueType: text("value_type").notNull(), 26 | booleanValue: boolean("boolean_value"), 27 | stringValue: text("string_value"), 28 | numberValue: integer("number_value"), 29 | structuredValue: text("structured_value"), 30 | isDisabled: boolean("is_disabled").default(false).notNull(), 31 | isStatic: boolean("is_static").default(false).notNull(), 32 | archived: boolean("archived").default(false).notNull(), 33 | createdAt: timestamp("created_at").defaultNow().notNull(), 34 | updatedAt: timestamp("updated_at").defaultNow().notNull(), 35 | }, 36 | (table) => { 37 | return { 38 | flagKeyIndex: index("flag_key_index").on(table.flagKey), 39 | }; 40 | }, 41 | ); 42 | 43 | export const featureFlagMemberTable = pgTable( 44 | "feature_flag_member", 45 | { 46 | id: serial("id").primaryKey(), 47 | flagId: integer("flag_id") 48 | .notNull() 49 | .references(() => featureFlagTable.id), 50 | entityId: text("entity_id").notNull(), 51 | entityType: text("entity_type").notNull(), 52 | booleanValue: boolean("boolean_value"), 53 | stringValue: text("string_value"), 54 | numberValue: integer("number_value"), 55 | structuredValue: text("structured_value"), 56 | createdAt: timestamp("created_at").defaultNow().notNull(), 57 | updatedAt: timestamp("updated_at").defaultNow().notNull(), 58 | }, 59 | (table) => { 60 | return { 61 | entityIdIndex: index("flag_member_entity_id_idx").on(table.entityId), 62 | unq: unique().on(table.flagId, table.entityId, table.entityType), 63 | }; 64 | }, 65 | ); 66 | 67 | // export const eventTypeEnum = pgEnum("event_type", ["ADDED", "REMOVED"]); 68 | 69 | // This table is used to log changes to feature flag members. 70 | // We can show this in the UI to show the history of changes. 71 | export const featureFlagMemberLogTable = pgTable( 72 | "feature_flag_member_log", 73 | { 74 | id: serial("id").primaryKey(), 75 | flagId: integer("flag_id") // Change here 76 | .notNull() 77 | .references(() => featureFlagTable.id), // Change here 78 | entityId: text("entity_id").notNull(), 79 | entityType: text("entity_type").notNull(), 80 | booleanValue: boolean("boolean_value"), 81 | stringValue: text("string_value"), 82 | numberValue: integer("number_value"), 83 | structuredValue: text("structured_value"), 84 | event: text("event").notNull(), 85 | timestamp: timestamp("timestamp").defaultNow().notNull(), 86 | }, 87 | (table) => { 88 | return { 89 | entityIdIndex: index("flag_member_log_entity_id_idx").on(table.entityId), 90 | }; 91 | }, 92 | ); 93 | -------------------------------------------------------------------------------- /packages/functions/src/index.ts: -------------------------------------------------------------------------------- 1 | // import { OpenAPIHono } from "@hono/zod-openapi"; 2 | // // import { MiddlewareHandler } from "hono"; 3 | import { logger } from "hono/logger"; 4 | // import { VisibleError } from "@sst-feature-flag/core/error"; 5 | // // import { session } from "../session"; 6 | // import { handle, streamHandle } from "hono/aws-lambda"; 7 | // import { ZodError } from "zod"; 8 | // import { FeatureFlagAdminApi } from "./feature-flag-admin"; 9 | 10 | import { Hono } from "hono"; 11 | import { handle } from "hono/aws-lambda"; 12 | import { FeatureFlagAdminApi } from "./feature-flag-admin"; 13 | import { FeatureFlagEvaluationApi } from "./feature-flag-evaluation"; 14 | 15 | // // const auth: MiddlewareHandler = async (c, next) => { 16 | // // const authHeader = 17 | // // c.req.query("authorization") ?? c.req.header("authorization"); 18 | // // if (authHeader) { 19 | // // const match = authHeader.match(/^Bearer (.+)$/); 20 | // // if (!match) { 21 | // // throw new VisibleError( 22 | // // "input", 23 | // // "auth.token", 24 | // // "Bearer token not found or improperly formatted", 25 | // // ); 26 | // // } 27 | // // const bearerToken = match[1]; 28 | // // const result = await session.verify(bearerToken!); 29 | // // if (result.type === "user") { 30 | // // return ActorContext.with( 31 | // // { 32 | // // type: "user", 33 | // // properties: { 34 | // // userID: result.properties.userID, 35 | // // }, 36 | // // }, 37 | // // next, 38 | // // ); 39 | // // } 40 | // // } 41 | 42 | // // return ActorContext.with({ type: "public", properties: {} }, next); 43 | // // }; 44 | 45 | // const app = new OpenAPIHono(); 46 | // app 47 | // .use(logger(), async (c, next) => { 48 | // c.header("Cache-Control", "no-store"); 49 | // return next(); 50 | // }) 51 | // // .use(auth); 52 | // app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { 53 | // type: "http", 54 | // scheme: "bearer", 55 | // }); 56 | // // app.openAPIRegistry.registerComponent("schemas", "Product", {}); 57 | 58 | // const routes = app 59 | // // .route("/feature-flag/evaluate", ProductApi.route) 60 | // .route("/feature-flag/admin", FeatureFlagAdminApi.route) 61 | // .onError((error, c) => { 62 | // if (error instanceof VisibleError) { 63 | // return c.json( 64 | // { 65 | // code: error.code, 66 | // message: error.message, 67 | // }, 68 | // error.kind === "input" ? 400 : 401, 69 | // ); 70 | // } 71 | // console.error(error); 72 | // if (error instanceof ZodError) { 73 | // const e = error.errors[0]; 74 | // if (e) { 75 | // return c.json( 76 | // { 77 | // code: e?.code, 78 | // message: e?.message, 79 | // }, 80 | // 400, 81 | // ); 82 | // } 83 | // } 84 | // return c.json( 85 | // { 86 | // code: "internal", 87 | // message: "Internal server error!!!!!!", 88 | // }, 89 | // 500, 90 | // ); 91 | // }); 92 | 93 | // app.doc("/doc", () => ({ 94 | // openapi: "3.0.0", 95 | // info: { 96 | // title: "Terminal API", 97 | // version: "0.0.1", 98 | // }, 99 | // })); 100 | 101 | // export type Routes = typeof routes; 102 | // export const handler = streamHandle(app); 103 | // export const handler = process.env.SST_LIVE ? handle(app) : streamHandle(app)index.ts 104 | const app = new Hono() 105 | .use(logger(), async (c, next) => { 106 | c.header("Cache-Control", "no-store"); 107 | return next(); 108 | }) 109 | .route("/feature-flag/admin", FeatureFlagAdminApi.route) 110 | .route("/feature-flag/evaluate", FeatureFlagEvaluationApi.route); 111 | 112 | export const handler = handle(app); 113 | -------------------------------------------------------------------------------- /packages/load-testing/src/test_evaluation_api_complex.ts: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | import { check, sleep } from "k6"; 3 | 4 | export const options = { 5 | stages: [ 6 | // { duration: '1s', target: 1 }, 7 | { duration: "30s", target: 500 }, 8 | ], 9 | }; 10 | 11 | // This will be in output of `sst dev` and `sst deploy`. It is the domain that this API is deployed to. 12 | const baseUrl = "ENTER_API_URL_HERE"; 13 | const params = { 14 | headers: { 15 | "Content-Type": "application/json", 16 | }, 17 | }; 18 | 19 | const STATIC_VALUE_MAP = { 20 | BOOLEAN: true, 21 | STRING: "test_string", 22 | NUMBER: 3, 23 | STRUCTURED: { foo: "bar" }, 24 | }; 25 | 26 | const getTypeAndStaticValue = (i: number) => { 27 | switch (i % 4) { 28 | case 0: 29 | return { type: "BOOLEAN", staticValue: STATIC_VALUE_MAP["BOOLEAN"] }; 30 | case 1: 31 | return { type: "STRING", staticValue: STATIC_VALUE_MAP["STRING"] }; 32 | case 2: 33 | return { type: "NUMBER", staticValue: STATIC_VALUE_MAP["NUMBER"] }; 34 | case 3: 35 | return { 36 | type: "STRUCTURED", 37 | staticValue: STATIC_VALUE_MAP["STRUCTURED"], 38 | }; 39 | default: 40 | throw new Error("Invalid index"); 41 | } 42 | }; 43 | 44 | export async function setup() { 45 | // Create flags with random 10 character names and varied types 46 | const numFlags = 50; 47 | const flagKeyValues: Record = {}; 48 | for (let i = 0; i < numFlags; i++) { 49 | if (i % 10 === 0) { 50 | console.log(`Creating feature flag ${i} of ${numFlags}`); 51 | } 52 | const flagKey = Math.random().toString(36).substring(2, 12); 53 | const { type, staticValue } = getTypeAndStaticValue(i); 54 | 55 | await http.post( 56 | `${baseUrl}/feature-flag/admin`, 57 | JSON.stringify({ 58 | flagKey, 59 | description: "Test flag", 60 | type, 61 | isStatic: true, 62 | staticValue, 63 | }), 64 | params, 65 | ); 66 | 67 | flagKeyValues[flagKey] = staticValue; 68 | sleep(0.1); 69 | } 70 | return flagKeyValues; 71 | } 72 | 73 | export default async function (flagKeyValues: Record) { 74 | const flagKeys = Object.keys(flagKeyValues); 75 | // Call 10 feature flags randomly 76 | const randomFlagKeys = flagKeys.sort(() => 0.5 - Math.random()).slice(0, 10); 77 | 78 | for (const flagKey of randomFlagKeys) { 79 | const payload = JSON.stringify({ 80 | flagKey: flagKey, 81 | defaultValue: flagKeyValues[flagKey], // Use actual value as default for simplicty. We check the reason below so it's fine that these are the same. 82 | context: {}, 83 | }); 84 | 85 | const res = await http.post( 86 | `${baseUrl}/feature-flag/evaluate`, 87 | payload, 88 | params, 89 | ); 90 | 91 | const expectedValue = flagKeyValues[flagKey]; 92 | 93 | check(res, { 94 | "success evaluation": (r) => { 95 | const res = r.json() as { body?: string }; 96 | const data = JSON.parse(res?.body || "{}"); 97 | const isSuccess = 98 | r.status === 200 && 99 | JSON.stringify(data) === 100 | JSON.stringify({ value: expectedValue, reason: "STATIC" }); 101 | if (!isSuccess) { 102 | console.error( 103 | `Evaluation failed for flag ${flagKey}: STATUS ${r.status} BODY ${JSON.stringify(data)}`, 104 | ); 105 | } 106 | return isSuccess; 107 | }, 108 | }); 109 | } 110 | 111 | sleep(1); 112 | } 113 | 114 | export async function teardown(flagKeyValues: Record) { 115 | const flagKeys = Object.keys(flagKeyValues); 116 | 117 | // Delete all feature flags 118 | console.log(`Deleting ${flagKeys.length} feature flags`); 119 | for (let i = 0; i < flagKeys.length; i++) { 120 | const flagKey = flagKeys[i]; 121 | if (i % 10 === 0) { 122 | console.log(`Deleting feature flag ${i + 1} of ${flagKeys.length}`); 123 | } 124 | await http.del(`${baseUrl}/feature-flag/admin/${flagKey}`); 125 | sleep(0.1); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /packages/core/src/feature-flag-evaluation/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResolutionDetails, 3 | EvaluationContext, 4 | JsonValue, 5 | ErrorCode, 6 | } from "@openfeature/server-sdk"; 7 | import { db } from "../drizzle"; 8 | import { 9 | featureFlagMemberTable, 10 | featureFlagTable, 11 | } from "../feature-flag/feature-flag.sql"; 12 | import { eq, and } from "drizzle-orm"; 13 | import { z } from "zod"; 14 | 15 | function getValueKey(valueType: string) { 16 | switch (valueType.toUpperCase()) { 17 | case "BOOLEAN": 18 | return "booleanValue" as const; 19 | case "STRING": 20 | return "stringValue" as const; 21 | case "NUMBER": 22 | return "numberValue" as const; 23 | case "STRUCTURED": 24 | return "structuredValue" as const; 25 | default: 26 | throw new Error(`Invalid value type: ${valueType}`); 27 | } 28 | } 29 | 30 | const getFlagValue = ( 31 | value: T, 32 | valueType: "BOOLEAN" | "STRING" | "NUMBER" | "STRUCTURED", 33 | ) => { 34 | if (valueType === "STRUCTURED") { 35 | if (typeof value !== "string") { 36 | throw new Error(`Invalid structured value type: ${typeof value}`); 37 | } 38 | return JSON.parse(value); 39 | } 40 | return value; 41 | }; 42 | 43 | export module FeatureFlagEvaluation { 44 | export const genericFlagEvaluation = async < 45 | T extends boolean | string | number, 46 | >( 47 | flagKey: string, 48 | defaultValue: T, 49 | context: EvaluationContext, 50 | ) => { 51 | const [res] = await db 52 | .select() 53 | .from(featureFlagTable) 54 | .where( 55 | and( 56 | eq(featureFlagTable.flagKey, flagKey), 57 | eq(featureFlagTable.archived, false), 58 | ), 59 | ) 60 | .leftJoin( 61 | featureFlagMemberTable, 62 | eq(featureFlagTable.id, featureFlagMemberTable.flagId), 63 | ); 64 | 65 | const flag = res?.feature_flag 66 | 67 | if (!flag) { 68 | return { 69 | value: defaultValue, 70 | reason: "ERROR", 71 | errorCode: ErrorCode.FLAG_NOT_FOUND, 72 | }; 73 | } 74 | 75 | if (flag.isDisabled) { 76 | return { 77 | value: defaultValue, 78 | reason: "DISABLED", 79 | }; 80 | } 81 | 82 | const valueKey = getValueKey(flag.valueType); 83 | if (flag.isStatic) { 84 | const value = flag[valueKey] as T; 85 | const isValidStructuredValue = 86 | flag.valueType === "STRUCTURED" && 87 | typeof value === "string" && 88 | typeof defaultValue === "object"; 89 | if ( 90 | value === null || 91 | (typeof value !== typeof defaultValue && !isValidStructuredValue) 92 | ) { 93 | return { 94 | value: defaultValue, 95 | reason: "ERROR", 96 | errorCode: ErrorCode.TYPE_MISMATCH, 97 | }; 98 | } 99 | 100 | return { 101 | value: getFlagValue( 102 | flag[valueKey] as T, 103 | flag.valueType as "BOOLEAN" | "STRING" | "NUMBER" | "STRUCTURED", 104 | ), 105 | reason: "STATIC", 106 | }; 107 | } 108 | 109 | const contextSchema = z.object({ 110 | entityType: z.string(), 111 | entityId: z.string(), 112 | }); 113 | 114 | const parseTargetingSchemaResult = contextSchema.safeParse(context); 115 | 116 | if (!parseTargetingSchemaResult.success) { 117 | return { 118 | value: defaultValue, 119 | reason: "ERROR", 120 | errorCode: ErrorCode.TARGETING_KEY_MISSING, 121 | errorMessage: 122 | "Context must include both entityType and entityId for dynamic flags.", 123 | }; 124 | } 125 | 126 | const { entityType, entityId } = parseTargetingSchemaResult.data; 127 | 128 | const [flagMemberRes] = await db 129 | .select() 130 | .from(featureFlagMemberTable) 131 | .leftJoin( 132 | featureFlagMemberTable, 133 | eq(featureFlagTable.id, featureFlagMemberTable.flagId), 134 | ) 135 | .where( 136 | and( 137 | eq(featureFlagTable.flagKey, flagKey), 138 | eq(featureFlagMemberTable.entityType, entityType), 139 | eq(featureFlagMemberTable.entityId, entityId), 140 | ), 141 | ); 142 | 143 | const flagMember = flagMemberRes?.feature_flag_member; 144 | 145 | // Return default if there is no entry for the given target. This means that the entity hasn't 146 | // been explicitly targeted or excluded so we should opt to return the default value. 147 | if (!flagMember) { 148 | return { 149 | value: defaultValue, 150 | reason: "DEFAULT", 151 | }; 152 | } 153 | 154 | const value = flagMember[valueKey] as T; 155 | if (value === null || typeof value !== typeof defaultValue) { 156 | return { 157 | value: defaultValue, 158 | reason: "error", 159 | errorCode: ErrorCode.TYPE_MISMATCH, 160 | }; 161 | } 162 | 163 | // code to resolve boolean details 164 | return { 165 | value: getFlagValue( 166 | flagMember[valueKey] as T, 167 | flag.valueType as "BOOLEAN" | "STRING" | "NUMBER" | "STRUCTURED", 168 | ), 169 | reason: "TARGETING_MATCH", 170 | }; 171 | }; 172 | 173 | export const resolveBooleanEvaluation = async ( 174 | flagKey: string, 175 | defaultValue: boolean, 176 | context: EvaluationContext, 177 | ): Promise> => { 178 | return genericFlagEvaluation(flagKey, defaultValue, context); 179 | }; 180 | 181 | export const resolveStringEvaluation = ( 182 | flagKey: string, 183 | defaultValue: string, 184 | context: EvaluationContext, 185 | ): Promise> => { 186 | return genericFlagEvaluation(flagKey, defaultValue, context); 187 | }; 188 | 189 | export const resolveNumberEvaluation = ( 190 | flagKey: string, 191 | defaultValue: number, 192 | context: EvaluationContext, 193 | ): Promise> => { 194 | return genericFlagEvaluation(flagKey, defaultValue, context); 195 | }; 196 | 197 | export const resolveObjectEvaluation = ( 198 | flagKey: string, 199 | defaultValue: JsonValue, 200 | context: EvaluationContext, 201 | ): Promise> => { 202 | throw new Error("Not implemented"); 203 | // return genericFlagEvaluation( 204 | // flagKey, 205 | // defaultValue, 206 | // context, 207 | // "structuredValue" 208 | // ); 209 | }; 210 | } 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SST Feature Flag 2 | 3 | SST Feature Flag is a feature flagging service that you can deploy into your AWS account 4 | with only a few simple commands. 5 | 6 | Screenshot 2024-10-03 at 2 19 40 PM 7 | 8 | ## Table of Contents 9 | 10 | - [What's a Feature Flag?](#whats-a-feature-flag) 11 | - [OpenFeature](#openfeature) 12 | - [Prerequisites](#prerequisites) 13 | - [What is Interval?](#what-is-interval) 14 | - [Local Development](#local-development) 15 | - [Example Walkthroughs](#example-walkthroughs) 16 | - [Create a static feature flag and use Evaluation API](#create-a-static-feature-flag-and-use-evaluation-api) 17 | - [Create a dynamic feature flag and use Evaluation API](#create-a-dynamic-feature-flag-and-use-evaluation-api) 18 | - [Extra Resources](#extra-resources) 19 | - [Cost](#cost) 20 | - [More on Interval](#more-on-interval) 21 | - [interval-sandbox.com](#interval-sandboxcom) 22 | - [Self Hosting Interval](#self-hosting-interval) 23 | - [Todos](#todos) 24 | 25 | ## What's a Feature Flag? 26 | 27 | Per [openfeature.dev](https://openfeature.dev): 28 | 29 | > Feature flags are a software development technique that allows teams to enable, disable or change 30 | > the behavior of certain features or code paths in a product or service, without modifying the source code. 31 | 32 | Some examples: 33 | 34 | - A boolean flag to disable a feature that affects all users 35 | - A boolean flag that defaults to false except for internal testers/ beta users / organizations that are added to the flag 36 | - A structured flag (json) that has an array of white listed domains 37 | - It also supports string and number flags but I can't really think of a use case for those. Please add some suggestions! 38 | 39 | ## OpenFeature 40 | 41 | The API for evaluating flags follows the [openfeature.dev](https://openfeature.dev) spec. This is a project that 42 | defines a standard and sdk for flags so that people aren't tied to a specific sass vendor, oss project, 43 | or handrolled solution. 44 | 45 | The top priority Todo for this project is to create a provider for OpenFeature so that it is more 46 | certain that API is standard and so that you can easily switch to / from other Feature Flag solutions. 47 | 48 | ## Prerequisites 49 | 50 | 1. Configure AWS credentials on your machine 51 | 52 | - https://sst.dev/docs/iam-credentials/ 53 | - This is required for SST to be able to deploy the service. 54 | 55 | ## What is Interval? 56 | 57 | [Interval](https://interval.com) is a newly OSS project for for easily building internal tools. It is extremely nice for a project like this because 58 | it handles all the boring features like authentication, users, and permissions. It also makes it much easier to build simple UIs. You can use it to build arbitrary internal tools in addition to SST Feature Flag. 59 | 60 | For ease of testing, I've spun up a free sandbox you can use: https://interval-sandbox.com (not intended for production use). There are some more notes on Interval and this sandbox [below](#more-on-interval). 61 | 62 | ## Local Development 63 | 64 | You likely will want to do some testing locally before deploying to your production environment. 65 | 66 | Here are the steps to start the local development environment (if you're self hosting, use that url instead): 67 | 68 | 1. Clone this repo: `git clone git@github.com:bensenescu/sst-feature-flag.git` 69 | 1. `cd sst-feature-flag` 70 | 1. Create an account on the Interval Sandbox (or your self hosted version). 71 | 72 | - https://interval-sandbox.com 73 | 74 | 1. Get you Personal Development Key and copy it to your clipboard 75 | - https://interval-sandbox.com/dashboard/develop/keys 76 | 1. `npx sst secret set IntervalApiKey YOUR_API_KEY_HERE` 77 | 1. `npx sst dev` 78 | - Note the Api Url in the output. This will be used to call the evaluation API. 79 | 1. `nox sst shell -- drizzle-studio push` - This initializes the db. 80 | 81 | ## Example Walkthroughs 82 | 83 | ### Create a static feature flag and use Evaluation API 84 | 85 | 1. Go to https://interval-sandbox.com/dashboard/develop/ and you should `Feature Flags` as an option. 86 | 1. Create a feature flag called `test-bool-flag` with a boolean value of `true` 87 | 1. Test the evaluation API 88 | 89 | ``` 90 | curl [api_url_output_from_sst_dev]/feature-flag/evaluate \ 91 | --header 'Content-Type: application/json' \ 92 | --data '{ 93 | "flagKey": "test-bool-flag", 94 | "defaultValue": false, 95 | "context": {} 96 | }' 97 | ``` 98 | 99 | ### Create a dynamic feature flag and use Evaluation API 100 | 101 | 1. Create another boolean feature flag called `test-bool-flag-dynamic`, but this time make it dynamic 102 | 1. In the dynamic flag table, click the actions button (three dots) and select `Add members` 103 | 1. Add a member with an id `john` and type of `user`, set the flag value to `true` for them 104 | 1. Check if this user is in the flag with the evaluation API 105 | 106 | ``` 107 | curl [api_url_output_from_sst_dev]/feature-flag/evaluate \ 108 | --header 'Content-Type: application/json' \ 109 | --data '{ 110 | "flagKey": "test-bool-flag-dynamic", 111 | "defaultValue": false, 112 | "context": { 113 | "entityId": "john", 114 | "entityType": "user" 115 | } 116 | }' 117 | ``` 118 | 119 | Note: this will take a few minutes the first time you do it to spin up the vpc and database 120 | 121 | ## Deployment 122 | 123 | ### General Steps: 124 | 125 | 1. `npx sst secret set IntervalApiKey [live_key_value] --stage prod` 126 | 1. `npx sst secret set IntervalServerEndpoint [self_hosted_interval_endpoint] --stage prod` 127 | 1. `npx sst deploy --stage prod` 128 | 1. `nox sst shell --stage prod -- drizzle-studio push` - This initializes the db. 129 | 130 | ### Actual Steps 131 | 132 | 1. Self host interval as described in [#more-on-interval](#more-on-interval) 133 | 2. Get a "live key" for your production deploy 134 | 135 | If you don't need multiple team members to be able to easily manage flags, you can use your Personal Access Token in `prod` sst stage and `development` ui for managing flags. 136 | 137 | If you need multiple team members to manage flags, its a bit trickier. Basically, Interval recently shut down their cloud offering and the self hosting is still rough around the edges. You need to "confirm your email" to create a live key. Here are some options: 138 | 139 | 1. Set up PostMark as described in their docs. Postmark was not simple to set up so I didn't do it in the sandbox. 140 | 2. Share a user login in a password manager so that your team can all log in as that user to manage flags in "development" mode. 141 | 3. Manually enter an into the `email` field in the `UserEmailConfirmationToken` table. This is effectively the same as the email being confirmed. 142 | 143 | Once the TUI is built, this should all be easier so that you can just manage your flags with `sst shell -- sst-ff-tui`. Also, hopefully email sending becomes provider agnostic so that we can use SES instead of Postmark. 144 | 145 | ## Extra Resources 146 | 147 | - https://openfeature.dev 148 | - https://interval.com/docs 149 | 150 | ### Cost 151 | 152 | Since this currently requires Aurora Serverless which has a minimum capacity of 0.5 ACU, this will 153 | cost ~$1.57 per day to run the database. The rest of the costs are negligible / well under the free tier, 154 | but will scale with your usage as with all AWS services. 155 | 156 | - There is a todo item to add a cheaper database option using Cloudflare D1. 157 | - Thread on Aurora Serverless Pricing: https://repost.aws/questions/QUbtHMLZXiS4Kppi7KMIB5YQ/aurora-serverless-v2-minimum-cost-setup-for-development-environment 158 | 159 | ### More on Interval 160 | 161 | The interval server is responsible for rendering data sent to it from the Interval Client we've built in this project. See [packages/interval/src/index.ts](./packages/interval/src/index.ts). 162 | 163 | #### interval-sandbox.com 164 | 165 | For ease of testing this project out, I have spun up a free sandbox you can use: https://interval-sandbox.com. 166 | 167 | Please do not use this for any production use cases. I have this running on a small server as you can see in this repo: https://github.com/bensenescu/interval-sandbox. 168 | 169 | I may not keep this up indefinitely so don't rely on it. However, all the interval server does is store your user information so that you can access the UI. All feature flag data will be stored in the database spun up as part of this project i.e. even if you are using this and it goes away, it shouldn't be a big deal and you can just connect the new interval server you spin up and make an account there. 170 | 171 | #### Self Hosting Interval 172 | 173 | Here are the instructions for self hosting interval: https://github.com/interval/server 174 | 175 | - Note: there are some optional dependencies mentioned like emails and WorkOS for enterprise features that I don't have setup in my example. 176 | 177 | ### Todos 178 | 179 | - [ ] Add API key auth to the API 180 | - [ ] Create a provider for openfeature.dev 181 | - [ ] Add percentage rollout flags 182 | - This would allow you to specify that you want 20% of entities to have access to a flag. 183 | - [ ] Add option for using cloudflare for all infra 184 | - This should be substantially cheaper and scale to zero since you won't need RDS + a vpc. 185 | - [ ] Add TUI so that it isn't necessary to run Interval if you don't want to. 186 | - [ ] Add tests for core functionality 187 | -------------------------------------------------------------------------------- /packages/core/src/feature-flag/index.ts: -------------------------------------------------------------------------------- 1 | import { and, desc, eq, or } from "drizzle-orm"; 2 | 3 | import { db } from "../drizzle"; 4 | import { 5 | featureFlagMemberLogTable, 6 | featureFlagMemberTable, 7 | featureFlagTable, 8 | } from "./feature-flag.sql"; 9 | import { isJsonString } from "../utils"; 10 | 11 | interface Flag { 12 | flagKey: string; 13 | isStatic: boolean; 14 | description: string; 15 | valueType: string; 16 | isDisabled: boolean; 17 | booleanValue?: boolean; 18 | stringValue?: string; 19 | numberValue?: number; 20 | structuredValue?: string; 21 | createdAt: Date; 22 | updatedAt: Date; 23 | } 24 | 25 | interface FeatureFlagMember { 26 | flagKey: string; 27 | entityId: string; 28 | entityType: string; 29 | createdAt: Date; 30 | } 31 | 32 | export module FeatureFlag { 33 | // export const Flag = z.object({ 34 | // flagKey: z.string(), 35 | // isStatic: z.boolean(), 36 | // description: z.string(), 37 | // valueType: z.string(), 38 | // isDisabled: z.boolean(), 39 | // booleanValue: z.boolean().optional(), 40 | // stringValue: z.string().optional(), 41 | // numberValue: z.number().optional(), 42 | // structuredValue: z 43 | // .string() 44 | // .refine(isJsonString, { 45 | // message: "Invalid JSON string", 46 | // }) 47 | // .optional(), 48 | // createdAt: z.number(), 49 | // updatedAt: z.number(), 50 | // }); 51 | 52 | // export const FeatureFlagMember = z.object({ 53 | // flagKey: z.string(), 54 | // entityId: z.string(), 55 | // entityType: z.string(), 56 | // createdAt: z.number(), 57 | // }); 58 | 59 | // export type Flag = z.infer; 60 | // export type FeatureFlagMember = z.infer; 61 | export const generateValueMap = ( 62 | valueType: string, 63 | value: string | boolean | number, 64 | ) => { 65 | /* 66 | * Helper to generate the value map. 67 | */ 68 | let values: { 69 | booleanValue?: boolean; 70 | stringValue?: string; 71 | numberValue?: number; 72 | structuredValue?: string; 73 | } = { 74 | booleanValue: undefined, 75 | stringValue: undefined, 76 | numberValue: undefined, 77 | structuredValue: undefined, 78 | }; 79 | 80 | const typeMismatchError = (expected: string) => 81 | `Programming Error: Type mismatch. Expected ${expected} for ${valueType} type.`; 82 | 83 | switch (valueType.toUpperCase()) { 84 | case "BOOLEAN": 85 | if (typeof value === "string") { 86 | values.booleanValue = value === "True"; 87 | } else if (typeof value === "boolean") { 88 | values.booleanValue = value; 89 | } else { 90 | throw new Error( 91 | typeMismatchError("boolean or string 'True'/'False'"), 92 | ); 93 | } 94 | break; 95 | case "STRING": 96 | if (typeof value !== "string") { 97 | throw new Error(typeMismatchError("string")); 98 | } 99 | values.stringValue = value; 100 | break; 101 | case "NUMBER": 102 | if (typeof value !== "number") { 103 | throw new Error(typeMismatchError("number")); 104 | } 105 | values.numberValue = value; 106 | break; 107 | case "STRUCTURED": 108 | if (typeof value === "object") { 109 | values.structuredValue = JSON.stringify(value); 110 | } else if (typeof value === "string") { 111 | if (!isJsonString(value)) { 112 | throw new Error(typeMismatchError("string or javascript object")); 113 | } 114 | values.structuredValue = value; 115 | } else { 116 | throw new Error(typeMismatchError("string or javascript object")); 117 | } 118 | break; 119 | default: 120 | throw new Error(`Programming Error: Invalid value type: ${valueType}.`); 121 | } 122 | 123 | return values; 124 | }; 125 | 126 | // TODO: Add pagination, sorting, etc 127 | export const list = async (filters?: { isStatic?: boolean }) => { 128 | return { 129 | items: await db 130 | .select() 131 | .from(featureFlagTable) 132 | .where( 133 | and( 134 | eq(featureFlagTable.archived, false), 135 | filters?.isStatic !== undefined 136 | ? eq(featureFlagTable.isStatic, filters.isStatic) 137 | : undefined, 138 | ), 139 | ), 140 | }; 141 | }; 142 | 143 | export const get = async (flagKey: string) => { 144 | const flag = await db 145 | .select() 146 | .from(featureFlagTable) 147 | .where( 148 | and( 149 | eq(featureFlagTable.flagKey, flagKey), 150 | eq(featureFlagTable.archived, false), 151 | ), 152 | ); 153 | if (flag.length === 0) { 154 | return null; 155 | } 156 | return flag[0]; 157 | }; 158 | 159 | export const create = async ( 160 | input: Omit, 161 | ) => { 162 | const hasBooleanValue = 163 | input.valueType === "BOOLEAN" && input.booleanValue !== undefined; 164 | const hasStringValue = 165 | input.valueType === "STRING" && input.stringValue !== undefined; 166 | const hasNumberValue = 167 | input.valueType === "NUMBER" && input.numberValue !== undefined; 168 | const hasStructuredValue = 169 | input.valueType === "STRUCTURED" && input.structuredValue !== undefined; 170 | 171 | if ( 172 | input.isStatic && 173 | !( 174 | hasBooleanValue || 175 | hasStringValue || 176 | hasNumberValue || 177 | hasStructuredValue 178 | ) 179 | ) { 180 | throw new Error("Static value is required if the flag is static."); 181 | } 182 | 183 | return db.insert(featureFlagTable).values(input).returning(); 184 | }; 185 | 186 | export const update = async ( 187 | flagKey: string, 188 | input: Partial>, 189 | ) => { 190 | // TODO: What happens if the flag doesn't exist? 191 | await db 192 | .update(featureFlagTable) 193 | .set({ 194 | flagKey: input.flagKey, 195 | description: input.description, 196 | isDisabled: input.isDisabled, 197 | booleanValue: input.booleanValue, 198 | stringValue: input.stringValue, 199 | numberValue: input.numberValue, 200 | structuredValue: input.structuredValue, 201 | updatedAt: new Date(), 202 | }) 203 | .where( 204 | and( 205 | eq(featureFlagTable.flagKey, flagKey), 206 | eq(featureFlagTable.archived, false), 207 | ), 208 | ); 209 | }; 210 | 211 | export const archive = async (flagKey: string) => { 212 | return db 213 | .update(featureFlagTable) 214 | // Append "_archived" so that the flag key is free to be reused since they are unique 215 | .set({ archived: true, flagKey: `${flagKey}_archived` }) 216 | .where(eq(featureFlagTable.flagKey, flagKey)); 217 | }; 218 | 219 | // Only use this in tests. 220 | export const __delete_test_flag = async (flagKey: string) => { 221 | return db 222 | .delete(featureFlagTable) 223 | .where(eq(featureFlagTable.flagKey, flagKey)); 224 | }; 225 | 226 | export const addMembers = async ( 227 | flagKey: string, 228 | entityIds: string[], 229 | entityType: string, 230 | valueMap: { 231 | booleanValue?: boolean; 232 | stringValue?: string; 233 | numberValue?: number; 234 | structuredValue?: string; 235 | }, 236 | ) => { 237 | 238 | const flagRes = await db.select().from(featureFlagTable).where(eq(featureFlagTable.flagKey, flagKey)) 239 | 240 | 241 | const flag = flagRes?.[0] 242 | if (!flag) { 243 | throw new Error(`Flag ${flagKey} not found`); 244 | } 245 | 246 | // TODO: Make this a transaction 247 | const membersToAdd = entityIds.map((entityId) => ({ 248 | flagId: flag.id, 249 | entityId, 250 | entityType, 251 | booleanValue: valueMap.booleanValue, 252 | stringValue: valueMap.stringValue, 253 | numberValue: valueMap.numberValue, 254 | structuredValue: valueMap.structuredValue, 255 | })); 256 | 257 | // This onConflictDoNothing could be dangerous if featureFlagMember involves more data in the future. 258 | // Then, other updates outside of flagKey, entityId, and entityType may not get updated when 259 | // the unique constraint is violated. 260 | await db 261 | .insert(featureFlagMemberTable) 262 | .values(membersToAdd) 263 | .onConflictDoNothing(); 264 | 265 | const logEntries = membersToAdd.map((entry) => ({ 266 | ...entry, 267 | flagId: flag.id, 268 | event: "ADDED" as const, 269 | })); 270 | await db.insert(featureFlagMemberLogTable).values(logEntries); 271 | }; 272 | 273 | export const removeMembers = async ( 274 | input: Pick[], 275 | ) => { 276 | const flagKey = input[0]?.flagKey 277 | if (!flagKey) { 278 | throw new Error('No flag key provided'); 279 | } 280 | const flagRes = await db.select().from(featureFlagTable).where(eq(featureFlagTable.flagKey, flagKey)) 281 | 282 | const flag = flagRes[0] 283 | if (!flag) { 284 | throw new Error(`Flag ${flagKey} not found`); 285 | } 286 | // TODO: Make this a transaction 287 | const deletedMembers = await db 288 | .delete(featureFlagMemberTable) 289 | .where( 290 | or( 291 | ...input.map((entry) => 292 | and( 293 | eq(featureFlagMemberTable.flagId, flag.id), 294 | eq(featureFlagMemberTable.entityId, entry.entityId), 295 | ), 296 | ), 297 | ), 298 | ) 299 | .returning(); 300 | 301 | // TODO: If this fails, we should add it to a queue to be retried. 302 | const logEntries = deletedMembers.map((entry) => ({ 303 | flagId: flag.id, 304 | entityId: entry.entityId, 305 | entityType: entry.entityType, 306 | event: "REMOVED" as const, 307 | })); 308 | await db.insert(featureFlagMemberLogTable).values(logEntries); 309 | }; 310 | 311 | // TODO: Add pagination, sorting, etc. This should probably return a count as well. 312 | export const getMembers = async (flagKey: string) => { 313 | const flagRes = await db.select().from(featureFlagTable).where(eq(featureFlagTable.flagKey, flagKey)) 314 | 315 | const flag = flagRes?.[0] 316 | if (!flag) { 317 | throw new Error(`Flag ${flagKey} not found`); 318 | } 319 | return db 320 | .select() 321 | .from(featureFlagMemberTable) 322 | .where(eq(featureFlagMemberTable.flagId, flag.id)) 323 | .orderBy(desc(featureFlagMemberTable.createdAt)); 324 | }; 325 | 326 | export const getFlagsForEntity = async (entityId: string) => { 327 | return db 328 | .select() 329 | .from(featureFlagMemberTable) 330 | .where(eq(featureFlagMemberTable.entityId, entityId)) 331 | .orderBy(desc(featureFlagMemberTable.createdAt)); 332 | }; 333 | } 334 | -------------------------------------------------------------------------------- /packages/interval/src/routes/feature-flags.ts: -------------------------------------------------------------------------------- 1 | import { FeatureFlag } from "@sst-feature-flag/core/feature-flag/index"; 2 | import { isJsonString } from "@sst-feature-flag/core/utils"; 3 | import { Action, ctx, io, Layout, Page } from "@interval/sdk"; 4 | import { MaybeOptionalGroupIOPromise } from "@interval/sdk/dist/types"; 5 | 6 | const staticValueHelpText = 7 | "This value will be the value of the feature flag for all entities."; 8 | const dynamicValueHelpText = 9 | "This value will be the value of the feature flag for the currently selected entities."; 10 | 11 | const getValueInput = ({ 12 | isStatic, 13 | valueType, 14 | staticValueMap, 15 | }: { 16 | isStatic: boolean; 17 | valueType: string; 18 | staticValueMap?: { 19 | booleanValue?: boolean; 20 | stringValue?: string; 21 | numberValue?: number; 22 | structuredValue?: string; 23 | }; 24 | }): MaybeOptionalGroupIOPromise => { 25 | switch (valueType.toUpperCase()) { 26 | case "BOOLEAN": 27 | return io.select.single(isStatic ? "Static Value" : "Dynamic Value", { 28 | helpText: isStatic ? staticValueHelpText : dynamicValueHelpText, 29 | options: ["True", "False"], 30 | defaultValue: staticValueMap?.booleanValue === true ? "True" : "False", 31 | }); 32 | case "STRING": 33 | return io.input.text(isStatic ? "Static Value" : "Dynamic Value", { 34 | helpText: isStatic ? staticValueHelpText : dynamicValueHelpText, 35 | defaultValue: staticValueMap?.stringValue, 36 | }); 37 | case "NUMBER": 38 | return io.input.number(isStatic ? "Static Value" : "Dynamic Value", { 39 | helpText: isStatic ? staticValueHelpText : dynamicValueHelpText, 40 | defaultValue: staticValueMap?.numberValue, 41 | }); 42 | case "STRUCTURED": 43 | return io.input 44 | .text(isStatic ? "Static Value" : "Dynamic Value", { 45 | helpText: isStatic ? staticValueHelpText : dynamicValueHelpText, 46 | multiline: true, 47 | defaultValue: staticValueMap?.structuredValue, 48 | }) 49 | .validate((val) => { 50 | if (!isJsonString(val)) { 51 | return "Invalid JSON string."; 52 | } 53 | }); 54 | default: 55 | throw new Error("Invalid static type"); 56 | } 57 | }; 58 | 59 | const handleEditFlagInput = async (flagDetails: { 60 | inputLabel: string; 61 | isStatic: boolean; 62 | valueType: string; 63 | description: string; 64 | flagKey: string; 65 | isDisabled: boolean; // Add isDisabled property 66 | staticValueMap?: { 67 | booleanValue?: boolean; 68 | stringValue?: string; 69 | numberValue?: number; 70 | structuredValue?: string; 71 | }; 72 | }) => { 73 | ctx.log({ flagDetails }); 74 | if (flagDetails.isStatic) { 75 | const { 76 | choice, 77 | returnValue: { flagKey, description, staticValue, isDisabled }, // Add isDisabled 78 | } = await io 79 | .group({ 80 | flagKey: io.input.text("Key", { 81 | defaultValue: flagDetails.flagKey, 82 | }), 83 | description: io.input.text("Description", { 84 | defaultValue: flagDetails.description, 85 | multiline: true, 86 | }), 87 | staticValue: getValueInput({ 88 | isStatic: flagDetails.isStatic, 89 | valueType: flagDetails.valueType, 90 | staticValueMap: flagDetails.staticValueMap, 91 | }), 92 | isDisabled: io.select.single("Status", { 93 | // Add isDisabled input 94 | defaultValue: flagDetails.isDisabled ? "Disabled" : "Enabled", 95 | options: ["Enabled", "Disabled"], 96 | }), 97 | }) 98 | .withChoices(["Update", "Cancel"]); 99 | 100 | return { 101 | choice, 102 | returnValue: { 103 | flagKey, 104 | description, 105 | staticValue, 106 | isDisabled: isDisabled === "Disabled", 107 | }, 108 | }; // Map string to boolean 109 | } 110 | 111 | const { 112 | choice, 113 | returnValue: { flagKey, description, isDisabled }, // Add isDisabled 114 | } = await io 115 | .group({ 116 | flagKey: io.input.text("Key", { 117 | defaultValue: flagDetails.flagKey, 118 | }), 119 | description: io.input.text("Description", { 120 | defaultValue: flagDetails.description, 121 | multiline: true, 122 | }), 123 | isDisabled: io.select.single("Status", { 124 | // Add isDisabled input 125 | defaultValue: flagDetails.isDisabled ? "Disabled" : "Enabled", 126 | options: ["Enabled", "Disabled"], 127 | }), 128 | }) 129 | .withChoices(["Update", "Cancel"]); 130 | 131 | return { 132 | choice, 133 | returnValue: { 134 | flagKey, 135 | description, 136 | isDisabled: isDisabled === "Disabled", 137 | }, 138 | }; // Map string to boolean 139 | }; 140 | 141 | const renderValue = (row: { 142 | booleanValue: boolean | null; 143 | stringValue: string | null; 144 | numberValue: number | null; 145 | structuredValue: string | null; 146 | }) => { 147 | let value = ""; 148 | if (row.booleanValue !== null) { 149 | value = row.booleanValue ? "True" : "False"; 150 | } else if (row.stringValue !== null) { 151 | value = row.stringValue ?? ""; 152 | } else if (row.numberValue !== null) { 153 | value = row.numberValue?.toString() ?? ""; 154 | } else if (row.structuredValue !== null) { 155 | value = JSON.stringify(row.structuredValue); 156 | } 157 | return value.length > 50 ? value.substring(0, 47) + "..." : value; 158 | }; 159 | 160 | const renderStatus = (row: { isDisabled: boolean }) => { 161 | return row.isDisabled 162 | ? { 163 | label: "Disabled", 164 | highlightColor: "red" as const, 165 | } 166 | : { 167 | label: "Enabled", 168 | highlightColor: "green" as const, 169 | }; 170 | }; 171 | export default new Page({ 172 | name: "Feature Flags", 173 | routes: { 174 | create: new Action({ 175 | name: "Create Feature Flag", 176 | unlisted: true, 177 | handler: async () => { 178 | const { key, description, staticSelection, type } = await io.group({ 179 | key: io.input.text("Key"), 180 | description: io.input.text("Description"), 181 | staticSelection: io.select.single("Dynamic or Static?", { 182 | defaultValue: "Dynamic", 183 | helpText: 184 | "Dynamic flags can have members which have unique flag values. Static flags are the same regardless of context passed in.", 185 | options: ["Dynamic" as const, "Static" as const], 186 | }), 187 | type: io.select.single("Type", { 188 | defaultValue: "Boolean", 189 | helpText: 190 | "Boolean flags are the most common. Other types include String, Number, and Structured if you need more granular control.", 191 | options: [ 192 | "Boolean" as const, 193 | "String" as const, 194 | "Number" as const, 195 | "Structured" as const, 196 | ], 197 | }), 198 | }); 199 | 200 | const isStatic = staticSelection === "Static"; 201 | if (isStatic) { 202 | const staticValue = await getValueInput({ 203 | isStatic, 204 | valueType: type, 205 | }); 206 | const staticValues = FeatureFlag.generateValueMap(type, staticValue); 207 | 208 | await FeatureFlag.create({ 209 | flagKey: key, 210 | description, 211 | isStatic, 212 | ...staticValues, 213 | valueType: type.toUpperCase(), 214 | }); 215 | } else { 216 | await FeatureFlag.create({ 217 | flagKey: key, 218 | description, 219 | isStatic, 220 | valueType: type.toUpperCase(), 221 | }); 222 | } 223 | await ctx.redirect({ route: "featureFlags" }); 224 | }, 225 | }), 226 | edit: new Action({ 227 | name: "Edit Details", 228 | unlisted: true, 229 | handler: async () => { 230 | if (typeof ctx?.params?.key !== "string") { 231 | throw new Error( 232 | "Please navigate to this page from the Feature Flags table instead of from the sidebar.", 233 | ); 234 | } 235 | 236 | const flag = await FeatureFlag.get(ctx.params.key); 237 | if (!flag) { 238 | throw new Error("Feature flag not found"); 239 | } 240 | 241 | const { 242 | choice, 243 | returnValue: { flagKey, description, staticValue, isDisabled }, 244 | } = await handleEditFlagInput({ 245 | isStatic: flag.isStatic, 246 | valueType: flag.valueType, 247 | description: flag.description, 248 | flagKey: flag.flagKey, 249 | inputLabel: flag.isStatic ? "Static Value" : "Dynamic Value", 250 | staticValueMap: { 251 | booleanValue: 252 | flag.booleanValue === null ? undefined : flag.booleanValue, 253 | stringValue: flag.stringValue ?? undefined, 254 | numberValue: flag.numberValue ?? undefined, 255 | structuredValue: flag.structuredValue ?? undefined, 256 | }, 257 | isDisabled: flag.isDisabled, 258 | }); 259 | 260 | if (choice === "Cancel") { 261 | await ctx.redirect({ route: "featureFlags" }); 262 | return; 263 | } 264 | 265 | if (flag.isStatic) { 266 | const staticValues = FeatureFlag.generateValueMap( 267 | flag.valueType, 268 | staticValue, 269 | ); 270 | await FeatureFlag.update(flag.flagKey, { 271 | flagKey, 272 | description, 273 | isDisabled, 274 | ...staticValues, 275 | }); 276 | } else { 277 | await FeatureFlag.update(flag.flagKey, { 278 | flagKey, 279 | description, 280 | isDisabled, 281 | }); 282 | } 283 | 284 | // Redirect to the new edit page if the key changed since the key is the url param 285 | if (flag.flagKey !== flagKey) { 286 | await ctx.redirect({ 287 | route: "featureFlags/edit", 288 | params: { key: flagKey }, 289 | }); 290 | } 291 | }, 292 | }), 293 | archive: new Action({ 294 | name: "Archive", 295 | unlisted: true, 296 | handler: async () => { 297 | if (typeof ctx?.params?.key !== "string") { 298 | throw new Error( 299 | "Please navigate to this page from the Feature Flags table instead of from the sidebar.", 300 | ); 301 | } 302 | const shouldArchive = await io.confirm("Archive this feature flag?", { 303 | helpText: 304 | "This feature flag will be archived. If you check if an enitity is part of this feature flag, it will return false. To undo this action, you will need to manually update the archive value in the database.", 305 | }); 306 | 307 | if (!shouldArchive) { 308 | await ctx.redirect({ route: "featureFlags" }); 309 | return; 310 | } 311 | 312 | await FeatureFlag.archive(ctx.params.key); 313 | await ctx.redirect({ route: "featureFlags" }); 314 | }, 315 | }), 316 | "view-members": new Page({ 317 | name: "View Members", 318 | unlisted: true, 319 | handler: async () => { 320 | if (typeof ctx?.params?.key !== "string") { 321 | throw new Error( 322 | "Please navigate to this page from the Feature Flags table instead of from the sidebar.", 323 | ); 324 | } 325 | 326 | const members = await FeatureFlag.getMembers(ctx.params.key); 327 | 328 | if (members.length === 0) { 329 | return new Layout({ 330 | title: "View Members", 331 | children: [ 332 | io.display.heading("No members found", { level: 3 }), 333 | io.display.link("Add Members", { 334 | route: "featureFlags/add-members", 335 | params: { key: ctx.params.key }, 336 | }), 337 | ], 338 | }); 339 | } 340 | 341 | return new Layout({ 342 | title: `${ctx.params.key} - View Members`, 343 | menuItems: [ 344 | { 345 | label: "Remove Members", 346 | route: "featureFlags/remove-members", 347 | params: { key: ctx.params.key }, 348 | }, 349 | { 350 | label: "Add Members", 351 | route: "featureFlags/add-members", 352 | params: { key: ctx.params.key }, 353 | }, 354 | ], 355 | children: [ 356 | io.display.table("", { 357 | data: members, 358 | columns: [ 359 | { label: "Entity ID", accessorKey: "entityId" }, 360 | { label: "Entity Type", accessorKey: "entityType" }, 361 | { 362 | label: "Value", 363 | renderCell: (row) => renderValue(row), 364 | }, 365 | { label: "Created At", accessorKey: "createdAt" }, 366 | ], 367 | }), 368 | ], 369 | }); 370 | }, 371 | }), 372 | "add-members": new Action({ 373 | name: "Add Members", 374 | unlisted: true, 375 | handler: async () => { 376 | if (typeof ctx?.params?.key !== "string") { 377 | throw new Error( 378 | "Please navigate to this page from the Feature Flags table instead of from the sidebar.", 379 | ); 380 | } 381 | 382 | const flag = await FeatureFlag.get(ctx.params.key); 383 | if (!flag) { 384 | throw new Error("Feature flag not found"); 385 | } 386 | 387 | const [entityIdsInput, entityType, value] = await io.group([ 388 | io.input.text("Entity IDs", { 389 | multiline: true, 390 | helpText: 391 | "Enter a comma separated list of entity IDs or 1 per line.", 392 | }), 393 | io.input.text("Entity Type", { 394 | helpText: 395 | "Enter the type of entity that these IDs represent e.g. user, organization, etc.", 396 | }), 397 | getValueInput({ 398 | isStatic: flag.isStatic, 399 | valueType: flag.valueType, 400 | }), 401 | ]); 402 | 403 | // parse the entity ids input into an array 404 | const entityIds = entityIdsInput 405 | .split(/[\n,]/) // Split by newline or comma 406 | .map((id) => id.trim()) 407 | .filter((id) => id !== ""); 408 | 409 | const valueMap = FeatureFlag.generateValueMap(flag.valueType, value); 410 | await FeatureFlag.addMembers( 411 | ctx.params.key, 412 | entityIds, 413 | entityType, 414 | valueMap, 415 | ); 416 | await ctx.redirect({ 417 | route: "featureFlags/view-members", 418 | params: { key: ctx.params.key }, 419 | }); 420 | }, 421 | }), 422 | "remove-members": new Action({ 423 | name: "Remove Members", 424 | unlisted: true, 425 | handler: async () => { 426 | const flagKey = ctx?.params?.key; 427 | if (typeof flagKey !== "string") { 428 | throw new Error( 429 | "Please navigate to this page from the Feature Flags table instead of from the sidebar.", 430 | ); 431 | } 432 | 433 | const members = await FeatureFlag.getMembers(flagKey); 434 | 435 | if (members.length === 0) { 436 | throw new Error("No members to remove"); 437 | } 438 | 439 | const selectedMembers = await io.select.table( 440 | "Select members to remove", 441 | { 442 | data: members, 443 | minSelections: 1, 444 | maxSelections: members.length, 445 | columns: [ 446 | { label: "Entity ID", accessorKey: "entityId" }, 447 | { label: "Entity Type", accessorKey: "entityType" }, 448 | { 449 | label: "Value", 450 | renderCell: (row) => renderValue(row), 451 | }, 452 | { label: "Created At", accessorKey: "createdAt" }, 453 | ], 454 | }, 455 | ); 456 | 457 | await FeatureFlag.removeMembers( 458 | selectedMembers.map((member) => ({ 459 | flagKey: flagKey, 460 | entityId: member.entityId, 461 | })), 462 | ); 463 | 464 | await ctx.redirect({ 465 | route: "featureFlags/view-members", 466 | params: { key: ctx.params.key }, 467 | }); 468 | }, 469 | }), 470 | }, 471 | handler: async () => { 472 | const dynamicFlags = await FeatureFlag.list({ isStatic: false }); 473 | const staticFlags = await FeatureFlag.list({ isStatic: true }); 474 | return new Layout({ 475 | title: "Feature Flags", 476 | menuItems: [{ label: "Create Flag", route: "featureFlags/create" }], 477 | children: [ 478 | io.display.heading("🤹 Dynamic", { level: 3 }), 479 | io.display.table("", { 480 | data: dynamicFlags.items, 481 | columns: [ 482 | { 483 | label: "Key", 484 | renderCell: (row) => ({ 485 | label: row.flagKey, 486 | route: "featureFlags/edit", 487 | params: { key: row.flagKey }, 488 | }), 489 | }, 490 | { 491 | label: "Type", 492 | renderCell: (row) => { 493 | switch (row.valueType.toUpperCase()) { 494 | case "BOOLEAN": 495 | return "Boolean"; 496 | case "STRING": 497 | return "String"; 498 | case "NUMBER": 499 | return "Number"; 500 | case "STRUCTURED": 501 | return "Structured"; 502 | default: 503 | return "Unknown"; 504 | } 505 | }, 506 | }, 507 | { 508 | label: "Description", 509 | renderCell: (row) => { 510 | const description = row.description || ""; 511 | return description.length > 50 512 | ? `${description.slice(0, 50)}...` 513 | : description; 514 | }, 515 | }, 516 | { 517 | label: "Status", 518 | renderCell: (row) => renderStatus(row), 519 | }, 520 | { label: "Created At", accessorKey: "createdAt" }, 521 | { label: "Updated At", accessorKey: "updatedAt" }, 522 | ], 523 | rowMenuItems: (row) => [ 524 | { 525 | label: "Add Members", 526 | route: `featureFlags/add-members`, 527 | params: { key: row.flagKey }, 528 | }, 529 | { 530 | label: "Remove Members", 531 | route: `featureFlags/remove-members`, 532 | params: { key: row.flagKey }, 533 | }, 534 | { 535 | label: "View Members", 536 | route: `featureFlags/view-members`, 537 | params: { key: row.flagKey }, 538 | }, 539 | { 540 | label: "Edit Details", 541 | route: `featureFlags/edit`, 542 | params: { key: row.flagKey }, 543 | }, 544 | { 545 | label: "Archive", 546 | route: `featureFlags/archive`, 547 | params: { key: row.flagKey }, 548 | theme: "danger", 549 | }, 550 | ], 551 | }), 552 | io.display.heading("🗿 Static", { level: 3 }), 553 | io.display.table("", { 554 | data: staticFlags.items, 555 | columns: [ 556 | { 557 | label: "Key", 558 | renderCell: (row) => ({ 559 | label: row.flagKey, 560 | route: "featureFlags/edit", 561 | params: { key: row.flagKey }, 562 | }), 563 | }, 564 | { 565 | label: "Type", 566 | renderCell: (row) => { 567 | switch (row.valueType.toUpperCase()) { 568 | case "BOOLEAN": 569 | return "Boolean"; 570 | case "STRING": 571 | return "String"; 572 | case "NUMBER": 573 | return "Number"; 574 | case "STRUCTURED": 575 | return "Structured"; 576 | default: 577 | return "Unknown"; 578 | } 579 | }, 580 | }, 581 | { 582 | label: "Description", 583 | renderCell: (row) => { 584 | const description = row.description || ""; 585 | return description.length > 50 586 | ? `${description.slice(0, 50)}...` 587 | : description; 588 | }, 589 | }, 590 | { 591 | label: "Value", 592 | renderCell: (row) => renderValue(row), 593 | }, 594 | { 595 | label: "Status", 596 | renderCell: (row) => renderStatus(row), 597 | }, 598 | { label: "Created At", accessorKey: "createdAt" }, 599 | { label: "Updated At", accessorKey: "updatedAt" }, 600 | ], 601 | rowMenuItems: (row) => [ 602 | { 603 | label: "Edit Details", 604 | route: `featureFlags/edit`, 605 | params: { key: row.flagKey }, 606 | }, 607 | { 608 | label: "Archive", 609 | route: `featureFlags/archive`, 610 | params: { key: row.flagKey }, 611 | theme: "danger", 612 | }, 613 | ], 614 | }), 615 | ], 616 | }); 617 | }, 618 | }); 619 | --------------------------------------------------------------------------------