Skip to content

brandtg/flapjack

Repository files navigation

Flapjack

CI npm version License

A simple feature flags library with PostgreSQL integration, inspired by django-waffle.

Features

  • Multiple targeting strategies: Enable features for specific users, roles, groups, or percentage rollouts
  • External subject targeting: Assign flags (or flag groups) to external subjects like tenants or org IDs
  • Consistent hashing: Deterministic user bucketing for A/B testing and experimentation
  • PostgreSQL-backed: Reliable, transactional flag storage with your existing database
  • CLI included: Manage feature flags from the command line
  • TypeScript-first: Full type safety with comprehensive TypeScript definitions
  • Battle-tested: Comprehensive test suite with 31+ tests

Requirements

  • Node.js >= 18.0.0
  • PostgreSQL >= 10.0

Installation

Install the library

npm install @brandtg/flapjack

Apply the migrations

import { runMigrations } from "@brandtg/flapjack";

await runMigrations({
  // Connect to your PostgreSQL database
  databaseUrl: process.env.DATABASE_URL,
  // Use the same migrations table as your application
  migrationsTable: "pgmigrations",
});

Usage

import { Pool } from "pg";
import { FeatureFlagModel } from "@brandtg/flapjack";

// Connect to the database
const pool: Pool = getDatabasePool();
const featureFlags = new FeatureFlagModel(pool);

// Create a feature flag
// N.b. use a meaningful naming scheme like <feature>_<date>_<owner>
const flag = await featureFlags.create({
  name: "enable_new_checkout_20250101_gbrandt",
});

// Check if a feature flag is active for a user
const active = await featureFlag.isActiveForUser({
  name: "enable_new_checkout_20250101_gbrandt",
  user: "1234",
});

// Assign a flag to an external subject ID (for example, a tenant)
await featureFlags.addSubject(flag.id, "tenant:acme");

// Check if active for a broader context that includes external subject IDs
const activeForTenant = await featureFlags.isActiveForContext({
  name: "enable_new_checkout_20250101_gbrandt",
  user: "1234",
  subjects: ["tenant:acme"],
});

// Enable the feature flag for certain roles
await featureFlags.update(flag.id, {
  roles: ["admin", "staff"],
});

// Enable the feature flag for certain user groups
await featureFlags.update(flag.id, {
  groups: ["early_adopters"],
});

// Launch the flag to a certain percentage of users
await featureFlags.update(flag.id, {
  percent: 25,
});

// Launch the flag to everyone
await featureFlags.update(flag.id, {
  everyone: true,
});

// Disable the flag for everyone
await featureFlags.update(flag.id, {
  everyone: false,
});

// Move the flag back into normal state (other rules then apply)
await featureFlags.update(flag.id, {
  everyone: null,
});

How Feature Flag Evaluation Works

When checking if a feature flag is active for a user, Flapjack evaluates rules in the following order:

  1. Everyone Override (everyone: true/false): If set, immediately returns this value, ignoring all other rules
  2. User List (users: [...]): If the user ID is in this list, returns true
  3. Group Membership (groups: [...]): If the user belongs to any specified group, returns true
  4. External Subject Match (subjects: [...]): If any provided subject matches a direct flag subject or a flag-group subject, returns true
  5. Role Membership (roles: [...]): If the user has any specified role, returns true
  6. Percentage Rollout (percent: 0-99.9): Uses consistent hashing to deterministically bucket users
  7. Default: Returns false if no conditions are met

External Subject Targeting

Use subjects when identity/group membership is managed outside Flapjack (for example, tenant IDs from another system).

const groupModel = new FeatureFlagGroupModel(pool);
const flagModel = new FeatureFlagModel(pool);

const rolloutGroup = await groupModel.create({
  name: "checkout_rollout",
});

const checkoutFlag = await flagModel.create({
  name: "enable_checkout_v2",
});

await groupModel.addFeatureFlag(rolloutGroup.id, checkoutFlag.id);

// Attach external subject to all flags in this feature flag group
await groupModel.addSubject(rolloutGroup.id, "tenant:acme");

// You can also attach a subject directly to a single flag
await flagModel.addSubject(checkoutFlag.id, "tenant:beta");

const isActive = await flagModel.isActiveForContext({
  name: "enable_checkout_v2",
  user: "user_123",
  subjects: ["tenant:acme"],
});

Example Evaluation

// Flag configured with multiple rules
await featureFlags.create({
  name: "new_feature",
  roles: ["admin"],
  groups: ["beta_testers"],
  percent: 25,
});

