@brandtg/flapjack 1.3.0 → 1.5.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 +148 -13
- package/dist/cache.d.ts +7 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +19 -3
- package/dist/cli.js +867 -9
- package/dist/model.d.ts +167 -17
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +310 -28
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/migrations/1779210257698_add-feature-flag-subjects.js +93 -0
- package/migrations/1781107651000_add-feature-flag-tags.js +38 -0
- package/migrations/1781200000000_add-archived.js +37 -0
- package/package.json +2 -2
package/dist/model.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import MurmurHash3 from "imurmurhash";
|
|
2
2
|
const TABLE = "flapjack.feature_flag";
|
|
3
|
+
const SUBJECT_TABLE = "flapjack.feature_flag_subject";
|
|
4
|
+
const GROUP_SUBJECT_TABLE = "flapjack.feature_flag_group_subject";
|
|
3
5
|
const COLUMNS = [
|
|
4
6
|
"id",
|
|
5
7
|
"name",
|
|
@@ -8,10 +10,12 @@ const COLUMNS = [
|
|
|
8
10
|
"roles",
|
|
9
11
|
"groups",
|
|
10
12
|
"users",
|
|
13
|
+
"tags",
|
|
11
14
|
"note",
|
|
12
15
|
"created",
|
|
13
16
|
"modified",
|
|
14
17
|
"expires",
|
|
18
|
+
"archived",
|
|
15
19
|
];
|
|
16
20
|
function mapRow(row) {
|
|
17
21
|
return {
|
|
@@ -26,10 +30,12 @@ function mapRow(row) {
|
|
|
26
30
|
? row.groups
|
|
27
31
|
: undefined,
|
|
28
32
|
users: row.users && row.users.length > 0 ? row.users : undefined,
|
|
33
|
+
tags: row.tags && row.tags.length > 0 ? row.tags : undefined,
|
|
29
34
|
note: row.note ?? undefined,
|
|
30
35
|
created: new Date(row.created),
|
|
31
36
|
modified: new Date(row.modified),
|
|
32
37
|
expires: row.expires ? new Date(row.expires) : undefined,
|
|
38
|
+
archived: row.archived ? new Date(row.archived) : undefined,
|
|
33
39
|
};
|
|
34
40
|
}
|
|
35
41
|
/**
|
|
@@ -73,6 +79,7 @@ export class FeatureFlagModel {
|
|
|
73
79
|
* @param input.roles - Optional list of roles that have this flag enabled
|
|
74
80
|
* @param input.groups - Optional list of user groups that have this flag enabled
|
|
75
81
|
* @param input.users - Optional list of specific user IDs that have this flag enabled
|
|
82
|
+
* @param input.tags - Optional list of tags for organizing the flag (e.g. a release version)
|
|
76
83
|
* @param input.note - Optional description of the flag's purpose
|
|
77
84
|
* @param input.expires - Optional expiration date for the feature flag
|
|
78
85
|
* @returns The created feature flag with generated id, created, and modified timestamps
|
|
@@ -111,6 +118,10 @@ export class FeatureFlagModel {
|
|
|
111
118
|
cols.push("users");
|
|
112
119
|
vals.push(input.users ?? null);
|
|
113
120
|
}
|
|
121
|
+
if ("tags" in input) {
|
|
122
|
+
cols.push("tags");
|
|
123
|
+
vals.push(input.tags ?? null);
|
|
124
|
+
}
|
|
114
125
|
if ("note" in input) {
|
|
115
126
|
cols.push("note");
|
|
116
127
|
vals.push(input.note ?? null);
|
|
@@ -138,8 +149,9 @@ export class FeatureFlagModel {
|
|
|
138
149
|
* }
|
|
139
150
|
* ```
|
|
140
151
|
*/
|
|
141
|
-
async getById(id) {
|
|
142
|
-
const
|
|
152
|
+
async getById(id, { includeArchived = false } = {}) {
|
|
153
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
154
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = $1${filter}`;
|
|
143
155
|
const res = await this.db.query(sql, [id]);
|
|
144
156
|
if (res.rows.length === 0)
|
|
145
157
|
return null;
|
|
@@ -159,8 +171,9 @@ export class FeatureFlagModel {
|
|
|
159
171
|
* }
|
|
160
172
|
* ```
|
|
161
173
|
*/
|
|
162
|
-
async getByName(name) {
|
|
163
|
-
const
|
|
174
|
+
async getByName(name, { includeArchived = false } = {}) {
|
|
175
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
176
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = $1${filter}`;
|
|
164
177
|
const res = await this.db.query(sql, [name]);
|
|
165
178
|
if (res.rows.length === 0)
|
|
166
179
|
return null;
|
|
@@ -178,10 +191,11 @@ export class FeatureFlagModel {
|
|
|
178
191
|
* console.log(`Found ${flags.length} flags`);
|
|
179
192
|
* ```
|
|
180
193
|
*/
|
|
181
|
-
async getManyByName(names) {
|
|
194
|
+
async getManyByName(names, { includeArchived = false } = {}) {
|
|
182
195
|
if (names.length === 0)
|
|
183
196
|
return [];
|
|
184
|
-
const
|
|
197
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
198
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = ANY($1)${filter}`;
|
|
185
199
|
const res = await this.db.query(sql, [names]);
|
|
186
200
|
return res.rows.map(mapRow);
|
|
187
201
|
}
|
|
@@ -197,10 +211,11 @@ export class FeatureFlagModel {
|
|
|
197
211
|
* console.log(`Found ${flags.length} flags`);
|
|
198
212
|
* ```
|
|
199
213
|
*/
|
|
200
|
-
async getMany(ids) {
|
|
214
|
+
async getMany(ids, { includeArchived = false } = {}) {
|
|
201
215
|
if (ids.length === 0)
|
|
202
216
|
return [];
|
|
203
|
-
const
|
|
217
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
218
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = ANY($1)${filter}`;
|
|
204
219
|
const res = await this.db.query(sql, [ids]);
|
|
205
220
|
return res.rows.map(mapRow);
|
|
206
221
|
}
|
|
@@ -218,11 +233,30 @@ export class FeatureFlagModel {
|
|
|
218
233
|
* });
|
|
219
234
|
* ```
|
|
220
235
|
*/
|
|
221
|
-
async list() {
|
|
222
|
-
const
|
|
236
|
+
async list({ includeArchived = false, } = {}) {
|
|
237
|
+
const filter = includeArchived ? "" : " WHERE archived IS NULL";
|
|
238
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE}${filter} ORDER BY id`;
|
|
223
239
|
const res = await this.db.query(sql);
|
|
224
240
|
return res.rows.map(mapRow);
|
|
225
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Retrieves all feature flags that carry the given tag.
|
|
244
|
+
*
|
|
245
|
+
* @param tag - The tag to filter by (exact match against a tag in the flag's tags array)
|
|
246
|
+
* @returns Array of feature flags tagged with the given value
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```typescript
|
|
250
|
+
* // Find every flag associated with a release version
|
|
251
|
+
* const flags = await model.listByTag("release:1.5.0");
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
async listByTag(tag, { includeArchived = false } = {}) {
|
|
255
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
256
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE $1 = ANY(tags)${filter} ORDER BY id`;
|
|
257
|
+
const res = await this.db.query(sql, [tag]);
|
|
258
|
+
return res.rows.map(mapRow);
|
|
259
|
+
}
|
|
226
260
|
/**
|
|
227
261
|
* Updates an existing feature flag.
|
|
228
262
|
*
|
|
@@ -274,6 +308,10 @@ export class FeatureFlagModel {
|
|
|
274
308
|
sets.push(`users = $${sets.length + 1}`);
|
|
275
309
|
vals.push(changes.users ?? null);
|
|
276
310
|
}
|
|
311
|
+
if ("tags" in changes) {
|
|
312
|
+
sets.push(`tags = $${sets.length + 1}`);
|
|
313
|
+
vals.push(changes.tags ?? null);
|
|
314
|
+
}
|
|
277
315
|
if ("note" in changes) {
|
|
278
316
|
sets.push(`note = $${sets.length + 1}`);
|
|
279
317
|
vals.push(changes.note ?? null);
|
|
@@ -294,11 +332,16 @@ export class FeatureFlagModel {
|
|
|
294
332
|
return mapRow(res.rows[0]);
|
|
295
333
|
}
|
|
296
334
|
/**
|
|
297
|
-
*
|
|
335
|
+
* Permanently deletes a feature flag from the database, including its
|
|
336
|
+
* subject and group-member relationships (via ON DELETE CASCADE).
|
|
298
337
|
*
|
|
299
338
|
* @param id - The unique identifier of the feature flag to delete
|
|
300
339
|
* @returns true if the flag was deleted, false if not found
|
|
301
340
|
*
|
|
341
|
+
* @remarks
|
|
342
|
+
* This is a hard delete that destroys all metadata. To hide a flag while
|
|
343
|
+
* retaining its history for audit purposes, use {@link archive} instead.
|
|
344
|
+
*
|
|
302
345
|
* @example
|
|
303
346
|
* ```typescript
|
|
304
347
|
* const deleted = await model.delete(flagId);
|
|
@@ -311,6 +354,105 @@ export class FeatureFlagModel {
|
|
|
311
354
|
const res = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
|
|
312
355
|
return res.rowCount > 0;
|
|
313
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Archives (hides) a feature flag while keeping the row for historical/audit
|
|
359
|
+
* purposes. Archived flags are excluded from normal reads and evaluation
|
|
360
|
+
* (an archived flag evaluates to inactive), but can still be retrieved by
|
|
361
|
+
* passing `{ includeArchived: true }` to the read methods.
|
|
362
|
+
*
|
|
363
|
+
* @param id - The unique identifier of the feature flag to archive
|
|
364
|
+
* @returns The archived feature flag, or null if it does not exist or is already archived
|
|
365
|
+
*
|
|
366
|
+
* @remarks
|
|
367
|
+
* Archiving is irreversible: there is intentionally no `unarchive` method.
|
|
368
|
+
* The `archived` timestamp is set once and never changed (a second call is a
|
|
369
|
+
* no-op that returns null). The flag's name remains reserved permanently, so
|
|
370
|
+
* a new flag cannot reuse it — create a new flag with a different name.
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* ```typescript
|
|
374
|
+
* const archived = await model.archive(flagId);
|
|
375
|
+
* if (archived) {
|
|
376
|
+
* console.log(`Archived at ${archived.archived}`);
|
|
377
|
+
* }
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
async archive(id) {
|
|
381
|
+
const sql = `UPDATE ${TABLE} SET archived = now() WHERE id = $1 AND archived IS NULL RETURNING ${COLUMNS.join(", ")}`;
|
|
382
|
+
const res = await this.db.query(sql, [id]);
|
|
383
|
+
if (res.rows.length === 0)
|
|
384
|
+
return null;
|
|
385
|
+
return mapRow(res.rows[0]);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Adds an external subject identifier to a feature flag.
|
|
389
|
+
*/
|
|
390
|
+
async addSubject(featureFlagId, subject) {
|
|
391
|
+
try {
|
|
392
|
+
const sql = `INSERT INTO ${SUBJECT_TABLE} (feature_flag_id, subject) VALUES ($1, $2)`;
|
|
393
|
+
await this.db.query(sql, [featureFlagId, subject]);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
if (err.code === "23505") {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Removes an external subject identifier from a feature flag.
|
|
405
|
+
*/
|
|
406
|
+
async removeSubject(featureFlagId, subject) {
|
|
407
|
+
const sql = `DELETE FROM ${SUBJECT_TABLE} WHERE feature_flag_id = $1 AND subject = $2`;
|
|
408
|
+
const res = await this.db.query(sql, [featureFlagId, subject]);
|
|
409
|
+
return res.rowCount > 0;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Gets all external subject identifiers associated with a feature flag.
|
|
413
|
+
*/
|
|
414
|
+
async getSubjects(featureFlagId) {
|
|
415
|
+
const sql = `SELECT subject FROM ${SUBJECT_TABLE} WHERE feature_flag_id = $1 ORDER BY created DESC`;
|
|
416
|
+
const res = await this.db.query(sql, [featureFlagId]);
|
|
417
|
+
return res.rows.map((row) => row.subject);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Gets all feature flags directly associated with an external subject identifier.
|
|
421
|
+
*/
|
|
422
|
+
async getFeatureFlagsForSubject(subject, { includeArchived = false } = {}) {
|
|
423
|
+
const filter = includeArchived ? "" : " AND f.archived IS NULL";
|
|
424
|
+
const sql = `
|
|
425
|
+
SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
|
|
426
|
+
FROM ${TABLE} f
|
|
427
|
+
INNER JOIN ${SUBJECT_TABLE} s ON f.id = s.feature_flag_id
|
|
428
|
+
WHERE s.subject = $1${filter}
|
|
429
|
+
ORDER BY f.created DESC
|
|
430
|
+
`;
|
|
431
|
+
const res = await this.db.query(sql, [subject]);
|
|
432
|
+
return res.rows.map(mapRow);
|
|
433
|
+
}
|
|
434
|
+
async getSubjectMatchedFlagIds(subjects = []) {
|
|
435
|
+
if (subjects.length === 0) {
|
|
436
|
+
return new Set();
|
|
437
|
+
}
|
|
438
|
+
// Archived flags and archived groups do not contribute subject-based
|
|
439
|
+
// targeting: a flag matched directly must itself be active, and a flag
|
|
440
|
+
// matched via group membership must belong to an active group.
|
|
441
|
+
const sql = `
|
|
442
|
+
SELECT s.feature_flag_id AS feature_flag_id
|
|
443
|
+
FROM ${SUBJECT_TABLE} s
|
|
444
|
+
INNER JOIN ${TABLE} f ON f.id = s.feature_flag_id
|
|
445
|
+
WHERE s.subject = ANY($1) AND f.archived IS NULL
|
|
446
|
+
UNION
|
|
447
|
+
SELECT gm.feature_flag_id AS feature_flag_id
|
|
448
|
+
FROM ${GROUP_SUBJECT_TABLE} gs
|
|
449
|
+
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON gs.feature_flag_group_id = gm.group_id
|
|
450
|
+
INNER JOIN ${GROUP_TABLE} g ON g.id = gs.feature_flag_group_id
|
|
451
|
+
WHERE gs.subject = ANY($1) AND g.archived IS NULL
|
|
452
|
+
`;
|
|
453
|
+
const res = await this.db.query(sql, [subjects]);
|
|
454
|
+
return new Set(res.rows.map((row) => Number(row.feature_flag_id)));
|
|
455
|
+
}
|
|
314
456
|
/**
|
|
315
457
|
* Checks if a user belongs to any of the specified groups
|
|
316
458
|
*/
|
|
@@ -323,7 +465,7 @@ export class FeatureFlagModel {
|
|
|
323
465
|
* Evaluates a feature flag for a user based on flag configuration.
|
|
324
466
|
* This is a stateless helper that performs the actual flag evaluation logic.
|
|
325
467
|
*/
|
|
326
|
-
async evaluateFlagForUser(flag, { user, roles, groups, }) {
|
|
468
|
+
async evaluateFlagForUser(flag, { user, roles, groups, subjects, subjectMatchedFlagIds, }) {
|
|
327
469
|
// Everyone Override: If everyone is true or false, return that value immediately
|
|
328
470
|
if (flag.everyone !== undefined && flag.everyone !== null) {
|
|
329
471
|
return flag.everyone;
|
|
@@ -332,6 +474,20 @@ export class FeatureFlagModel {
|
|
|
332
474
|
if (user && flag.users && flag.users.includes(user)) {
|
|
333
475
|
return true;
|
|
334
476
|
}
|
|
477
|
+
// Subject Check: If any external subject maps to this flag directly or via group, return true
|
|
478
|
+
if (subjects && subjects.length > 0) {
|
|
479
|
+
let isSubjectMatched = false;
|
|
480
|
+
if (subjectMatchedFlagIds) {
|
|
481
|
+
isSubjectMatched = subjectMatchedFlagIds.has(flag.id);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
const matchedFlagIds = await this.getSubjectMatchedFlagIds(subjects);
|
|
485
|
+
isSubjectMatched = matchedFlagIds.has(flag.id);
|
|
486
|
+
}
|
|
487
|
+
if (isSubjectMatched) {
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
335
491
|
// Group Check: If any of the user's groups match any group in the groups array, return true
|
|
336
492
|
if (groups && this.isActiveForGroups(groups, flag.groups)) {
|
|
337
493
|
return true;
|
|
@@ -414,6 +570,17 @@ export class FeatureFlagModel {
|
|
|
414
570
|
* ```
|
|
415
571
|
*/
|
|
416
572
|
async isActiveForUser({ name, user, roles, groups, }) {
|
|
573
|
+
return this.isActiveForContext({
|
|
574
|
+
name,
|
|
575
|
+
user,
|
|
576
|
+
roles,
|
|
577
|
+
groups,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Checks if a feature flag is active for a context, including optional external subjects.
|
|
582
|
+
*/
|
|
583
|
+
async isActiveForContext({ name, user, roles, groups, subjects, }) {
|
|
417
584
|
const flag = await this.getByName(name);
|
|
418
585
|
// No such flag
|
|
419
586
|
if (!flag) {
|
|
@@ -429,7 +596,14 @@ export class FeatureFlagModel {
|
|
|
429
596
|
return expiredResult;
|
|
430
597
|
}
|
|
431
598
|
}
|
|
432
|
-
|
|
599
|
+
const subjectMatchedFlagIds = await this.getSubjectMatchedFlagIds(subjects);
|
|
600
|
+
return this.evaluateFlagForUser(flag, {
|
|
601
|
+
user,
|
|
602
|
+
roles,
|
|
603
|
+
groups,
|
|
604
|
+
subjects,
|
|
605
|
+
subjectMatchedFlagIds,
|
|
606
|
+
});
|
|
433
607
|
}
|
|
434
608
|
/**
|
|
435
609
|
* Checks if multiple feature flags are active for a user based on configured rules.
|
|
@@ -479,6 +653,17 @@ export class FeatureFlagModel {
|
|
|
479
653
|
* ```
|
|
480
654
|
*/
|
|
481
655
|
async areActiveForUser({ names, user, roles, groups, }) {
|
|
656
|
+
return this.areActiveForContext({
|
|
657
|
+
names,
|
|
658
|
+
user,
|
|
659
|
+
roles,
|
|
660
|
+
groups,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Checks if multiple feature flags are active for a context, including optional external subjects.
|
|
665
|
+
*/
|
|
666
|
+
async areActiveForContext({ names, user, roles, groups, subjects, }) {
|
|
482
667
|
let flags;
|
|
483
668
|
let requestedNames;
|
|
484
669
|
if (names === undefined) {
|
|
@@ -493,6 +678,7 @@ export class FeatureFlagModel {
|
|
|
493
678
|
}
|
|
494
679
|
const flagMap = new Map(flags.map((flag) => [flag.name, flag]));
|
|
495
680
|
const result = {};
|
|
681
|
+
const subjectMatchedFlagIds = await this.getSubjectMatchedFlagIds(subjects);
|
|
496
682
|
for (const name of requestedNames) {
|
|
497
683
|
const flag = flagMap.get(name);
|
|
498
684
|
if (!flag) {
|
|
@@ -513,6 +699,8 @@ export class FeatureFlagModel {
|
|
|
513
699
|
user,
|
|
514
700
|
roles,
|
|
515
701
|
groups,
|
|
702
|
+
subjects,
|
|
703
|
+
subjectMatchedFlagIds,
|
|
516
704
|
});
|
|
517
705
|
}
|
|
518
706
|
return result;
|
|
@@ -541,7 +729,14 @@ export class FeatureFlagModel {
|
|
|
541
729
|
}
|
|
542
730
|
const GROUP_TABLE = "flapjack.feature_flag_group";
|
|
543
731
|
const GROUP_MEMBER_TABLE = "flapjack.feature_flag_group_member";
|
|
544
|
-
const GROUP_COLUMNS = [
|
|
732
|
+
const GROUP_COLUMNS = [
|
|
733
|
+
"id",
|
|
734
|
+
"name",
|
|
735
|
+
"note",
|
|
736
|
+
"created",
|
|
737
|
+
"modified",
|
|
738
|
+
"archived",
|
|
739
|
+
];
|
|
545
740
|
function mapGroupRow(row) {
|
|
546
741
|
return {
|
|
547
742
|
id: row.id,
|
|
@@ -549,6 +744,7 @@ function mapGroupRow(row) {
|
|
|
549
744
|
note: row.note ?? undefined,
|
|
550
745
|
created: new Date(row.created),
|
|
551
746
|
modified: new Date(row.modified),
|
|
747
|
+
archived: row.archived ? new Date(row.archived) : undefined,
|
|
552
748
|
};
|
|
553
749
|
}
|
|
554
750
|
/**
|
|
@@ -625,8 +821,9 @@ export class FeatureFlagGroupModel {
|
|
|
625
821
|
* }
|
|
626
822
|
* ```
|
|
627
823
|
*/
|
|
628
|
-
async getById(id) {
|
|
629
|
-
const
|
|
824
|
+
async getById(id, { includeArchived = false } = {}) {
|
|
825
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
826
|
+
const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE id = $1${filter}`;
|
|
630
827
|
const res = await this.db.query(sql, [id]);
|
|
631
828
|
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
632
829
|
}
|
|
@@ -641,8 +838,9 @@ export class FeatureFlagGroupModel {
|
|
|
641
838
|
* const group = await model.getByName("billing_redesign");
|
|
642
839
|
* ```
|
|
643
840
|
*/
|
|
644
|
-
async getByName(name) {
|
|
645
|
-
const
|
|
841
|
+
async getByName(name, { includeArchived = false } = {}) {
|
|
842
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
843
|
+
const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE name = $1${filter}`;
|
|
646
844
|
const res = await this.db.query(sql, [name]);
|
|
647
845
|
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
648
846
|
}
|
|
@@ -659,8 +857,9 @@ export class FeatureFlagGroupModel {
|
|
|
659
857
|
* }
|
|
660
858
|
* ```
|
|
661
859
|
*/
|
|
662
|
-
async list() {
|
|
663
|
-
const
|
|
860
|
+
async list({ includeArchived = false, } = {}) {
|
|
861
|
+
const filter = includeArchived ? "" : " WHERE archived IS NULL";
|
|
862
|
+
const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE}${filter} ORDER BY created DESC`;
|
|
664
863
|
const res = await this.db.query(sql);
|
|
665
864
|
return res.rows.map(mapGroupRow);
|
|
666
865
|
}
|
|
@@ -702,11 +901,16 @@ export class FeatureFlagGroupModel {
|
|
|
702
901
|
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
703
902
|
}
|
|
704
903
|
/**
|
|
705
|
-
*
|
|
904
|
+
* Permanently deletes a feature flag group and all its member and subject
|
|
905
|
+
* relationships (via ON DELETE CASCADE).
|
|
706
906
|
*
|
|
707
907
|
* @param id - The group ID to delete
|
|
708
908
|
* @returns true if deleted, false if not found
|
|
709
909
|
*
|
|
910
|
+
* @remarks
|
|
911
|
+
* This is a hard delete that destroys all metadata. To hide a group while
|
|
912
|
+
* retaining its history for audit purposes, use {@link archive} instead.
|
|
913
|
+
*
|
|
710
914
|
* @example
|
|
711
915
|
* ```typescript
|
|
712
916
|
* const deleted = await model.delete(1);
|
|
@@ -716,6 +920,31 @@ export class FeatureFlagGroupModel {
|
|
|
716
920
|
const res = await this.db.query(`DELETE FROM ${GROUP_TABLE} WHERE id = $1`, [id]);
|
|
717
921
|
return res.rowCount > 0;
|
|
718
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* Archives (hides) a feature flag group while keeping the row for
|
|
925
|
+
* historical/audit purposes. Archived groups are excluded from normal reads
|
|
926
|
+
* and no longer contribute subject-based targeting. Member flags are left
|
|
927
|
+
* untouched and remain active; the membership and subject rows are kept for
|
|
928
|
+
* audit but become inert.
|
|
929
|
+
*
|
|
930
|
+
* @param id - The group ID to archive
|
|
931
|
+
* @returns The archived group, or null if it does not exist or is already archived
|
|
932
|
+
*
|
|
933
|
+
* @remarks
|
|
934
|
+
* Archiving is irreversible: there is intentionally no `unarchive` method.
|
|
935
|
+
* The `archived` timestamp is set once and never changed (a second call is a
|
|
936
|
+
* no-op that returns null). The group's name remains reserved permanently.
|
|
937
|
+
*
|
|
938
|
+
* @example
|
|
939
|
+
* ```typescript
|
|
940
|
+
* const archived = await model.archive(1);
|
|
941
|
+
* ```
|
|
942
|
+
*/
|
|
943
|
+
async archive(id) {
|
|
944
|
+
const sql = `UPDATE ${GROUP_TABLE} SET archived = now() WHERE id = $1 AND archived IS NULL RETURNING ${GROUP_COLUMNS.join(", ")}`;
|
|
945
|
+
const res = await this.db.query(sql, [id]);
|
|
946
|
+
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
947
|
+
}
|
|
719
948
|
/**
|
|
720
949
|
* Adds a feature flag to a group.
|
|
721
950
|
*
|
|
@@ -777,12 +1006,13 @@ export class FeatureFlagGroupModel {
|
|
|
777
1006
|
* }
|
|
778
1007
|
* ```
|
|
779
1008
|
*/
|
|
780
|
-
async getFeatureFlags(groupId) {
|
|
1009
|
+
async getFeatureFlags(groupId, { includeArchived = false } = {}) {
|
|
1010
|
+
const filter = includeArchived ? "" : " AND f.archived IS NULL";
|
|
781
1011
|
const sql = `
|
|
782
1012
|
SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
|
|
783
1013
|
FROM ${TABLE} f
|
|
784
1014
|
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON f.id = gm.feature_flag_id
|
|
785
|
-
WHERE gm.group_id = $1
|
|
1015
|
+
WHERE gm.group_id = $1${filter}
|
|
786
1016
|
ORDER BY f.created DESC
|
|
787
1017
|
`;
|
|
788
1018
|
const res = await this.db.query(sql, [groupId]);
|
|
@@ -799,17 +1029,69 @@ export class FeatureFlagGroupModel {
|
|
|
799
1029
|
* const groups = await model.getGroupsForFeatureFlag(5);
|
|
800
1030
|
* ```
|
|
801
1031
|
*/
|
|
802
|
-
async getGroupsForFeatureFlag(featureFlagId) {
|
|
1032
|
+
async getGroupsForFeatureFlag(featureFlagId, { includeArchived = false } = {}) {
|
|
1033
|
+
const filter = includeArchived ? "" : " AND g.archived IS NULL";
|
|
803
1034
|
const sql = `
|
|
804
1035
|
SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
|
|
805
1036
|
FROM ${GROUP_TABLE} g
|
|
806
1037
|
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON g.id = gm.group_id
|
|
807
|
-
WHERE gm.feature_flag_id = $1
|
|
1038
|
+
WHERE gm.feature_flag_id = $1${filter}
|
|
808
1039
|
ORDER BY g.created DESC
|
|
809
1040
|
`;
|
|
810
1041
|
const res = await this.db.query(sql, [featureFlagId]);
|
|
811
1042
|
return res.rows.map(mapGroupRow);
|
|
812
1043
|
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Adds an external subject identifier to a feature flag group.
|
|
1046
|
+
*/
|
|
1047
|
+
async addSubject(groupId, subject) {
|
|
1048
|
+
try {
|
|
1049
|
+
const sql = `INSERT INTO ${GROUP_SUBJECT_TABLE} (feature_flag_group_id, subject)
|
|
1050
|
+
VALUES ($1, $2)`;
|
|
1051
|
+
await this.db.query(sql, [groupId, subject]);
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
if (err.code === "23505") {
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
throw err;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Removes an external subject identifier from a feature flag group.
|
|
1063
|
+
*/
|
|
1064
|
+
async removeSubject(groupId, subject) {
|
|
1065
|
+
const sql = `DELETE FROM ${GROUP_SUBJECT_TABLE}
|
|
1066
|
+
WHERE feature_flag_group_id = $1 AND subject = $2`;
|
|
1067
|
+
const res = await this.db.query(sql, [groupId, subject]);
|
|
1068
|
+
return res.rowCount > 0;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Gets all external subject identifiers associated with a feature flag group.
|
|
1072
|
+
*/
|
|
1073
|
+
async getSubjects(groupId) {
|
|
1074
|
+
const sql = `SELECT subject FROM ${GROUP_SUBJECT_TABLE}
|
|
1075
|
+
WHERE feature_flag_group_id = $1
|
|
1076
|
+
ORDER BY created DESC`;
|
|
1077
|
+
const res = await this.db.query(sql, [groupId]);
|
|
1078
|
+
return res.rows.map((row) => row.subject);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Gets all groups that are associated with a specific external subject identifier.
|
|
1082
|
+
*/
|
|
1083
|
+
async getGroupsForSubject(subject, { includeArchived = false } = {}) {
|
|
1084
|
+
const filter = includeArchived ? "" : " AND g.archived IS NULL";
|
|
1085
|
+
const sql = `
|
|
1086
|
+
SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
|
|
1087
|
+
FROM ${GROUP_TABLE} g
|
|
1088
|
+
INNER JOIN ${GROUP_SUBJECT_TABLE} gs ON g.id = gs.feature_flag_group_id
|
|
1089
|
+
WHERE gs.subject = $1${filter}
|
|
1090
|
+
ORDER BY g.created DESC
|
|
1091
|
+
`;
|
|
1092
|
+
const res = await this.db.query(sql, [subject]);
|
|
1093
|
+
return res.rows.map(mapGroupRow);
|
|
1094
|
+
}
|
|
813
1095
|
/**
|
|
814
1096
|
* Updates all feature flags in a group with the same changes.
|
|
815
1097
|
*
|
|
@@ -858,10 +1140,10 @@ export class FeatureFlagGroupModel {
|
|
|
858
1140
|
}
|
|
859
1141
|
vals.push(groupId);
|
|
860
1142
|
const sql = `
|
|
861
|
-
UPDATE ${TABLE}
|
|
1143
|
+
UPDATE ${TABLE}
|
|
862
1144
|
SET ${updates.join(", ")}
|
|
863
|
-
WHERE id IN (
|
|
864
|
-
SELECT feature_flag_id
|
|
1145
|
+
WHERE archived IS NULL AND id IN (
|
|
1146
|
+
SELECT feature_flag_id
|
|
865
1147
|
FROM ${GROUP_MEMBER_TABLE}
|
|
866
1148
|
WHERE group_id = $${paramIdx}
|
|
867
1149
|
)
|
package/dist/types.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type FeatureFlag = {
|
|
|
16
16
|
groups?: string[];
|
|
17
17
|
/** List of specific user IDs that have this feature flag enabled */
|
|
18
18
|
users?: string[];
|
|
19
|
+
/** List of tags for organizing flags (e.g. a release version like "release:1.5.0") */
|
|
20
|
+
tags?: string[];
|
|
19
21
|
/** Description of where this flag is used and what it does */
|
|
20
22
|
note?: string;
|
|
21
23
|
/** Date when the feature flag was created */
|
|
@@ -24,6 +26,11 @@ export type FeatureFlag = {
|
|
|
24
26
|
modified: Date;
|
|
25
27
|
/** Optional expiration date for the feature flag */
|
|
26
28
|
expires?: Date;
|
|
29
|
+
/**
|
|
30
|
+
* Date when the feature flag was archived (hidden). Set once and immutable;
|
|
31
|
+
* archiving is irreversible and the name remains reserved permanently.
|
|
32
|
+
*/
|
|
33
|
+
archived?: Date;
|
|
27
34
|
};
|
|
28
35
|
/**
|
|
29
36
|
* Called when a feature flag which has expired is used.
|
|
@@ -56,5 +63,10 @@ export type FeatureFlagGroup = {
|
|
|
56
63
|
created: Date;
|
|
57
64
|
/** Date when the group was last modified */
|
|
58
65
|
modified: Date;
|
|
66
|
+
/**
|
|
67
|
+
* Date when the group was archived (hidden). Set once and immutable;
|
|
68
|
+
* archiving is irreversible and the name remains reserved permanently.
|
|
69
|
+
*/
|
|
70
|
+
archived?: Date;
|
|
59
71
|
};
|
|
60
72
|
//# 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;IACf,oDAAoD;IACpD,OAAO,CAAC,EAAE,IAAI,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,sFAAsF;IACtF,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,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;IACf;;;OAGG;IACH,QAAQ,CAAC,EAAE,IAAI,CAAC;CACjB,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;IACf;;;OAGG;IACH,QAAQ,CAAC,EAAE,IAAI,CAAC;CACjB,CAAC"}
|