@brandtg/flapjack 0.1.0 → 0.2.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/README.md CHANGED
@@ -140,49 +140,97 @@ await featureFlags.isActiveForUser({
140
140
 
141
141
  ## Performance Considerations
142
142
 
143
- ⚠️ **Important**: Flapjack queries the database on every `isActiveForUser()` call. For high-traffic applications, consider:
143
+ ⚠️ **Important**: Without caching, Flapjack queries the database on every `isActiveForUser()` call. For high-traffic applications, use the built-in caching layer:
144
144
 
145
- ### Recommended Caching Strategy
145
+ ### Built-in Caching Layer
146
+
147
+ Flapjack includes a high-performance caching layer with TTL support:
146
148
 
147
149
  ```typescript
148
150
  import { Pool } from "pg";
149
- import { FeatureFlagModel } from "@brandtg/flapjack";
151
+ import {
152
+ FeatureFlagModel,
153
+ FeatureFlagCache,
154
+ InMemoryCache,
155
+ } from "@brandtg/flapjack";
156
+
157
+ // Set up the database model
158
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
159
+
160
+ // Create cached feature flags instance
161
+ const featureFlags = new FeatureFlagCache({
162
+ model: new FeatureFlagModel(pool),
163
+ cache: new InMemoryCache<boolean>(),
164
+ ttl: 300, // Set TTL to 5 minutes (300 seconds)
165
+ });
150
166
 
151
- // Simple in-memory cache with TTL
152
- class CachedFeatureFlags {
153
- private model: FeatureFlagModel;
154
- private cache = new Map<string, { flag: any; expires: number }>();
155
- private ttlMs = 60000; // 1 minute
167
+ // Use exactly the same as the model, but with automatic caching
168
+ const isActive = await featureFlags.isActiveForUser({
169
+ name: "new_feature",
170
+ user: "user_123",
171
+ roles: ["admin"],
172
+ groups: ["beta_testers"],
173
+ });
174
+ ```
156
175
 
157
- constructor(pool: Pool) {
158
- this.model = new FeatureFlagModel(pool);
176
+ ### Cache Key Generation
177
+
178
+ The cache automatically generates deterministic keys using MurmurHash3 of the flag name and user parameters:
179
+
180
+ ```typescript
181
+ // These calls generate the same cache key:
182
+ await featureFlags.isActiveForUser({
183
+ name: "test",
184
+ roles: ["admin", "user"],
185
+ groups: ["beta", "alpha"],
186
+ });
187
+
188
+ await featureFlags.isActiveForUser({
189
+ name: "test",
190
+ roles: ["user", "admin"], // Different order
191
+ groups: ["alpha", "beta"], // Different order
192
+ });
193
+ ```
194
+
195
+ ### Custom Cache Implementation
196
+
197
+ You can implement your own cache (Redis, Memcached, etc.) by implementing the `Cache` interface:
198
+
199
+ ```typescript
200
+ import type { Cache } from "@brandtg/flapjack";
201
+
202
+ class RedisCache implements Cache {
203
+ private redis: RedisClient;
204
+
205
+ constructor(redis: RedisClient) {
206
+ this.redis = redis;
159
207
  }
160
208
 
161
- async isActiveForUser(params: {
162
- name: string;
163
- user?: string;
164
- roles?: string[];
165
- groups?: string[];
166
- }): Promise<boolean> {
167
- const now = Date.now();
168
- const cached = this.cache.get(params.name);
169
-
170
- // Use cached flag if still valid
171
- if (cached && cached.expires > now) {
172
- // Re-evaluate with cached flag data
173
- return this.evaluateLocally(cached.flag, params);
174
- }
209
+ get(key: string): any {
210
+ const value = this.redis.get(key);
211
+ return value ? JSON.parse(value) : undefined;
212
+ }
175
213
 
176
- // Cache miss or expired - fetch from DB
177
- return await this.model.isActiveForUser(params);
214
+ set(key: string, value: any, ttl?: number): void {
215
+ const serialized = JSON.stringify(value);
216
+ if (ttl) {
217
+ this.redis.setex(key, ttl, serialized);
218
+ } else {
219
+ this.redis.set(key, serialized);
220
+ }
178
221
  }
179
222
 
180
- private evaluateLocally(flag: any, params: any): boolean {
181
- // Implement evaluation logic locally to avoid DB queries
182
- // See model.ts isActiveForUser() for reference
183
- // ...
223
+ delete(key: string): void {
224
+ this.redis.del(key);
184
225
  }
185
226
  }
227
+
228
+ // Use your custom cache
229
+ const featureFlags = new FeatureFlagCache({
230
+ model,
231
+ cache: new RedisCache(redisClient),
232
+ ttl: 300,
233
+ });
186
234
  ```
187
235
 
188
236
  ### Database Connection Pooling
@@ -432,6 +480,12 @@ flapjack hash-user user_123
432
480
 
433
481
  ### Setup
434
482
 
483
+ Install dependencies
484
+
485
+ ```bash
486
+ npm install
487
+ ```
488
+
435
489
  Create an environment file:
436
490
 
437
491
  ```bash
@@ -453,7 +507,7 @@ npm run dev:migrate
453
507
  ### Running Tests
454
508
 
455
509
  ```bash
456
- npm test
510
+ npm run test
457
511
  ```
458
512
 
459
513
  ### Database Management
@@ -0,0 +1,48 @@
1
+ import { FeatureFlagModel } from "./model.js";
2
+ export interface Cache<T> {
3
+ get(key: string): T | undefined;
4
+ set(key: string, value: T, ttl?: number): void;
5
+ delete(key: string): void;
6
+ }
7
+ /**
8
+ * Simple in-memory cache with TTL support.
9
+ */
10
+ export declare class InMemoryCache<T> implements Cache<T> {
11
+ private cache;
12
+ get(key: string): T | undefined;
13
+ set(key: string, value: T, ttl?: number): void;
14
+ delete(key: string): void;
15
+ /**
16
+ * Clear all expired entries from the cache.
17
+ */
18
+ clearExpired(): void;
19
+ /**
20
+ * Get the current size of the cache (including expired entries).
21
+ */
22
+ size(): number;
23
+ /**
24
+ * Clear all entries from the cache.
25
+ */
26
+ clear(): void;
27
+ }
28
+ export declare class FeatureFlagCache {
29
+ private model;
30
+ private cache;
31
+ private ttl;
32
+ constructor({ model, cache, ttl, }: {
33
+ model: FeatureFlagModel;
34
+ cache: Cache<boolean>;
35
+ ttl?: number;
36
+ });
37
+ /**
38
+ * Generates a cache key using murmur3 hash of the flag name and parameters.
39
+ */
40
+ private generateCacheKey;
41
+ isActiveForUser({ name, user, roles, groups, }: {
42
+ name: string;
43
+ user?: string;
44
+ roles?: string[];
45
+ groups?: string[];
46
+ }): Promise<boolean>;
47
+ }
48
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAAC;IAChC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AASD;;GAEG;AACH,qBAAa,aAAa,CAAC,CAAC,CAAE,YAAW,KAAK,CAAC,CAAC,CAAC;IAC/C,OAAO,CAAC,KAAK,CAAoC;IAEjD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAgB/B,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI;IAK9C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIzB;;OAEG;IACH,YAAY,IAAI,IAAI;IASpB;;OAEG;IACH,IAAI,IAAI,MAAM;IAId;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,GAAG,CAAS;gBAER,EACV,KAAK,EACL,KAAK,EACL,GAAiB,GAClB,EAAE;QACD,KAAK,EAAE,gBAAgB,CAAC;QACxB,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACtB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd;IAMD;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAyBlB,eAAe,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,GACP,EAAE;QACD,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;KACnB,GAAG,OAAO,CAAC,OAAO,CAAC;CAuBrB"}
package/dist/cache.js ADDED
@@ -0,0 +1,96 @@
1
+ import MurmurHash3 from "imurmurhash";
2
+ const DEFAULT_TTL = 60 * 5; // 5 minutes
3
+ /**
4
+ * Simple in-memory cache with TTL support.
5
+ */
6
+ export class InMemoryCache {
7
+ cache = new Map();
8
+ get(key) {
9
+ const entry = this.cache.get(key);
10
+ if (!entry) {
11
+ return undefined;
12
+ }
13
+ const now = Date.now();
14
+ if (entry.expiresAt !== undefined && now >= entry.expiresAt) {
15
+ // Entry has expired, remove it
16
+ this.cache.delete(key);
17
+ return undefined;
18
+ }
19
+ return entry.value;
20
+ }
21
+ set(key, value, ttl) {
22
+ const expiresAt = ttl !== undefined ? Date.now() + ttl * 1000 : undefined;
23
+ this.cache.set(key, { value, expiresAt });
24
+ }
25
+ delete(key) {
26
+ this.cache.delete(key);
27
+ }
28
+ /**
29
+ * Clear all expired entries from the cache.
30
+ */
31
+ clearExpired() {
32
+ const now = Date.now();
33
+ for (const [key, entry] of this.cache.entries()) {
34
+ if (entry.expiresAt !== undefined && now >= entry.expiresAt) {
35
+ this.cache.delete(key);
36
+ }
37
+ }
38
+ }
39
+ /**
40
+ * Get the current size of the cache (including expired entries).
41
+ */
42
+ size() {
43
+ return this.cache.size;
44
+ }
45
+ /**
46
+ * Clear all entries from the cache.
47
+ */
48
+ clear() {
49
+ this.cache.clear();
50
+ }
51
+ }
52
+ export class FeatureFlagCache {
53
+ model;
54
+ cache;
55
+ ttl;
56
+ constructor({ model, cache, ttl = DEFAULT_TTL, }) {
57
+ this.model = model;
58
+ this.cache = cache;
59
+ this.ttl = ttl;
60
+ }
61
+ /**
62
+ * Generates a cache key using murmur3 hash of the flag name and parameters.
63
+ */
64
+ generateCacheKey({ name, user, roles, groups, }) {
65
+ // Create a consistent string representation of the parameters
66
+ const keyParts = [
67
+ name,
68
+ user || "",
69
+ (roles || []).sort().join(","),
70
+ (groups || []).sort().join(","),
71
+ ];
72
+ const keyString = keyParts.join("|");
73
+ // Generate murmur3 hash
74
+ const hash = MurmurHash3(keyString).result();
75
+ return `flag:${hash}`;
76
+ }
77
+ async isActiveForUser({ name, user, roles, groups, }) {
78
+ // Generate cache key
79
+ const cacheKey = this.generateCacheKey({ name, user, roles, groups });
80
+ // Try to get from cache first
81
+ const cachedResult = this.cache.get(cacheKey);
82
+ if (cachedResult !== undefined) {
83
+ return cachedResult;
84
+ }
85
+ // Cache miss, get from model
86
+ const result = await this.model.isActiveForUser({
87
+ name,
88
+ user,
89
+ roles,
90
+ groups,
91
+ });
92
+ // Store in cache with TTL
93
+ this.cache.set(cacheKey, result, this.ttl);
94
+ return result;
95
+ }
96
+ }
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { hideBin } from "yargs/helpers";
4
4
  import { Pool } from "pg";
5
5
  import { FeatureFlagModel } from "./model.js";
6
6
  import dotenv from "dotenv";
7
- dotenv.config();
7
+ dotenv.config({ quiet: true });
8
8
  function createDatabase() {
9
9
  const connectionString = process.env.DATABASE_URL;
10
10
  if (!connectionString) {
@@ -51,6 +51,10 @@ const flagOptions = {
51
51
  type: "string",
52
52
  describe: "Description of where this flag is used and what it does",
53
53
  },
54
+ expires: {
55
+ type: "string",
56
+ describe: "Expiration date (ISO 8601 format)",
57
+ },
54
58
  };
55
59
  // Create command
56
60
  cli.command("create", "Create a new feature flag", (yargs) => {
@@ -76,6 +80,8 @@ cli.command("create", "Create a new feature flag", (yargs) => {
76
80
  input.users = argv.users;
77
81
  if (argv.note !== undefined)
78
82
  input.note = argv.note;
83
+ if (argv.expires !== undefined)
84
+ input.expires = new Date(argv.expires);
79
85
  const flag = await model.create(input);
80
86
  console.log(JSON.stringify(flag, null, 2));
81
87
  }
@@ -190,6 +196,10 @@ cli.command("update <id>", "Update a feature flag", (yargs) => {
190
196
  type: "boolean",
191
197
  describe: "Clear the note",
192
198
  },
199
+ "clear-expires": {
200
+ type: "boolean",
201
+ describe: "Clear the expiration date",
202
+ },
193
203
  });
194
204
  }, async (argv) => {
195
205
  const db = createDatabase();
@@ -228,6 +238,11 @@ cli.command("update <id>", "Update a feature flag", (yargs) => {
228
238
  changes.note = null;
229
239
  else if (argv.note !== undefined)
230
240
  changes.note = argv.note;
241
+ // Expires: clear flag takes precedence
242
+ if (argv.clearExpires)
243
+ changes.expires = null;
244
+ else if (argv.expires !== undefined)
245
+ changes.expires = new Date(argv.expires);
231
246
  const flag = await model.update(argv.id, changes);
232
247
  if (flag) {
233
248
  console.log(JSON.stringify(flag, null, 2));
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export type { FeatureFlag } from "./types.js";
2
- export { FeatureFlagModel } from "./model.js";
1
+ export type { FeatureFlag, FeatureFlagGroup } from "./types.js";
2
+ export { FeatureFlagModel, FeatureFlagGroupModel } from "./model.js";
3
+ export { FeatureFlagCache, InMemoryCache, type Cache } from "./cache.js";
3
4
  export { runMigrations, type MigrationOptions } from "./migrate.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export { FeatureFlagModel } from "./model.js";
1
+ export { FeatureFlagModel, FeatureFlagGroupModel } from "./model.js";
2
+ export { FeatureFlagCache, InMemoryCache } from "./cache.js";
2
3
  export { runMigrations } from "./migrate.js";
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FeatureFlag } from "./types.js";
1
+ import type { FeatureFlag, FeatureFlagEventHandlers, FeatureFlagGroup } from "./types.js";
2
2
  import type { QueryResult } from "pg";
3
3
  interface Queryable {
4
4
  query: (text: string, params?: any[]) => Promise<QueryResult>;
@@ -19,10 +19,12 @@ type UpdateChanges = Partial<Omit<FeatureFlag, "id" | "created" | "modified">>;
19
19
  */
20
20
  export declare class FeatureFlagModel {
21
21
  private db;
22
+ private eventHandlers?;
22
23
  /**
23
24
  * Creates a new FeatureFlagModel instance.
24
25
  *
25
26
  * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
27
+ * @param eventHandlers - Optional event handlers for feature flag events
26
28
  *
27
29
  * @example
28
30
  * ```typescript
@@ -30,7 +32,7 @@ export declare class FeatureFlagModel {
30
32
  * const model = new FeatureFlagModel(pool);
31
33
  * ```
32
34
  */
33
- constructor(db: Queryable);
35
+ constructor(db: Queryable, eventHandlers?: FeatureFlagEventHandlers);
34
36
  /**
35
37
  * Creates a new feature flag in the database.
36
38
  *
@@ -42,6 +44,7 @@ export declare class FeatureFlagModel {
42
44
  * @param input.groups - Optional list of user groups that have this flag enabled
43
45
  * @param input.users - Optional list of specific user IDs that have this flag enabled
44
46
  * @param input.note - Optional description of the flag's purpose
47
+ * @param input.expires - Optional expiration date for the feature flag
45
48
  * @returns The created feature flag with generated id, created, and modified timestamps
46
49
  *
47
50
  * @throws Will throw an error if a flag with the same name already exists
@@ -86,6 +89,19 @@ export declare class FeatureFlagModel {
86
89
  * ```
87
90
  */
88
91
  getByName(name: string): Promise<FeatureFlag | null>;
92
+ /**
93
+ * Retrieves multiple feature flags by their IDs.
94
+ *
95
+ * @param ids - Array of feature flag IDs to retrieve
96
+ * @returns Array of feature flags found (may be shorter than input if some IDs don't exist)
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const flags = await model.getMany([1, 2, 3]);
101
+ * console.log(`Found ${flags.length} flags`);
102
+ * ```
103
+ */
104
+ getMany(ids: number[]): Promise<FeatureFlag[]>;
89
105
  /**
90
106
  * Retrieves all feature flags, ordered by ID.
91
107
  *
@@ -208,5 +224,197 @@ export declare class FeatureFlagModel {
208
224
  groups?: string[];
209
225
  }): Promise<boolean>;
210
226
  }
227
+ type CreateGroupInput = Omit<FeatureFlagGroup, "id" | "created" | "modified">;
228
+ type UpdateGroupChanges = Partial<Omit<FeatureFlagGroup, "id" | "created" | "modified">>;
229
+ type UpdateAllChanges = Partial<Pick<FeatureFlag, "everyone" | "percent" | "roles" | "groups" | "users">>;
230
+ /**
231
+ * Model for managing feature flag groups stored in PostgreSQL.
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * import { Pool } from "pg";
236
+ * import { FeatureFlagGroupModel } from "@brandtg/flapjack";
237
+ *
238
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
239
+ * const groups = new FeatureFlagGroupModel(pool);
240
+ * ```
241
+ */
242
+ export declare class FeatureFlagGroupModel {
243
+ private db;
244
+ /**
245
+ * Creates a new FeatureFlagGroupModel instance.
246
+ *
247
+ * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
248
+ *
249
+ * @example
250
+ * ```typescript
251
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
252
+ * const model = new FeatureFlagGroupModel(pool);
253
+ * ```
254
+ */
255
+ constructor(db: Queryable);
256
+ /**
257
+ * Creates a new feature flag group in the database.
258
+ *
259
+ * @param input - Feature flag group configuration
260
+ * @param input.name - Unique name for the group (required)
261
+ * @param input.note - Optional description of the group's purpose
262
+ * @returns The created group with generated id, created, and modified timestamps
263
+ *
264
+ * @throws Will throw an error if a group with the same name already exists
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * const group = await model.create({
269
+ * name: "billing_redesign",
270
+ * note: "All flags related to the new billing flow",
271
+ * });
272
+ * ```
273
+ */
274
+ create(input: CreateGroupInput): Promise<FeatureFlagGroup>;
275
+ /**
276
+ * Retrieves a feature flag group by its ID.
277
+ *
278
+ * @param id - The group ID
279
+ * @returns The group if found, null otherwise
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const group = await model.getById(1);
284
+ * if (group) {
285
+ * console.log(group.name);
286
+ * }
287
+ * ```
288
+ */
289
+ getById(id: number): Promise<FeatureFlagGroup | null>;
290
+ /**
291
+ * Retrieves a feature flag group by its name.
292
+ *
293
+ * @param name - The group name
294
+ * @returns The group if found, null otherwise
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * const group = await model.getByName("billing_redesign");
299
+ * ```
300
+ */
301
+ getByName(name: string): Promise<FeatureFlagGroup | null>;
302
+ /**
303
+ * Lists all feature flag groups.
304
+ *
305
+ * @returns Array of all groups
306
+ *
307
+ * @example
308
+ * ```typescript
309
+ * const groups = await model.list();
310
+ * for (const group of groups) {
311
+ * console.log(group.name);
312
+ * }
313
+ * ```
314
+ */
315
+ list(): Promise<FeatureFlagGroup[]>;
316
+ /**
317
+ * Updates a feature flag group.
318
+ *
319
+ * @param id - The group ID to update
320
+ * @param changes - Fields to update
321
+ * @returns The updated group if found, null otherwise
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * const updated = await model.update(1, {
326
+ * note: "Updated description",
327
+ * });
328
+ * ```
329
+ */
330
+ update(id: number, changes: UpdateGroupChanges): Promise<FeatureFlagGroup | null>;
331
+ /**
332
+ * Deletes a feature flag group and all its member relationships.
333
+ *
334
+ * @param id - The group ID to delete
335
+ * @returns true if deleted, false if not found
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * const deleted = await model.delete(1);
340
+ * ```
341
+ */
342
+ delete(id: number): Promise<boolean>;
343
+ /**
344
+ * Adds a feature flag to a group.
345
+ *
346
+ * @param groupId - The group ID
347
+ * @param featureFlagId - The feature flag ID to add
348
+ * @returns true if added successfully, false if already exists
349
+ *
350
+ * @throws Will throw an error if group or feature flag doesn't exist
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * await model.addFeatureFlag(1, 5);
355
+ * ```
356
+ */
357
+ addFeatureFlag(groupId: number, featureFlagId: number): Promise<boolean>;
358
+ /**
359
+ * Removes a feature flag from a group.
360
+ *
361
+ * @param groupId - The group ID
362
+ * @param featureFlagId - The feature flag ID to remove
363
+ * @returns true if removed, false if relationship didn't exist
364
+ *
365
+ * @example
366
+ * ```typescript
367
+ * await model.removeFeatureFlag(1, 5);
368
+ * ```
369
+ */
370
+ removeFeatureFlag(groupId: number, featureFlagId: number): Promise<boolean>;
371
+ /**
372
+ * Gets all feature flags in a group.
373
+ *
374
+ * @param groupId - The group ID
375
+ * @returns Array of feature flags in the group
376
+ *
377
+ * @example
378
+ * ```typescript
379
+ * const flags = await model.getFeatureFlags(1);
380
+ * for (const flag of flags) {
381
+ * console.log(flag.name);
382
+ * }
383
+ * ```
384
+ */
385
+ getFeatureFlags(groupId: number): Promise<FeatureFlag[]>;
386
+ /**
387
+ * Gets all groups that contain a specific feature flag.
388
+ *
389
+ * @param featureFlagId - The feature flag ID
390
+ * @returns Array of groups containing the feature flag
391
+ *
392
+ * @example
393
+ * ```typescript
394
+ * const groups = await model.getGroupsForFeatureFlag(5);
395
+ * ```
396
+ */
397
+ getGroupsForFeatureFlag(featureFlagId: number): Promise<FeatureFlagGroup[]>;
398
+ /**
399
+ * Updates all feature flags in a group with the same changes.
400
+ *
401
+ * @param groupId - The group ID
402
+ * @param changes - Fields to update (only everyone, percent, roles, groups, users allowed)
403
+ * @returns The number of feature flags updated
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * // Enable all flags in a group for everyone
408
+ * const count = await model.updateAll(1, { everyone: true });
409
+ *
410
+ * // Set percentage rollout for all flags in a group
411
+ * const count = await model.updateAll(1, { percent: 25 });
412
+ *
413
+ * // Add roles to all flags in a group
414
+ * const count = await model.updateAll(1, { roles: ["admin", "beta"] });
415
+ * ```
416
+ */
417
+ updateAll(groupId: number, changes: UpdateAllChanges): Promise<number>;
418
+ }
211
419
  export {};
212
420
  //# sourceMappingURL=model.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,IAAI,CAAC;AAItC,UAAU,SAAS;IACjB,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;CAC/D;AAiBD,KAAK,WAAW,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,SAAS,GAAG,UAAU,CAAC,CAAC;AACpE,KAAK,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,SAAS,GAAG,UAAU,CAAC,CAAC,CAAC;AAyB/E;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,EAAE,CAAY;IAEtB;;;;;;;;;;OAUG;gBACS,EAAE,EAAE,SAAS;IAIzB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACG,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAmCtD;;;;;;;;;;;;;OAaG;IACG,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAOtD;;;;;;;;;;;;;OAaG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAO1D;;;;;;;;;;;;;OAaG;IACG,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAMpC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACG,MAAM,CACV,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IA6C9B;;;;;;;;;;;;;OAaG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK1C;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAQzB;;;;;;;;;;;;;;;;;;OAkBG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAmCG;IACG,eAAe,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,GACP,EAAE;QACD,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;KACnB,GAAG,OAAO,CAAC,OAAO,CAAC;CA4CrB"}
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
package/dist/model.js CHANGED
@@ -11,6 +11,7 @@ const COLUMNS = [
11
11
  "note",
12
12
  "created",
13
13
  "modified",
14
+ "expires",
14
15
  ];
15
16
  function mapRow(row) {
16
17
  return {
@@ -28,6 +29,7 @@ function mapRow(row) {
28
29
  note: row.note ?? undefined,
29
30
  created: new Date(row.created),
30
31
  modified: new Date(row.modified),
32
+ expires: row.expires ? new Date(row.expires) : undefined,
31
33
  };
32
34
  }
33
35
  /**
@@ -44,10 +46,12 @@ function mapRow(row) {
44
46
  */
45
47
  export class FeatureFlagModel {
46
48
  db;
49
+ eventHandlers;
47
50
  /**
48
51
  * Creates a new FeatureFlagModel instance.
49
52
  *
50
53
  * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
54
+ * @param eventHandlers - Optional event handlers for feature flag events
51
55
  *
52
56
  * @example
53
57
  * ```typescript
@@ -55,8 +59,9 @@ export class FeatureFlagModel {
55
59
  * const model = new FeatureFlagModel(pool);
56
60
  * ```
57
61
  */
58
- constructor(db) {
62
+ constructor(db, eventHandlers) {
59
63
  this.db = db;
64
+ this.eventHandlers = eventHandlers;
60
65
  }
61
66
  /**
62
67
  * Creates a new feature flag in the database.
@@ -69,6 +74,7 @@ export class FeatureFlagModel {
69
74
  * @param input.groups - Optional list of user groups that have this flag enabled
70
75
  * @param input.users - Optional list of specific user IDs that have this flag enabled
71
76
  * @param input.note - Optional description of the flag's purpose
77
+ * @param input.expires - Optional expiration date for the feature flag
72
78
  * @returns The created feature flag with generated id, created, and modified timestamps
73
79
  *
74
80
  * @throws Will throw an error if a flag with the same name already exists
@@ -109,6 +115,10 @@ export class FeatureFlagModel {
109
115
  cols.push("note");
110
116
  vals.push(input.note ?? null);
111
117
  }
118
+ if ("expires" in input) {
119
+ cols.push("expires");
120
+ vals.push(input.expires ?? null);
121
+ }
112
122
  const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
113
123
  const sql = `INSERT INTO ${TABLE} (${cols.join(", ")}) VALUES (${placeholders}) RETURNING ${COLUMNS.join(", ")}`;
114
124
  const res = await this.db.query(sql, vals);
@@ -156,6 +166,25 @@ export class FeatureFlagModel {
156
166
  return null;
157
167
  return mapRow(res.rows[0]);
158
168
  }
169
+ /**
170
+ * Retrieves multiple feature flags by their IDs.
171
+ *
172
+ * @param ids - Array of feature flag IDs to retrieve
173
+ * @returns Array of feature flags found (may be shorter than input if some IDs don't exist)
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * const flags = await model.getMany([1, 2, 3]);
178
+ * console.log(`Found ${flags.length} flags`);
179
+ * ```
180
+ */
181
+ async getMany(ids) {
182
+ if (ids.length === 0)
183
+ return [];
184
+ const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = ANY($1)`;
185
+ const res = await this.db.query(sql, [ids]);
186
+ return res.rows.map(mapRow);
187
+ }
159
188
  /**
160
189
  * Retrieves all feature flags, ordered by ID.
161
190
  *
@@ -230,6 +259,10 @@ export class FeatureFlagModel {
230
259
  sets.push(`note = $${sets.length + 1}`);
231
260
  vals.push(changes.note ?? null);
232
261
  }
262
+ if ("expires" in changes) {
263
+ sets.push(`expires = $${sets.length + 1}`);
264
+ vals.push(changes.expires ?? null);
265
+ }
233
266
  if (sets.length === 0) {
234
267
  return this.getById(id);
235
268
  }
@@ -331,6 +364,16 @@ export class FeatureFlagModel {
331
364
  if (!flag) {
332
365
  return false;
333
366
  }
367
+ // If the flag has expired, trigger event and return false
368
+ if (this.eventHandlers?.onExpired &&
369
+ flag &&
370
+ flag.expires &&
371
+ flag.expires <= new Date()) {
372
+ const expiredResult = await this.eventHandlers.onExpired({ flag });
373
+ if (expiredResult !== undefined) {
374
+ return expiredResult;
375
+ }
376
+ }
334
377
  // Everyone Override: If everyone is true or false, return that value immediately
335
378
  if (flag.everyone !== undefined && flag.everyone !== null) {
336
379
  return flag.everyone;
@@ -363,3 +406,334 @@ export class FeatureFlagModel {
363
406
  return false;
364
407
  }
365
408
  }
409
+ const GROUP_TABLE = "flapjack_feature_flag_group";
410
+ const GROUP_MEMBER_TABLE = "flapjack_feature_flag_group_member";
411
+ const GROUP_COLUMNS = ["id", "name", "note", "created", "modified"];
412
+ function mapGroupRow(row) {
413
+ return {
414
+ id: row.id,
415
+ name: row.name,
416
+ note: row.note ?? undefined,
417
+ created: new Date(row.created),
418
+ modified: new Date(row.modified),
419
+ };
420
+ }
421
+ /**
422
+ * Model for managing feature flag groups stored in PostgreSQL.
423
+ *
424
+ * @example
425
+ * ```typescript
426
+ * import { Pool } from "pg";
427
+ * import { FeatureFlagGroupModel } from "@brandtg/flapjack";
428
+ *
429
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
430
+ * const groups = new FeatureFlagGroupModel(pool);
431
+ * ```
432
+ */
433
+ export class FeatureFlagGroupModel {
434
+ db;
435
+ /**
436
+ * Creates a new FeatureFlagGroupModel instance.
437
+ *
438
+ * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
439
+ *
440
+ * @example
441
+ * ```typescript
442
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
443
+ * const model = new FeatureFlagGroupModel(pool);
444
+ * ```
445
+ */
446
+ constructor(db) {
447
+ this.db = db;
448
+ }
449
+ /**
450
+ * Creates a new feature flag group in the database.
451
+ *
452
+ * @param input - Feature flag group configuration
453
+ * @param input.name - Unique name for the group (required)
454
+ * @param input.note - Optional description of the group's purpose
455
+ * @returns The created group with generated id, created, and modified timestamps
456
+ *
457
+ * @throws Will throw an error if a group with the same name already exists
458
+ *
459
+ * @example
460
+ * ```typescript
461
+ * const group = await model.create({
462
+ * name: "billing_redesign",
463
+ * note: "All flags related to the new billing flow",
464
+ * });
465
+ * ```
466
+ */
467
+ async create(input) {
468
+ const cols = ["name"];
469
+ const vals = [input.name];
470
+ if ("note" in input) {
471
+ cols.push("note");
472
+ vals.push(input.note ?? null);
473
+ }
474
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
475
+ const sql = `INSERT INTO ${GROUP_TABLE} (${cols.join(", ")})
476
+ VALUES (${placeholders})
477
+ RETURNING ${GROUP_COLUMNS.join(", ")}`;
478
+ const res = await this.db.query(sql, vals);
479
+ return mapGroupRow(res.rows[0]);
480
+ }
481
+ /**
482
+ * Retrieves a feature flag group by its ID.
483
+ *
484
+ * @param id - The group ID
485
+ * @returns The group if found, null otherwise
486
+ *
487
+ * @example
488
+ * ```typescript
489
+ * const group = await model.getById(1);
490
+ * if (group) {
491
+ * console.log(group.name);
492
+ * }
493
+ * ```
494
+ */
495
+ async getById(id) {
496
+ const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE id = $1`;
497
+ const res = await this.db.query(sql, [id]);
498
+ return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
499
+ }
500
+ /**
501
+ * Retrieves a feature flag group by its name.
502
+ *
503
+ * @param name - The group name
504
+ * @returns The group if found, null otherwise
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const group = await model.getByName("billing_redesign");
509
+ * ```
510
+ */
511
+ async getByName(name) {
512
+ const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE name = $1`;
513
+ const res = await this.db.query(sql, [name]);
514
+ return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
515
+ }
516
+ /**
517
+ * Lists all feature flag groups.
518
+ *
519
+ * @returns Array of all groups
520
+ *
521
+ * @example
522
+ * ```typescript
523
+ * const groups = await model.list();
524
+ * for (const group of groups) {
525
+ * console.log(group.name);
526
+ * }
527
+ * ```
528
+ */
529
+ async list() {
530
+ const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} ORDER BY created DESC`;
531
+ const res = await this.db.query(sql);
532
+ return res.rows.map(mapGroupRow);
533
+ }
534
+ /**
535
+ * Updates a feature flag group.
536
+ *
537
+ * @param id - The group ID to update
538
+ * @param changes - Fields to update
539
+ * @returns The updated group if found, null otherwise
540
+ *
541
+ * @example
542
+ * ```typescript
543
+ * const updated = await model.update(1, {
544
+ * note: "Updated description",
545
+ * });
546
+ * ```
547
+ */
548
+ async update(id, changes) {
549
+ const updates = [];
550
+ const vals = [];
551
+ let paramIdx = 1;
552
+ if ("name" in changes) {
553
+ updates.push(`name = $${paramIdx++}`);
554
+ vals.push(changes.name);
555
+ }
556
+ if ("note" in changes) {
557
+ updates.push(`note = $${paramIdx++}`);
558
+ vals.push(changes.note ?? null);
559
+ }
560
+ if (updates.length === 0) {
561
+ return this.getById(id);
562
+ }
563
+ vals.push(id);
564
+ const sql = `UPDATE ${GROUP_TABLE}
565
+ SET ${updates.join(", ")}
566
+ WHERE id = $${paramIdx}
567
+ RETURNING ${GROUP_COLUMNS.join(", ")}`;
568
+ const res = await this.db.query(sql, vals);
569
+ return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
570
+ }
571
+ /**
572
+ * Deletes a feature flag group and all its member relationships.
573
+ *
574
+ * @param id - The group ID to delete
575
+ * @returns true if deleted, false if not found
576
+ *
577
+ * @example
578
+ * ```typescript
579
+ * const deleted = await model.delete(1);
580
+ * ```
581
+ */
582
+ async delete(id) {
583
+ const res = await this.db.query(`DELETE FROM ${GROUP_TABLE} WHERE id = $1`, [id]);
584
+ return res.rowCount > 0;
585
+ }
586
+ /**
587
+ * Adds a feature flag to a group.
588
+ *
589
+ * @param groupId - The group ID
590
+ * @param featureFlagId - The feature flag ID to add
591
+ * @returns true if added successfully, false if already exists
592
+ *
593
+ * @throws Will throw an error if group or feature flag doesn't exist
594
+ *
595
+ * @example
596
+ * ```typescript
597
+ * await model.addFeatureFlag(1, 5);
598
+ * ```
599
+ */
600
+ async addFeatureFlag(groupId, featureFlagId) {
601
+ try {
602
+ const sql = `INSERT INTO ${GROUP_MEMBER_TABLE} (group_id, feature_flag_id)
603
+ VALUES ($1, $2)`;
604
+ await this.db.query(sql, [groupId, featureFlagId]);
605
+ return true;
606
+ }
607
+ catch (err) {
608
+ // If unique constraint violation, return false
609
+ if (err.code === "23505") {
610
+ return false;
611
+ }
612
+ throw err;
613
+ }
614
+ }
615
+ /**
616
+ * Removes a feature flag from a group.
617
+ *
618
+ * @param groupId - The group ID
619
+ * @param featureFlagId - The feature flag ID to remove
620
+ * @returns true if removed, false if relationship didn't exist
621
+ *
622
+ * @example
623
+ * ```typescript
624
+ * await model.removeFeatureFlag(1, 5);
625
+ * ```
626
+ */
627
+ async removeFeatureFlag(groupId, featureFlagId) {
628
+ const sql = `DELETE FROM ${GROUP_MEMBER_TABLE}
629
+ WHERE group_id = $1 AND feature_flag_id = $2`;
630
+ const res = await this.db.query(sql, [groupId, featureFlagId]);
631
+ return res.rowCount > 0;
632
+ }
633
+ /**
634
+ * Gets all feature flags in a group.
635
+ *
636
+ * @param groupId - The group ID
637
+ * @returns Array of feature flags in the group
638
+ *
639
+ * @example
640
+ * ```typescript
641
+ * const flags = await model.getFeatureFlags(1);
642
+ * for (const flag of flags) {
643
+ * console.log(flag.name);
644
+ * }
645
+ * ```
646
+ */
647
+ async getFeatureFlags(groupId) {
648
+ const sql = `
649
+ SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
650
+ FROM ${TABLE} f
651
+ INNER JOIN ${GROUP_MEMBER_TABLE} gm ON f.id = gm.feature_flag_id
652
+ WHERE gm.group_id = $1
653
+ ORDER BY f.created DESC
654
+ `;
655
+ const res = await this.db.query(sql, [groupId]);
656
+ return res.rows.map(mapRow);
657
+ }
658
+ /**
659
+ * Gets all groups that contain a specific feature flag.
660
+ *
661
+ * @param featureFlagId - The feature flag ID
662
+ * @returns Array of groups containing the feature flag
663
+ *
664
+ * @example
665
+ * ```typescript
666
+ * const groups = await model.getGroupsForFeatureFlag(5);
667
+ * ```
668
+ */
669
+ async getGroupsForFeatureFlag(featureFlagId) {
670
+ const sql = `
671
+ SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
672
+ FROM ${GROUP_TABLE} g
673
+ INNER JOIN ${GROUP_MEMBER_TABLE} gm ON g.id = gm.group_id
674
+ WHERE gm.feature_flag_id = $1
675
+ ORDER BY g.created DESC
676
+ `;
677
+ const res = await this.db.query(sql, [featureFlagId]);
678
+ return res.rows.map(mapGroupRow);
679
+ }
680
+ /**
681
+ * Updates all feature flags in a group with the same changes.
682
+ *
683
+ * @param groupId - The group ID
684
+ * @param changes - Fields to update (only everyone, percent, roles, groups, users allowed)
685
+ * @returns The number of feature flags updated
686
+ *
687
+ * @example
688
+ * ```typescript
689
+ * // Enable all flags in a group for everyone
690
+ * const count = await model.updateAll(1, { everyone: true });
691
+ *
692
+ * // Set percentage rollout for all flags in a group
693
+ * const count = await model.updateAll(1, { percent: 25 });
694
+ *
695
+ * // Add roles to all flags in a group
696
+ * const count = await model.updateAll(1, { roles: ["admin", "beta"] });
697
+ * ```
698
+ */
699
+ async updateAll(groupId, changes) {
700
+ const updates = [];
701
+ const vals = [];
702
+ let paramIdx = 1;
703
+ if ("everyone" in changes) {
704
+ updates.push(`everyone = $${paramIdx++}`);
705
+ vals.push(changes.everyone ?? null);
706
+ }
707
+ if ("percent" in changes) {
708
+ updates.push(`percent = $${paramIdx++}`);
709
+ vals.push(changes.percent ?? null);
710
+ }
711
+ if ("roles" in changes) {
712
+ updates.push(`roles = $${paramIdx++}`);
713
+ vals.push(changes.roles ?? null);
714
+ }
715
+ if ("groups" in changes) {
716
+ updates.push(`groups = $${paramIdx++}`);
717
+ vals.push(changes.groups ?? null);
718
+ }
719
+ if ("users" in changes) {
720
+ updates.push(`users = $${paramIdx++}`);
721
+ vals.push(changes.users ?? null);
722
+ }
723
+ if (updates.length === 0) {
724
+ return 0;
725
+ }
726
+ vals.push(groupId);
727
+ const sql = `
728
+ UPDATE ${TABLE}
729
+ SET ${updates.join(", ")}
730
+ WHERE id IN (
731
+ SELECT feature_flag_id
732
+ FROM ${GROUP_MEMBER_TABLE}
733
+ WHERE group_id = $${paramIdx}
734
+ )
735
+ `;
736
+ const res = await this.db.query(sql, vals);
737
+ return res.rowCount;
738
+ }
739
+ }
package/dist/types.d.ts CHANGED
@@ -22,5 +22,39 @@ export type FeatureFlag = {
22
22
  created: Date;
23
23
  /** Date when the feature flag was last modified */
24
24
  modified: Date;
25
+ /** Optional expiration date for the feature flag */
26
+ expires?: Date;
27
+ };
28
+ /**
29
+ * Called when a feature flag which has expired is used.
30
+ *
31
+ * If the handler returns `true` or `false`, that value will be used as the result of the feature
32
+ * flag check. If it returns `undefined` the behavior will be the same as if the flag has not
33
+ * expired. This can be used to implement a grace period for expired flags, or to emit metrics to
34
+ * alert that an expired flag is still in use.
35
+ */
36
+ export type ExpiredFeatureFlagEventHandler = ({ flag, }: {
37
+ flag: FeatureFlag;
38
+ }) => Promise<boolean | undefined>;
39
+ /**
40
+ * Event handlers for feature flag events.
41
+ */
42
+ export type FeatureFlagEventHandlers = {
43
+ onExpired?: ExpiredFeatureFlagEventHandler;
44
+ };
45
+ /**
46
+ * Represents a feature flag group used to manage multiple feature flags together.
47
+ */
48
+ export type FeatureFlagGroup = {
49
+ /** Unique identifier for the feature flag group */
50
+ id: number;
51
+ /** Human readable name of the feature flag group */
52
+ name: string;
53
+ /** Description of what this group is for */
54
+ note?: string;
55
+ /** Date when the group was created */
56
+ created: Date;
57
+ /** Date when the group was last modified */
58
+ modified: Date;
25
59
  };
26
60
  //# sourceMappingURL=types.d.ts.map
@@ -1 +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"}
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;IACf,oDAAoD;IACpD,OAAO,CAAC,EAAE,IAAI,CAAC;CAChB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,8BAA8B,GAAG,CAAC,EAC5C,IAAI,GACL,EAAE;IACD,IAAI,EAAE,WAAW,CAAC;CACnB,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC;AAEnC;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,CAAC,EAAE,8BAA8B,CAAC;CAC5C,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,mDAAmD;IACnD,EAAE,EAAE,MAAM,CAAC;IACX,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,OAAO,EAAE,IAAI,CAAC;IACd,4CAA4C;IAC5C,QAAQ,EAAE,IAAI,CAAC;CAChB,CAAC"}
@@ -0,0 +1,24 @@
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
+ pgm.addColumn("flapjack_feature_flag", {
13
+ expires: { type: "timestamptz" },
14
+ });
15
+ };
16
+
17
+ /**
18
+ * @param pgm {import('node-pg-migrate').MigrationBuilder}
19
+ * @param run {() => void | undefined}
20
+ * @returns {Promise<void> | void}
21
+ */
22
+ export const down = (pgm) => {
23
+ pgm.dropColumn("flapjack_feature_flag", "expires");
24
+ };
@@ -0,0 +1,77 @@
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_group table
13
+ pgm.createTable("flapjack_feature_flag_group", {
14
+ id: "id",
15
+ name: { type: "text", notNull: true, unique: true },
16
+ note: { type: "text" },
17
+ created: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
18
+ modified: {
19
+ type: "timestamptz",
20
+ notNull: true,
21
+ default: pgm.func("now()"),
22
+ },
23
+ });
24
+
25
+ // Create the flapjack_feature_flag_group_member relation table
26
+ pgm.createTable("flapjack_feature_flag_group_member", {
27
+ id: "id",
28
+ group_id: {
29
+ type: "integer",
30
+ notNull: true,
31
+ references: "flapjack_feature_flag_group",
32
+ onDelete: "CASCADE",
33
+ },
34
+ feature_flag_id: {
35
+ type: "integer",
36
+ notNull: true,
37
+ references: "flapjack_feature_flag",
38
+ onDelete: "CASCADE",
39
+ },
40
+ created: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
41
+ });
42
+
43
+ // Add unique constraint on group_id and feature_flag_id
44
+ pgm.addConstraint(
45
+ "flapjack_feature_flag_group_member",
46
+ "unique_group_feature_flag",
47
+ {
48
+ unique: ["group_id", "feature_flag_id"],
49
+ },
50
+ );
51
+
52
+ // Create indexes for efficient lookups
53
+ pgm.createIndex("flapjack_feature_flag_group_member", "group_id");
54
+ pgm.createIndex("flapjack_feature_flag_group_member", "feature_flag_id");
55
+
56
+ // Attach the modified timestamp trigger to the group table
57
+ pgm.sql(`
58
+ CREATE TRIGGER flapjack_feature_flag_group_set_modified
59
+ BEFORE UPDATE ON flapjack_feature_flag_group
60
+ FOR EACH ROW
61
+ EXECUTE FUNCTION flapjack_set_modified_timestamp();
62
+ `);
63
+ };
64
+
65
+ /**
66
+ * @param pgm {import('node-pg-migrate').MigrationBuilder}
67
+ * @param run {() => void | undefined}
68
+ * @returns {Promise<void> | void}
69
+ */
70
+ export const down = (pgm) => {
71
+ pgm.sql(`
72
+ DROP TRIGGER IF EXISTS flapjack_feature_flag_group_set_modified
73
+ ON flapjack_feature_flag_group;
74
+ `);
75
+ pgm.dropTable("flapjack_feature_flag_group_member");
76
+ pgm.dropTable("flapjack_feature_flag_group");
77
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandtg/flapjack",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A simple feature flags library with PostgreSQL integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,7 +40,7 @@
40
40
  "smoke": "npm run build && node dist/cli.js --help && node dist/cli.js hash-user smoke"
41
41
  },
42
42
  "dependencies": {
43
- "dotenv": "^16.4.5",
43
+ "dotenv": "^17.2.3",
44
44
  "imurmurhash": "^0.1.4",
45
45
  "node-pg-migrate": "^8.0.3",
46
46
  "pg": "^8.12.0",