// Admin user: ✓ enabled (matches role)
await featureFlags.isActiveForUser({
  name: "new_feature",
  user: "user_123",
  roles: ["admin"],
});

// Beta tester: ✓ enabled (matches group)
await featureFlags.isActiveForUser({
  name: "new_feature",
  user: "user_456",
  groups: ["beta_testers"],
});

// Regular user: ? maybe (depends on hash bucket)
await featureFlags.isActiveForUser({
  name: "new_feature",
  user: "user_789",
  roles: ["user"],
});

Organizing Flags with Tags

Tags are freeform labels for organizing flags without encoding that information in the flag name. They do not affect evaluation — they are purely organizational metadata. A common use is associating a release version with a flag, which can then be bumped via a database update without any code change.

// Tag a flag with a release version (and any other labels)
await featureFlags.create({
  name: "checkout_v2",
  tags: ["release:1.5.0", "checkout"],
});

// Find every flag shipping in a given release
const flags = await featureFlags.listByTag("release:1.5.0");

// Bump the release version later — no code change required
await featureFlags.update(flag.id, { tags: ["release:1.6.0", "checkout"] });

The equivalent CLI commands:

flapjack create --name checkout_v2 --tags release:1.5.0 checkout
flapjack list-by-tag release:1.5.0
flapjack update <id> --tags release:1.6.0 checkout
flapjack update <id> --clear-tags

Performance Considerations

⚠️ Important: Without caching, Flapjack queries the database on every isActiveForUser() call. For high-traffic applications, use the built-in caching layer:

Built-in Caching Layer

Flapjack includes a high-performance caching layer with TTL support:

import { Pool } from "pg";
import {
  FeatureFlagModel,
  FeatureFlagCache,
  InMemoryCache,
} from "@brandtg/flapjack";

// Set up the database model
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Create cached feature flags instance
const featureFlags = new FeatureFlagCache({
  model: new FeatureFlagModel(pool),
  cache: new InMemoryCache<boolean>(),
  ttl: 300, // Set TTL to 5 minutes (300 seconds)
});

// Use exactly the same as the model, but with automatic caching
const isActive = await featureFlags.isActiveForUser({
  name: "new_feature",
  user: "user_123",
  roles: ["admin"],
  groups: ["beta_testers"],
});

Cache Key Generation

The cache automatically generates deterministic keys using MurmurHash3 of the flag name and user parameters:

// These calls generate the same cache key:
await featureFlags.isActiveForUser({
  name: "test",
  roles: ["admin", "user"],
  groups: ["beta", "alpha"],
});

await featureFlags.isActiveForUser({
  name: "test",
  roles: ["user", "admin"], // Different order
  groups: ["alpha", "beta"], // Different order
});

Custom Cache Implementation

You can implement your own cache (Redis, Memcached, etc.) by implementing the Cache interface:

import type { Cache } from "@brandtg/flapjack";

class RedisCache implements Cache {
  private redis: RedisClient;

  constructor(redis: RedisClient) {
    this.redis = redis;
  }

  get(key: string): any {
    const value = this.redis.get(key);
    return value ? JSON.parse(value) : undefined;
  }

  set(key: string, value: any, ttl?: number): void {
    const serialized = JSON.stringify(value);
    if (ttl) {
      this.redis.setex(key, ttl, serialized);
    } else {
      this.redis.set(key, serialized);
    }
  }

  delete(key: string): void {
    this.redis.del(key);
  }
}

// Use your custom cache
const featureFlags = new FeatureFlagCache({
  model,
  cache: new RedisCache(redisClient),
  ttl: 300,
});

Database Connection Pooling

Always use connection pooling in production:

import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // Maximum pool size
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

const featureFlags = new FeatureFlagModel(pool);

Performance Tips

  • Cache flag configurations at the application level with a reasonable TTL (30-60 seconds)
  • Batch flag checks when possible to reduce round trips
  • Use database indexes: The default migration includes an index on name field
  • Monitor query performance: Feature flag checks should be <10ms in most cases
  • Consider read replicas for extremely high-traffic scenarios

Experimentation and A/B Testing

Flapjack's percentage rollout feature enables experimentation and A/B testing:

// Create an experiment: 50% of users see the new feature
await featureFlags.create({
  name: "checkout_redesign_experiment",
  percent: 50,
  note: "A/B test: new checkout flow vs. old",
});

// Users are consistently bucketed - same result every time
const isInExperiment = await featureFlags.isActiveForUser({
  name: "checkout_redesign_experiment",
  user: "user_123",
});

