@brandtg/flapjack 0.1.0 → 0.1.2

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
+ ```
175
+
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
+ });
156
187
 
157
- constructor(pool: Pool) {
158
- this.model = new FeatureFlagModel(pool);
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
@@ -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
1
  export type { FeatureFlag } from "./types.js";
2
2
  export { FeatureFlagModel } 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,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9C,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
1
  export { FeatureFlagModel } 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 } 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
@@ -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":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AACxE,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;AAkBD,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;AA0B/E;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,EAAE,CAAY;IACtB,OAAO,CAAC,aAAa,CAAC,CAA2B;IAEjD;;;;;;;;;;;OAWG;gBACS,EAAE,EAAE,SAAS,EAAE,aAAa,CAAC,EAAE,wBAAwB;IAKnE;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACG,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAuCtD;;;;;;;;;;;;;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;IAiD9B;;;;;;;;;;;;;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;CAyDrB"}
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);
@@ -230,6 +240,10 @@ export class FeatureFlagModel {
230
240
  sets.push(`note = $${sets.length + 1}`);
231
241
  vals.push(changes.note ?? null);
232
242
  }
243
+ if ("expires" in changes) {
244
+ sets.push(`expires = $${sets.length + 1}`);
245
+ vals.push(changes.expires ?? null);
246
+ }
233
247
  if (sets.length === 0) {
234
248
  return this.getById(id);
235
249
  }
@@ -331,6 +345,16 @@ export class FeatureFlagModel {
331
345
  if (!flag) {
332
346
  return false;
333
347
  }
348
+ // If the flag has expired, trigger event and return false
349
+ if (this.eventHandlers?.onExpired &&
350
+ flag &&
351
+ flag.expires &&
352
+ flag.expires <= new Date()) {
353
+ const expiredResult = await this.eventHandlers.onExpired({ flag });
354
+ if (expiredResult !== undefined) {
355
+ return expiredResult;
356
+ }
357
+ }
334
358
  // Everyone Override: If everyone is true or false, return that value immediately
335
359
  if (flag.everyone !== undefined && flag.everyone !== null) {
336
360
  return flag.everyone;
package/dist/types.d.ts CHANGED
@@ -22,5 +22,24 @@ 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;
25
44
  };
26
45
  //# 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"}
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandtg/flapjack",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",