@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 +78 -30
- package/dist/cache.d.ts +48 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +96 -0
- package/dist/cli.js +16 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/model.d.ts +5 -2
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +25 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/migrations/1760502793520_add-feature-flag-expires.js +24 -0
- package/package.json +2 -2
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,
|
|
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
|
-
###
|
|
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 {
|
|
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
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
package/dist/cache.d.ts
ADDED
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
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
|
package/dist/model.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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",
|