// Gradual rollout: Start at 5%, increase to 100% over time
await featureFlags.update(flagId, { percent: 5 });
// ... monitor metrics ...
await featureFlags.update(flagId, { percent: 25 });
// ... monitor metrics ...
await featureFlags.update(flagId, { percent: 100 });

How User Bucketing Works

  • Uses MurmurHash3 for consistent, deterministic hashing
  • Same user ID always maps to the same bucket (0-99)
  • Changing the user ID will change bucket assignment
  • Distribution is uniform across the user base

API Reference

FeatureFlagModel

create(input: CreateInput): Promise<FeatureFlag>

Creates a new feature flag.

getById(id: number, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>

Retrieves a feature flag by its ID. Archived flags are excluded unless includeArchived is true.

getByName(name: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>

Retrieves a feature flag by its name. Archived flags are excluded unless includeArchived is true.

list(options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>

Lists all feature flags, ordered by ID. Archived flags are excluded unless includeArchived is true.

listByTag(tag: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>

Lists all feature flags carrying the given tag (exact match), ordered by ID. Archived flags are excluded unless includeArchived is true.

update(id: number, changes: UpdateChanges): Promise<FeatureFlag | null>

Updates a feature flag. Returns the updated flag or null if not found.

delete(id: number): Promise<boolean>

Permanently deletes a feature flag and all its relationships. Returns true if deleted, false if not found. To hide a flag while keeping its history, use archive instead.

archive(id: number): Promise<FeatureFlag | null>

Archives (hides) a feature flag while keeping the row for historical/audit purposes. Archived flags are excluded from normal reads and evaluate as inactive, but remain retrievable via { includeArchived: true }. Returns the archived flag, or null if it does not exist or is already archived.

Archiving is irreversible (there is no unarchive) and the archived timestamp is immutable once set. The flag's name stays reserved permanently — a new flag cannot reuse it; create a new flag with a different name instead. The same archive(id) / includeArchived semantics apply to FeatureFlagGroupModel.

isActiveForUser(params): Promise<boolean>

Checks if a feature flag is active for a user based on the evaluation rules.

hashUserId(userId: string): Promise<number>

Returns the hash value used for percentage bucketing. Useful for debugging rollout distributions.

CLI Usage

Flapjack includes a CLI for managing feature flags:

# Set your database URL
export DATABASE_URL="postgresql://user:pass@localhost/dbname"

# Create a flag
flapjack create --name my_feature --roles admin --note "Admin-only feature"

# List all flags
flapjack list

# Include archived (hidden) flags in any read command
flapjack list --include-archived
flapjack get-by-name my_feature --include-archived

# List flags carrying a tag (e.g. a release version)
flapjack list-by-tag release:1.5.0

# Get a specific flag
flapjack get-by-name my_feature

# Check if active for a user
flapjack is-active my_feature --user user123 --roles admin

# Check if multiple flags are active for a user
flapjack are-active --names my_feature other_feature --user user123 --roles admin

# Check if active for context (with external subject IDs)
flapjack is-active-context my_feature --user user123 --subjects tenant:acme

# Check multiple flags for context
flapjack are-active-context --names my_feature other_feature --subjects tenant:acme

# Update a flag
flapjack update 1 --percent 50 --everyone false

# Clear specific fields
flapjack update 1 --clear-roles --clear-percent

# Delete a flag (permanent — destroys all metadata)
flapjack delete 1

# Archive a flag (hide it but keep its history; irreversible)
flapjack archive 1

# Add/remove/list subject mappings on a flag
flapjack add-subject 1 tenant:acme
flapjack remove-subject 1 tenant:acme
flapjack list-subjects 1
flapjack list-by-subject tenant:acme

# Feature flag groups
flapjack group-create --name checkout_rollout --note "Checkout launch cohort"
flapjack group-list
flapjack group-get 1
flapjack group-get-by-name checkout_rollout
flapjack group-update 1 --note "Updated"
flapjack group-delete 1
flapjack group-archive 1

# Group membership
flapjack group-add-flag 1 10
flapjack group-remove-flag 1 10
flapjack group-list-flags 1
flapjack group-list-for-flag 10

# Bulk update all flags in a group
flapjack group-update-all 1 --percent 25 --roles admin

# Group-level subject mappings
flapjack group-add-subject 1 tenant:acme
flapjack group-remove-subject 1 tenant:acme
flapjack group-list-subjects 1
flapjack group-list-by-subject tenant:acme

# Debug user bucketing
flapjack hash-user user123

Best Practices

Naming Conventions

Use descriptive names that include context:

// Good: Includes feature, date, and owner
"enable_new_checkout_20250101_gbrandt";
"experiment_ai_suggestions_20250115_team_growth";

// Avoid: Too generic
"new_feature";
"test_flag";

Gradual Rollouts

Always roll out features gradually:

  1. Start with internal users/roles (e.g., roles: ["admin", "staff"])
  2. Expand to beta testers (e.g., groups: ["beta_testers"])
  3. Percentage rollout (5% → 25% → 50% → 100%)
  4. Enable for everyone (e.g., everyone: true)
  5. After stable, remove the flag from code and database

Flag Lifecycle Management

// 1. Development: Admin only
await featureFlags.create({
  name: "new_feature_20250101",
  roles: ["admin"],
  note: "New feature in development",
});

// 2. Beta Testing
await featureFlags.update(flagId, {
  groups: ["beta_testers"],
});

// 3. Gradual Rollout
await featureFlags.update(flagId, { percent: 10 });
// Monitor, then increase...

// 4. Full Launch
await featureFlags.update(flagId, { everyone: true });

// 5. Cleanup (after feature is stable)
// Remove feature flag checks from code, then either:
//   - delete to remove it entirely, or
//   - archive to hide it while keeping its history for audit
await featureFlags.archive(flagId);
// (archive is irreversible and the name stays reserved permanently)

Error Handling

try {
  const isActive = await featureFlags.isActiveForUser({
    name: "my_feature",
    user: "user_123",
  });

  if (isActive) {
    // Show new feature
  } else {
    // Show old feature
  }
} catch (error) {
  // On error, fail closed (disable feature) or open (enable feature)
  // depending on your risk tolerance
  console.error("Feature flag check failed:", error);
  const isActive = false; // Fail closed - safer default
}

Troubleshooting

Database Connection Issues

// Verify connection before using feature flags
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

try {
  await pool.query("SELECT 1");
  console.log("Database connection successful");
} catch (error) {
  console.error("Database connection failed:", error);
}

Flag Not Found

If isActiveForUser() returns false unexpectedly:

  1. Verify the flag exists: flapjack get-by-name your_flag_name
  2. Check the evaluation rules with flapjack is-active
  3. Verify user/role/group matching is case-sensitive

Percentage Rollout Not Working

Debug user bucketing:

# Check which bucket a user falls into
flapjack hash-user user_123
# Output: { userId: "user_123", hash: 1234567, bucket: 67 }

# If bucket is 67 and percent is 50, user is NOT in rollout
# If bucket is 67 and percent is 75, user IS in rollout

Development

Setup

Install dependencies

npm install

Create an environment file:

npm run dev:env

Start the PostgreSQL database:

npm run dev:docker:up

Run database migrations:

npm run dev:migrate

Running Tests

npm run test

Database Management

Create a new migration:

npm run create-migration -- migration_name

Reset the database:

npm run dev:docker:down

Building and Publishing

Development Build

To build the project for development and testing:

npm run build

This compiles TypeScript to JavaScript and generates type declaration files in the dist/ directory.

Creating a Development Package

To create a tarball package that can be installed manually in other projects:

npm run pack:dev

This will create a flapjack-0.1.0.tgz file that you can install in another project using:

npm install /path/to/flapjack-0.1.0.tgz

Publishing to npm

Before publishing to npm, make sure your package is ready:

  1. Login to npm (one-time setup):

    npm login

    This will prompt for your username, password, email, and 2FA code.

  2. Verify your login:

    npm whoami
  3. Update the version in package.json:

    # For a patch release (0.1.0 -> 0.1.1)
    npm version patch
    
    # For a minor release (0.1.0 -> 0.2.0)
    npm version minor
    
    # For a major release (0.1.0 -> 1.0.0)
    npm version major

    This automatically creates a git commit and tag.

  4. Run pre-publish checks (linting, tests, and build):

    npm run prepublishOnly
  5. Publish to npm:

    npm publish --access public

    You'll be prompted for your 2FA code. For a dry run to see what would be published:

    npm publish --access public --dry-run
  6. Push the version tag to GitHub:

    git push && git push --tags
  7. Optional: Create a GitHub release for the new version at https://github.com/brandtg/flapjack/releases/new

Publishing a Beta Version

For pre-release versions:

# Update to a pre-release version
npm version prerelease --preid=beta

# Publish with beta tag
npm publish --access public --tag beta

Users can install beta versions with:

npm install @brandtg/flapjack@beta

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the BSD 3-Clause License - see the LICENSE file for details.

This project is inspired by django-waffle.

About

Feature Flags

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors