@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 +85 -31
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/model.d.ts +210 -2
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +375 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/migrations/1760502793520_add-feature-flag-expires.js +24 -0
- package/migrations/1764560643657_add-feature-flag-groups.js +77 -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
|
+
```
|
|
156
175
|
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
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
|
-
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
|
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;
|
|
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
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
|
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,
|
|
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
|
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;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.
|
|
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": "^
|
|
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",
|