@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/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 sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = $1`;
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 sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = $1`;
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 sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = ANY($1)`;
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 sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = ANY($1)`;
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 sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} ORDER BY id`;
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
- * Deletes a feature flag from the database.
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
- return this.evaluateFlagForUser(flag, { user, roles, groups });
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 = ["id", "name", "note", "created", "modified"];
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 sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE id = $1`;
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 sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE name = $1`;
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 sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} ORDER BY created DESC`;
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
- * Deletes a feature flag group and all its member relationships.
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
@@ -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;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"}
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"}