@brandtg/flapjack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/model.js ADDED
@@ -0,0 +1,365 @@
1
+ import MurmurHash3 from "imurmurhash";
2
+ const TABLE = "flapjack_feature_flag";
3
+ const COLUMNS = [
4
+ "id",
5
+ "name",
6
+ "everyone",
7
+ "percent",
8
+ "roles",
9
+ "groups",
10
+ "users",
11
+ "note",
12
+ "created",
13
+ "modified",
14
+ ];
15
+ function mapRow(row) {
16
+ return {
17
+ id: row.id,
18
+ name: row.name,
19
+ everyone: row.everyone ?? undefined,
20
+ percent: row.percent === null || row.percent === undefined
21
+ ? undefined
22
+ : Number(row.percent),
23
+ roles: row.roles && row.roles.length > 0 ? row.roles : undefined,
24
+ groups: row.groups && row.groups.length > 0
25
+ ? row.groups
26
+ : undefined,
27
+ users: row.users && row.users.length > 0 ? row.users : undefined,
28
+ note: row.note ?? undefined,
29
+ created: new Date(row.created),
30
+ modified: new Date(row.modified),
31
+ };
32
+ }
33
+ /**
34
+ * Model for managing feature flags stored in PostgreSQL.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * import { Pool } from "pg";
39
+ * import { FeatureFlagModel } from "@brandtg/flapjack";
40
+ *
41
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
42
+ * const featureFlags = new FeatureFlagModel(pool);
43
+ * ```
44
+ */
45
+ export class FeatureFlagModel {
46
+ db;
47
+ /**
48
+ * Creates a new FeatureFlagModel instance.
49
+ *
50
+ * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
55
+ * const model = new FeatureFlagModel(pool);
56
+ * ```
57
+ */
58
+ constructor(db) {
59
+ this.db = db;
60
+ }
61
+ /**
62
+ * Creates a new feature flag in the database.
63
+ *
64
+ * @param input - Feature flag configuration
65
+ * @param input.name - Unique name for the feature flag (required)
66
+ * @param input.everyone - Optional boolean to enable/disable for everyone (overrides all other settings)
67
+ * @param input.percent - Optional percentage rollout (0-99.9)
68
+ * @param input.roles - Optional list of roles that have this flag enabled
69
+ * @param input.groups - Optional list of user groups that have this flag enabled
70
+ * @param input.users - Optional list of specific user IDs that have this flag enabled
71
+ * @param input.note - Optional description of the flag's purpose
72
+ * @returns The created feature flag with generated id, created, and modified timestamps
73
+ *
74
+ * @throws Will throw an error if a flag with the same name already exists
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * const flag = await model.create({
79
+ * name: "new_checkout_flow",
80
+ * roles: ["admin"],
81
+ * note: "New checkout redesign",
82
+ * });
83
+ * ```
84
+ */
85
+ async create(input) {
86
+ const cols = ["name"];
87
+ const vals = [input.name];
88
+ if ("everyone" in input) {
89
+ cols.push("everyone");
90
+ vals.push(input.everyone ?? null);
91
+ }
92
+ if ("percent" in input) {
93
+ cols.push("percent");
94
+ vals.push(input.percent ?? null);
95
+ }
96
+ if ("roles" in input) {
97
+ cols.push("roles");
98
+ vals.push(input.roles ?? null);
99
+ }
100
+ if ("groups" in input) {
101
+ cols.push("groups");
102
+ vals.push(input.groups ?? null);
103
+ }
104
+ if ("users" in input) {
105
+ cols.push("users");
106
+ vals.push(input.users ?? null);
107
+ }
108
+ if ("note" in input) {
109
+ cols.push("note");
110
+ vals.push(input.note ?? null);
111
+ }
112
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
113
+ const sql = `INSERT INTO ${TABLE} (${cols.join(", ")}) VALUES (${placeholders}) RETURNING ${COLUMNS.join(", ")}`;
114
+ const res = await this.db.query(sql, vals);
115
+ return mapRow(res.rows[0]);
116
+ }
117
+ /**
118
+ * Retrieves a feature flag by its ID.
119
+ *
120
+ * @param id - The unique identifier of the feature flag
121
+ * @returns The feature flag if found, null otherwise
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const flag = await model.getById(123);
126
+ * if (flag) {
127
+ * console.log(`Flag ${flag.name} is configured`);
128
+ * }
129
+ * ```
130
+ */
131
+ async getById(id) {
132
+ const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = $1`;
133
+ const res = await this.db.query(sql, [id]);
134
+ if (res.rows.length === 0)
135
+ return null;
136
+ return mapRow(res.rows[0]);
137
+ }
138
+ /**
139
+ * Retrieves a feature flag by its name.
140
+ *
141
+ * @param name - The unique name of the feature flag
142
+ * @returns The feature flag if found, null otherwise
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * const flag = await model.getByName("new_checkout_flow");
147
+ * if (flag) {
148
+ * console.log(`Flag is ${flag.everyone ? 'enabled' : 'disabled'} for everyone`);
149
+ * }
150
+ * ```
151
+ */
152
+ async getByName(name) {
153
+ const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = $1`;
154
+ const res = await this.db.query(sql, [name]);
155
+ if (res.rows.length === 0)
156
+ return null;
157
+ return mapRow(res.rows[0]);
158
+ }
159
+ /**
160
+ * Retrieves all feature flags, ordered by ID.
161
+ *
162
+ * @returns Array of all feature flags in the database
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * const flags = await model.list();
167
+ * console.log(`Total flags: ${flags.length}`);
168
+ * flags.forEach(flag => {
169
+ * console.log(`${flag.name}: ${flag.everyone ?? 'conditional'}`);
170
+ * });
171
+ * ```
172
+ */
173
+ async list() {
174
+ const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} ORDER BY id`;
175
+ const res = await this.db.query(sql);
176
+ return res.rows.map(mapRow);
177
+ }
178
+ /**
179
+ * Updates an existing feature flag.
180
+ *
181
+ * @param id - The unique identifier of the feature flag to update
182
+ * @param changes - Object containing the fields to update
183
+ * @returns The updated feature flag if found, null otherwise
184
+ *
185
+ * @remarks
186
+ * The modified timestamp is automatically updated by a database trigger.
187
+ * Pass `null` for a field to clear it (e.g., `everyone: null` removes the override).
188
+ * If no changes are provided, returns the flag unchanged.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * // Gradually increase rollout percentage
193
+ * await model.update(flagId, { percent: 25 });
194
+ *
195
+ * // Enable for everyone
196
+ * await model.update(flagId, { everyone: true });
197
+ *
198
+ * // Remove everyone override, reverting to other rules
199
+ * await model.update(flagId, { everyone: null });
200
+ * ```
201
+ */
202
+ async update(id, changes) {
203
+ const sets = [];
204
+ const vals = [];
205
+ if ("name" in changes) {
206
+ sets.push(`name = $${sets.length + 1}`);
207
+ vals.push(changes.name ?? null);
208
+ }
209
+ if ("everyone" in changes) {
210
+ sets.push(`everyone = $${sets.length + 1}`);
211
+ vals.push(changes.everyone ?? null);
212
+ }
213
+ if ("percent" in changes) {
214
+ sets.push(`percent = $${sets.length + 1}`);
215
+ vals.push(changes.percent ?? null);
216
+ }
217
+ if ("roles" in changes) {
218
+ sets.push(`roles = $${sets.length + 1}`);
219
+ vals.push(changes.roles ?? null);
220
+ }
221
+ if ("groups" in changes) {
222
+ sets.push(`groups = $${sets.length + 1}`);
223
+ vals.push(changes.groups ?? null);
224
+ }
225
+ if ("users" in changes) {
226
+ sets.push(`users = $${sets.length + 1}`);
227
+ vals.push(changes.users ?? null);
228
+ }
229
+ if ("note" in changes) {
230
+ sets.push(`note = $${sets.length + 1}`);
231
+ vals.push(changes.note ?? null);
232
+ }
233
+ if (sets.length === 0) {
234
+ return this.getById(id);
235
+ }
236
+ const idParamIndex = sets.length + 1;
237
+ vals.push(id);
238
+ const sql = `UPDATE ${TABLE} SET ${sets.join(", ")} WHERE id = $${idParamIndex} RETURNING ${COLUMNS.join(", ")}`;
239
+ const res = await this.db.query(sql, vals);
240
+ if (res.rows.length === 0)
241
+ return null;
242
+ return mapRow(res.rows[0]);
243
+ }
244
+ /**
245
+ * Deletes a feature flag from the database.
246
+ *
247
+ * @param id - The unique identifier of the feature flag to delete
248
+ * @returns true if the flag was deleted, false if not found
249
+ *
250
+ * @example
251
+ * ```typescript
252
+ * const deleted = await model.delete(flagId);
253
+ * if (deleted) {
254
+ * console.log("Flag successfully removed");
255
+ * }
256
+ * ```
257
+ */
258
+ async delete(id) {
259
+ const res = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
260
+ return res.rowCount > 0;
261
+ }
262
+ /**
263
+ * Checks if a user belongs to any of the specified groups
264
+ */
265
+ isActiveForGroups(userGroups = [], flagGroups = []) {
266
+ if (flagGroups.length === 0)
267
+ return false;
268
+ return flagGroups.some((group) => userGroups.includes(group));
269
+ }
270
+ /**
271
+ * Computes the hash value for a user ID using MurmurHash3.
272
+ *
273
+ * @param userId - The user ID to hash
274
+ * @returns The hash value used for percentage bucketing
275
+ *
276
+ * @remarks
277
+ * This method is useful for debugging percentage rollouts.
278
+ * The hash value is consistent for the same user ID.
279
+ * The bucket is computed as `hash % 100`.
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const hash = await model.hashUserId("user_123");
284
+ * const bucket = hash % 100;
285
+ * console.log(`User bucket: ${bucket}`);
286
+ * // If bucket is 42 and percent is 50, user is in the rollout
287
+ * ```
288
+ */
289
+ async hashUserId(userId) {
290
+ return MurmurHash3(userId).result();
291
+ }
292
+ /**
293
+ * Checks if a feature flag is active for a user based on configured rules.
294
+ *
295
+ * @param params - Parameters for flag evaluation
296
+ * @param params.name - The name of the feature flag to check
297
+ * @param params.user - Optional user ID
298
+ * @param params.roles - Optional list of roles the user has
299
+ * @param params.groups - Optional list of groups the user belongs to
300
+ * @returns true if the flag is active for the user, false otherwise
301
+ *
302
+ * @remarks
303
+ * Evaluation order (first match wins):
304
+ * 1. Everyone override (if set to true/false, returns immediately)
305
+ * 2. User ID is in the users list
306
+ * 3. User belongs to any group in the groups list
307
+ * 4. User has any role in the roles list
308
+ * 5. User falls within the percentage rollout (based on consistent hashing)
309
+ * 6. Default: returns false
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * // Check for admin user
314
+ * const isActive = await model.isActiveForUser({
315
+ * name: "new_feature",
316
+ * user: "user_123",
317
+ * roles: ["admin"],
318
+ * groups: ["beta_testers"],
319
+ * });
320
+ *
321
+ * if (isActive) {
322
+ * // Show new feature
323
+ * } else {
324
+ * // Show old feature
325
+ * }
326
+ * ```
327
+ */
328
+ async isActiveForUser({ name, user, roles, groups, }) {
329
+ const flag = await this.getByName(name);
330
+ // No such flag
331
+ if (!flag) {
332
+ return false;
333
+ }
334
+ // Everyone Override: If everyone is true or false, return that value immediately
335
+ if (flag.everyone !== undefined && flag.everyone !== null) {
336
+ return flag.everyone;
337
+ }
338
+ // User-Specific Check: If user ID is in the users array, return true
339
+ if (user && flag.users && flag.users.includes(user)) {
340
+ return true;
341
+ }
342
+ // Group Check: If any of the user's groups match any group in the groups array, return true
343
+ if (groups && this.isActiveForGroups(groups, flag.groups)) {
344
+ return true;
345
+ }
346
+ // Role Check: If any of the user's roles match any role in the roles array, return true
347
+ if (flag.roles && roles) {
348
+ for (const role of roles) {
349
+ if (flag.roles.includes(role)) {
350
+ return true;
351
+ }
352
+ }
353
+ }
354
+ // Percentage Check: If percentage rollout applies to this user, return rollout result
355
+ if (user && flag.percent && flag.percent > 0) {
356
+ const userHash = await this.hashUserId(user);
357
+ const bucket = userHash % 100;
358
+ if (bucket < flag.percent) {
359
+ return true;
360
+ }
361
+ }
362
+ // Default: Return false
363
+ return false;
364
+ }
365
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Represents a feature flag used to enable or disable features in an application.
3
+ */
4
+ export type FeatureFlag = {
5
+ /** Unique identifier for the feature flag */
6
+ id: number;
7
+ /** Human readable of the feature flag */
8
+ name: string;
9
+ /** Flip this flag on or off for everyone, overriding all other settings */
10
+ everyone?: boolean | null;
11
+ /** Number between 0 and 99.9 for percentage rollout */
12
+ percent?: number;
13
+ /** List of roles that have this feature flag enabled */
14
+ roles?: string[];
15
+ /** List of user groups that have this feature flag enabled */
16
+ groups?: string[];
17
+ /** List of specific user IDs that have this feature flag enabled */
18
+ users?: string[];
19
+ /** Description of where this flag is used and what it does */
20
+ note?: string;
21
+ /** Date when the feature flag was created */
22
+ created: Date;
23
+ /** Date when the feature flag was last modified */
24
+ modified: Date;
25
+ };
26
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAC;IACX,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1B,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,OAAO,EAAE,IAAI,CAAC;IACd,mDAAmD;IACnD,QAAQ,EAAE,IAAI,CAAC;CAChB,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @type {import('node-pg-migrate').ColumnDefinitions | undefined}
3
+ */
4
+ export const shorthands = undefined;
5
+
6
+ /**
7
+ * @param pgm {import('node-pg-migrate').MigrationBuilder}
8
+ * @param run {() => void | undefined}
9
+ * @returns {Promise<void> | void}
10
+ */
11
+ export const up = (pgm) => {
12
+ // Create the flapjack_feature_flag table
13
+ pgm.createTable("flapjack_feature_flag", {
14
+ id: "id",
15
+ name: { type: "text", notNull: true, unique: true },
16
+ everyone: { type: "boolean" },
17
+ percent: {
18
+ type: "numeric(3,1)",
19
+ check: "percent >= 0 AND percent <= 99.9",
20
+ },
21
+ roles: { type: "text[]", default: pgm.func("'{}'::text[]") },
22
+ groups: { type: "text[]", default: pgm.func("'{}'::text[]") },
23
+ users: { type: "text[]", default: pgm.func("'{}'::text[]") },
24
+ note: { type: "text" },
25
+ created: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
26
+ modified: {
27
+ type: "timestamptz",
28
+ notNull: true,
29
+ default: pgm.func("now()"),
30
+ },
31
+ });
32
+
33
+ // Create a trigger to update the modified timestamp on row update
34
+ pgm.sql(`
35
+ CREATE OR REPLACE FUNCTION flapjack_set_modified_timestamp()
36
+ RETURNS trigger AS $$
37
+ BEGIN
38
+ NEW.modified = now();
39
+ RETURN NEW;
40
+ END;
41
+ $$ LANGUAGE plpgsql;
42
+ `);
43
+
44
+ // Attach the trigger to the flapjack_feature_flag table
45
+ pgm.sql(`
46
+ CREATE TRIGGER flapjack_feature_flag_set_modified
47
+ BEFORE UPDATE ON flapjack_feature_flag
48
+ FOR EACH ROW
49
+ EXECUTE FUNCTION flapjack_set_modified_timestamp();
50
+ `);
51
+ };
52
+
53
+ /**
54
+ * @param pgm {import('node-pg-migrate').MigrationBuilder}
55
+ * @param run {() => void | undefined}
56
+ * @returns {Promise<void> | void}
57
+ */
58
+ export const down = (pgm) => {
59
+ pgm.sql(`
60
+ DROP TRIGGER IF EXISTS flapjack_feature_flag_set_modified
61
+ ON flapjack_feature_flag;
62
+ `);
63
+ pgm.sql(`DROP FUNCTION IF EXISTS flapjack_set_modified_timestamp;`);
64
+ pgm.dropTable("flapjack_feature_flag");
65
+ };
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@brandtg/flapjack",
3
+ "version": "0.1.0",
4
+ "description": "A simple feature flags library with PostgreSQL integration",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "flapjack": "./dist/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "migrations",
21
+ "!dist/**/*.test.js",
22
+ "!dist/**/*.test.d.ts",
23
+ "!dist/**/*.test.d.ts.map"
24
+ ],
25
+ "scripts": {
26
+ "build": "npm run clean && npx tsc",
27
+ "clean": "rm -rf dist",
28
+ "dev": "npx tsc --watch",
29
+ "dev:env": "bash bin/dev/env.sh",
30
+ "dev:migrate": "npx dotenv-cli -e env -- node-pg-migrate up",
31
+ "dev:docker:up": "docker compose -f docker-compose.yml up -d",
32
+ "dev:docker:down": "docker compose -f docker-compose.yml down -v",
33
+ "create-migration": "npx node-pg-migrate create --",
34
+ "test": "npm run build && vitest run",
35
+ "typecheck": "tsc --noEmit",
36
+ "lint": "eslint .",
37
+ "format": "prettier --write .",
38
+ "prepublishOnly": "npm run lint && npm run test && npm run build",
39
+ "pack:dev": "npm run build && npm pack",
40
+ "smoke": "npm run build && node dist/cli.js --help && node dist/cli.js hash-user smoke"
41
+ },
42
+ "dependencies": {
43
+ "dotenv": "^16.4.5",
44
+ "imurmurhash": "^0.1.4",
45
+ "node-pg-migrate": "^8.0.3",
46
+ "pg": "^8.12.0",
47
+ "yargs": "^18.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^9.37.0",
51
+ "@types/imurmurhash": "^0.1.4",
52
+ "@types/pg": "^8.11.10",
53
+ "@types/yargs": "^17.0.33",
54
+ "dotenv-cli": "^10.0.0",
55
+ "eslint": "^9.37.0",
56
+ "prettier": "^3.6.2",
57
+ "typescript": "~5.9.3",
58
+ "typescript-eslint": "^8.45.0",
59
+ "vitest": "^3.2.4"
60
+ },
61
+ "keywords": [
62
+ "feature-flags",
63
+ "postgres",
64
+ "database",
65
+ "configuration"
66
+ ],
67
+ "repository": {
68
+ "type": "git",
69
+ "url": "git+https://github.com/brandtg/flapjack.git"
70
+ },
71
+ "bugs": {
72
+ "url": "https://github.com/brandtg/flapjack/issues"
73
+ },
74
+ "homepage": "https://github.com/brandtg/flapjack#readme",
75
+ "license": "BSD-3-Clause",
76
+ "author": "Greg Brandt",
77
+ "engines": {
78
+ "node": ">=18.0.0"
79
+ }
80
+ }