@brandtg/flapjack 1.2.0 → 1.4.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 +84 -3
- 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 +735 -1
- package/dist/model.d.ts +72 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +184 -2
- package/migrations/1779210257698_add-feature-flag-subjects.js +93 -0
- package/package.json +2 -2
package/dist/model.d.ts
CHANGED
|
@@ -170,6 +170,23 @@ export declare class FeatureFlagModel {
|
|
|
170
170
|
* ```
|
|
171
171
|
*/
|
|
172
172
|
delete(id: number): Promise<boolean>;
|
|
173
|
+
/**
|
|
174
|
+
* Adds an external subject identifier to a feature flag.
|
|
175
|
+
*/
|
|
176
|
+
addSubject(featureFlagId: number, subject: string): Promise<boolean>;
|
|
177
|
+
/**
|
|
178
|
+
* Removes an external subject identifier from a feature flag.
|
|
179
|
+
*/
|
|
180
|
+
removeSubject(featureFlagId: number, subject: string): Promise<boolean>;
|
|
181
|
+
/**
|
|
182
|
+
* Gets all external subject identifiers associated with a feature flag.
|
|
183
|
+
*/
|
|
184
|
+
getSubjects(featureFlagId: number): Promise<string[]>;
|
|
185
|
+
/**
|
|
186
|
+
* Gets all feature flags directly associated with an external subject identifier.
|
|
187
|
+
*/
|
|
188
|
+
getFeatureFlagsForSubject(subject: string): Promise<FeatureFlag[]>;
|
|
189
|
+
private getSubjectMatchedFlagIds;
|
|
173
190
|
/**
|
|
174
191
|
* Checks if a user belongs to any of the specified groups
|
|
175
192
|
*/
|
|
@@ -178,10 +195,12 @@ export declare class FeatureFlagModel {
|
|
|
178
195
|
* Evaluates a feature flag for a user based on flag configuration.
|
|
179
196
|
* This is a stateless helper that performs the actual flag evaluation logic.
|
|
180
197
|
*/
|
|
181
|
-
evaluateFlagForUser(flag: FeatureFlag, { user, roles, groups, }: {
|
|
198
|
+
evaluateFlagForUser(flag: FeatureFlag, { user, roles, groups, subjects, subjectMatchedFlagIds, }: {
|
|
182
199
|
user?: string;
|
|
183
200
|
roles?: string[];
|
|
184
201
|
groups?: string[];
|
|
202
|
+
subjects?: string[];
|
|
203
|
+
subjectMatchedFlagIds?: Set<number>;
|
|
185
204
|
}): Promise<boolean>;
|
|
186
205
|
/**
|
|
187
206
|
* Computes the hash value for a user ID using MurmurHash3.
|
|
@@ -245,6 +264,16 @@ export declare class FeatureFlagModel {
|
|
|
245
264
|
roles?: string[];
|
|
246
265
|
groups?: string[];
|
|
247
266
|
}): Promise<boolean>;
|
|
267
|
+
/**
|
|
268
|
+
* Checks if a feature flag is active for a context, including optional external subjects.
|
|
269
|
+
*/
|
|
270
|
+
isActiveForContext({ name, user, roles, groups, subjects, }: {
|
|
271
|
+
name: string;
|
|
272
|
+
user?: string;
|
|
273
|
+
roles?: string[];
|
|
274
|
+
groups?: string[];
|
|
275
|
+
subjects?: string[];
|
|
276
|
+
}): Promise<boolean>;
|
|
248
277
|
/**
|
|
249
278
|
* Checks if multiple feature flags are active for a user based on configured rules.
|
|
250
279
|
*
|
|
@@ -298,6 +327,32 @@ export declare class FeatureFlagModel {
|
|
|
298
327
|
roles?: string[];
|
|
299
328
|
groups?: string[];
|
|
300
329
|
}): Promise<Record<string, boolean>>;
|
|
330
|
+
/**
|
|
331
|
+
* Checks if multiple feature flags are active for a context, including optional external subjects.
|
|
332
|
+
*/
|
|
333
|
+
areActiveForContext({ names, user, roles, groups, subjects, }: {
|
|
334
|
+
names?: string[];
|
|
335
|
+
user?: string;
|
|
336
|
+
roles?: string[];
|
|
337
|
+
groups?: string[];
|
|
338
|
+
subjects?: string[];
|
|
339
|
+
}): Promise<Record<string, boolean>>;
|
|
340
|
+
/**
|
|
341
|
+
* Gets the latest modified timestamp across all feature flags.
|
|
342
|
+
*
|
|
343
|
+
* @returns The latest modified timestamp in milliseconds since epoch, or -1 if the table is empty
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```typescript
|
|
347
|
+
* const lastModified = await model.getLastModified();
|
|
348
|
+
* if (lastModified === -1) {
|
|
349
|
+
* console.log("No feature flags exist");
|
|
350
|
+
* } else {
|
|
351
|
+
* console.log(`Last modified: ${new Date(lastModified)}`);
|
|
352
|
+
* }
|
|
353
|
+
* ```
|
|
354
|
+
*/
|
|
355
|
+
getLastModified(): Promise<number>;
|
|
301
356
|
}
|
|
302
357
|
type CreateGroupInput = Omit<FeatureFlagGroup, "id" | "created" | "modified">;
|
|
303
358
|
type UpdateGroupChanges = Partial<Omit<FeatureFlagGroup, "id" | "created" | "modified">>;
|
|
@@ -470,6 +525,22 @@ export declare class FeatureFlagGroupModel {
|
|
|
470
525
|
* ```
|
|
471
526
|
*/
|
|
472
527
|
getGroupsForFeatureFlag(featureFlagId: number): Promise<FeatureFlagGroup[]>;
|
|
528
|
+
/**
|
|
529
|
+
* Adds an external subject identifier to a feature flag group.
|
|
530
|
+
*/
|
|
531
|
+
addSubject(groupId: number, subject: string): Promise<boolean>;
|
|
532
|
+
/**
|
|
533
|
+
* Removes an external subject identifier from a feature flag group.
|
|
534
|
+
*/
|
|
535
|
+
removeSubject(groupId: number, subject: string): Promise<boolean>;
|
|
536
|
+
/**
|
|
537
|
+
* Gets all external subject identifiers associated with a feature flag group.
|
|
538
|
+
*/
|
|
539
|
+
getSubjects(groupId: number): Promise<string[]>;
|
|
540
|
+
/**
|
|
541
|
+
* Gets all groups that are associated with a specific external subject identifier.
|
|
542
|
+
*/
|
|
543
|
+
getGroupsForSubject(subject: string): Promise<FeatureFlagGroup[]>;
|
|
473
544
|
/**
|
|
474
545
|
* Updates all feature flags in a group with the same changes.
|
|
475
546
|
*
|
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,EACV,WAAW,EACX,wBAAwB,EACxB,gBAAgB,EACjB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,IAAI,CAAC;AAItC,UAAU,SAAS;IACjB,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;CAC/D;
|
|
1
|
+
{"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
|
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",
|
|
@@ -311,6 +313,69 @@ export class FeatureFlagModel {
|
|
|
311
313
|
const res = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
|
|
312
314
|
return res.rowCount > 0;
|
|
313
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Adds an external subject identifier to a feature flag.
|
|
318
|
+
*/
|
|
319
|
+
async addSubject(featureFlagId, subject) {
|
|
320
|
+
try {
|
|
321
|
+
const sql = `INSERT INTO ${SUBJECT_TABLE} (feature_flag_id, subject) VALUES ($1, $2)`;
|
|
322
|
+
await this.db.query(sql, [featureFlagId, subject]);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
if (err.code === "23505") {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Removes an external subject identifier from a feature flag.
|
|
334
|
+
*/
|
|
335
|
+
async removeSubject(featureFlagId, subject) {
|
|
336
|
+
const sql = `DELETE FROM ${SUBJECT_TABLE} WHERE feature_flag_id = $1 AND subject = $2`;
|
|
337
|
+
const res = await this.db.query(sql, [featureFlagId, subject]);
|
|
338
|
+
return res.rowCount > 0;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Gets all external subject identifiers associated with a feature flag.
|
|
342
|
+
*/
|
|
343
|
+
async getSubjects(featureFlagId) {
|
|
344
|
+
const sql = `SELECT subject FROM ${SUBJECT_TABLE} WHERE feature_flag_id = $1 ORDER BY created DESC`;
|
|
345
|
+
const res = await this.db.query(sql, [featureFlagId]);
|
|
346
|
+
return res.rows.map((row) => row.subject);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Gets all feature flags directly associated with an external subject identifier.
|
|
350
|
+
*/
|
|
351
|
+
async getFeatureFlagsForSubject(subject) {
|
|
352
|
+
const sql = `
|
|
353
|
+
SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
|
|
354
|
+
FROM ${TABLE} f
|
|
355
|
+
INNER JOIN ${SUBJECT_TABLE} s ON f.id = s.feature_flag_id
|
|
356
|
+
WHERE s.subject = $1
|
|
357
|
+
ORDER BY f.created DESC
|
|
358
|
+
`;
|
|
359
|
+
const res = await this.db.query(sql, [subject]);
|
|
360
|
+
return res.rows.map(mapRow);
|
|
361
|
+
}
|
|
362
|
+
async getSubjectMatchedFlagIds(subjects = []) {
|
|
363
|
+
if (subjects.length === 0) {
|
|
364
|
+
return new Set();
|
|
365
|
+
}
|
|
366
|
+
const sql = `
|
|
367
|
+
SELECT s.feature_flag_id AS feature_flag_id
|
|
368
|
+
FROM ${SUBJECT_TABLE} s
|
|
369
|
+
WHERE s.subject = ANY($1)
|
|
370
|
+
UNION
|
|
371
|
+
SELECT gm.feature_flag_id AS feature_flag_id
|
|
372
|
+
FROM ${GROUP_SUBJECT_TABLE} gs
|
|
373
|
+
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON gs.feature_flag_group_id = gm.group_id
|
|
374
|
+
WHERE gs.subject = ANY($1)
|
|
375
|
+
`;
|
|
376
|
+
const res = await this.db.query(sql, [subjects]);
|
|
377
|
+
return new Set(res.rows.map((row) => Number(row.feature_flag_id)));
|
|
378
|
+
}
|
|
314
379
|
/**
|
|
315
380
|
* Checks if a user belongs to any of the specified groups
|
|
316
381
|
*/
|
|
@@ -323,7 +388,7 @@ export class FeatureFlagModel {
|
|
|
323
388
|
* Evaluates a feature flag for a user based on flag configuration.
|
|
324
389
|
* This is a stateless helper that performs the actual flag evaluation logic.
|
|
325
390
|
*/
|
|
326
|
-
async evaluateFlagForUser(flag, { user, roles, groups, }) {
|
|
391
|
+
async evaluateFlagForUser(flag, { user, roles, groups, subjects, subjectMatchedFlagIds, }) {
|
|
327
392
|
// Everyone Override: If everyone is true or false, return that value immediately
|
|
328
393
|
if (flag.everyone !== undefined && flag.everyone !== null) {
|
|
329
394
|
return flag.everyone;
|
|
@@ -332,6 +397,20 @@ export class FeatureFlagModel {
|
|
|
332
397
|
if (user && flag.users && flag.users.includes(user)) {
|
|
333
398
|
return true;
|
|
334
399
|
}
|
|
400
|
+
// Subject Check: If any external subject maps to this flag directly or via group, return true
|
|
401
|
+
if (subjects && subjects.length > 0) {
|
|
402
|
+
let isSubjectMatched = false;
|
|
403
|
+
if (subjectMatchedFlagIds) {
|
|
404
|
+
isSubjectMatched = subjectMatchedFlagIds.has(flag.id);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const matchedFlagIds = await this.getSubjectMatchedFlagIds(subjects);
|
|
408
|
+
isSubjectMatched = matchedFlagIds.has(flag.id);
|
|
409
|
+
}
|
|
410
|
+
if (isSubjectMatched) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
335
414
|
// Group Check: If any of the user's groups match any group in the groups array, return true
|
|
336
415
|
if (groups && this.isActiveForGroups(groups, flag.groups)) {
|
|
337
416
|
return true;
|
|
@@ -414,6 +493,17 @@ export class FeatureFlagModel {
|
|
|
414
493
|
* ```
|
|
415
494
|
*/
|
|
416
495
|
async isActiveForUser({ name, user, roles, groups, }) {
|
|
496
|
+
return this.isActiveForContext({
|
|
497
|
+
name,
|
|
498
|
+
user,
|
|
499
|
+
roles,
|
|
500
|
+
groups,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Checks if a feature flag is active for a context, including optional external subjects.
|
|
505
|
+
*/
|
|
506
|
+
async isActiveForContext({ name, user, roles, groups, subjects, }) {
|
|
417
507
|
const flag = await this.getByName(name);
|
|
418
508
|
// No such flag
|
|
419
509
|
if (!flag) {
|
|
@@ -429,7 +519,14 @@ export class FeatureFlagModel {
|
|
|
429
519
|
return expiredResult;
|
|
430
520
|
}
|
|
431
521
|
}
|
|
432
|
-
|
|
522
|
+
const subjectMatchedFlagIds = await this.getSubjectMatchedFlagIds(subjects);
|
|
523
|
+
return this.evaluateFlagForUser(flag, {
|
|
524
|
+
user,
|
|
525
|
+
roles,
|
|
526
|
+
groups,
|
|
527
|
+
subjects,
|
|
528
|
+
subjectMatchedFlagIds,
|
|
529
|
+
});
|
|
433
530
|
}
|
|
434
531
|
/**
|
|
435
532
|
* Checks if multiple feature flags are active for a user based on configured rules.
|
|
@@ -479,6 +576,17 @@ export class FeatureFlagModel {
|
|
|
479
576
|
* ```
|
|
480
577
|
*/
|
|
481
578
|
async areActiveForUser({ names, user, roles, groups, }) {
|
|
579
|
+
return this.areActiveForContext({
|
|
580
|
+
names,
|
|
581
|
+
user,
|
|
582
|
+
roles,
|
|
583
|
+
groups,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Checks if multiple feature flags are active for a context, including optional external subjects.
|
|
588
|
+
*/
|
|
589
|
+
async areActiveForContext({ names, user, roles, groups, subjects, }) {
|
|
482
590
|
let flags;
|
|
483
591
|
let requestedNames;
|
|
484
592
|
if (names === undefined) {
|
|
@@ -493,6 +601,7 @@ export class FeatureFlagModel {
|
|
|
493
601
|
}
|
|
494
602
|
const flagMap = new Map(flags.map((flag) => [flag.name, flag]));
|
|
495
603
|
const result = {};
|
|
604
|
+
const subjectMatchedFlagIds = await this.getSubjectMatchedFlagIds(subjects);
|
|
496
605
|
for (const name of requestedNames) {
|
|
497
606
|
const flag = flagMap.get(name);
|
|
498
607
|
if (!flag) {
|
|
@@ -513,10 +622,33 @@ export class FeatureFlagModel {
|
|
|
513
622
|
user,
|
|
514
623
|
roles,
|
|
515
624
|
groups,
|
|
625
|
+
subjects,
|
|
626
|
+
subjectMatchedFlagIds,
|
|
516
627
|
});
|
|
517
628
|
}
|
|
518
629
|
return result;
|
|
519
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Gets the latest modified timestamp across all feature flags.
|
|
633
|
+
*
|
|
634
|
+
* @returns The latest modified timestamp in milliseconds since epoch, or -1 if the table is empty
|
|
635
|
+
*
|
|
636
|
+
* @example
|
|
637
|
+
* ```typescript
|
|
638
|
+
* const lastModified = await model.getLastModified();
|
|
639
|
+
* if (lastModified === -1) {
|
|
640
|
+
* console.log("No feature flags exist");
|
|
641
|
+
* } else {
|
|
642
|
+
* console.log(`Last modified: ${new Date(lastModified)}`);
|
|
643
|
+
* }
|
|
644
|
+
* ```
|
|
645
|
+
*/
|
|
646
|
+
async getLastModified() {
|
|
647
|
+
const sql = `SELECT MAX(modified) as max_modified FROM ${TABLE}`;
|
|
648
|
+
const res = await this.db.query(sql);
|
|
649
|
+
const maxModified = res.rows[0]?.max_modified;
|
|
650
|
+
return maxModified ? new Date(maxModified).getTime() : -1;
|
|
651
|
+
}
|
|
520
652
|
}
|
|
521
653
|
const GROUP_TABLE = "flapjack.feature_flag_group";
|
|
522
654
|
const GROUP_MEMBER_TABLE = "flapjack.feature_flag_group_member";
|
|
@@ -789,6 +921,56 @@ export class FeatureFlagGroupModel {
|
|
|
789
921
|
const res = await this.db.query(sql, [featureFlagId]);
|
|
790
922
|
return res.rows.map(mapGroupRow);
|
|
791
923
|
}
|
|
924
|
+
/**
|
|
925
|
+
* Adds an external subject identifier to a feature flag group.
|
|
926
|
+
*/
|
|
927
|
+
async addSubject(groupId, subject) {
|
|
928
|
+
try {
|
|
929
|
+
const sql = `INSERT INTO ${GROUP_SUBJECT_TABLE} (feature_flag_group_id, subject)
|
|
930
|
+
VALUES ($1, $2)`;
|
|
931
|
+
await this.db.query(sql, [groupId, subject]);
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
if (err.code === "23505") {
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
throw err;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Removes an external subject identifier from a feature flag group.
|
|
943
|
+
*/
|
|
944
|
+
async removeSubject(groupId, subject) {
|
|
945
|
+
const sql = `DELETE FROM ${GROUP_SUBJECT_TABLE}
|
|
946
|
+
WHERE feature_flag_group_id = $1 AND subject = $2`;
|
|
947
|
+
const res = await this.db.query(sql, [groupId, subject]);
|
|
948
|
+
return res.rowCount > 0;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Gets all external subject identifiers associated with a feature flag group.
|
|
952
|
+
*/
|
|
953
|
+
async getSubjects(groupId) {
|
|
954
|
+
const sql = `SELECT subject FROM ${GROUP_SUBJECT_TABLE}
|
|
955
|
+
WHERE feature_flag_group_id = $1
|
|
956
|
+
ORDER BY created DESC`;
|
|
957
|
+
const res = await this.db.query(sql, [groupId]);
|
|
958
|
+
return res.rows.map((row) => row.subject);
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Gets all groups that are associated with a specific external subject identifier.
|
|
962
|
+
*/
|
|
963
|
+
async getGroupsForSubject(subject) {
|
|
964
|
+
const sql = `
|
|
965
|
+
SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
|
|
966
|
+
FROM ${GROUP_TABLE} g
|
|
967
|
+
INNER JOIN ${GROUP_SUBJECT_TABLE} gs ON g.id = gs.feature_flag_group_id
|
|
968
|
+
WHERE gs.subject = $1
|
|
969
|
+
ORDER BY g.created DESC
|
|
970
|
+
`;
|
|
971
|
+
const res = await this.db.query(sql, [subject]);
|
|
972
|
+
return res.rows.map(mapGroupRow);
|
|
973
|
+
}
|
|
792
974
|
/**
|
|
793
975
|
* Updates all feature flags in a group with the same changes.
|
|
794
976
|
*
|
|
@@ -0,0 +1,93 @@
|
|
|
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.createTable(
|
|
13
|
+
{ schema: "flapjack", name: "feature_flag_subject" },
|
|
14
|
+
{
|
|
15
|
+
id: "id",
|
|
16
|
+
feature_flag_id: {
|
|
17
|
+
type: "integer",
|
|
18
|
+
notNull: true,
|
|
19
|
+
references: { schema: "flapjack", name: "feature_flag" },
|
|
20
|
+
onDelete: "CASCADE",
|
|
21
|
+
},
|
|
22
|
+
subject: { type: "text", notNull: true },
|
|
23
|
+
created: {
|
|
24
|
+
type: "timestamptz",
|
|
25
|
+
notNull: true,
|
|
26
|
+
default: pgm.func("now()"),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
pgm.addConstraint(
|
|
32
|
+
{ schema: "flapjack", name: "feature_flag_subject" },
|
|
33
|
+
"unique_feature_flag_subject",
|
|
34
|
+
{
|
|
35
|
+
unique: ["feature_flag_id", "subject"],
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
pgm.createIndex(
|
|
40
|
+
{ schema: "flapjack", name: "feature_flag_subject" },
|
|
41
|
+
"feature_flag_id",
|
|
42
|
+
);
|
|
43
|
+
pgm.createIndex(
|
|
44
|
+
{ schema: "flapjack", name: "feature_flag_subject" },
|
|
45
|
+
"subject",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
pgm.createTable(
|
|
49
|
+
{ schema: "flapjack", name: "feature_flag_group_subject" },
|
|
50
|
+
{
|
|
51
|
+
id: "id",
|
|
52
|
+
feature_flag_group_id: {
|
|
53
|
+
type: "integer",
|
|
54
|
+
notNull: true,
|
|
55
|
+
references: { schema: "flapjack", name: "feature_flag_group" },
|
|
56
|
+
onDelete: "CASCADE",
|
|
57
|
+
},
|
|
58
|
+
subject: { type: "text", notNull: true },
|
|
59
|
+
created: {
|
|
60
|
+
type: "timestamptz",
|
|
61
|
+
notNull: true,
|
|
62
|
+
default: pgm.func("now()"),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
pgm.addConstraint(
|
|
68
|
+
{ schema: "flapjack", name: "feature_flag_group_subject" },
|
|
69
|
+
"unique_feature_flag_group_subject",
|
|
70
|
+
{
|
|
71
|
+
unique: ["feature_flag_group_id", "subject"],
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
pgm.createIndex(
|
|
76
|
+
{ schema: "flapjack", name: "feature_flag_group_subject" },
|
|
77
|
+
"feature_flag_group_id",
|
|
78
|
+
);
|
|
79
|
+
pgm.createIndex(
|
|
80
|
+
{ schema: "flapjack", name: "feature_flag_group_subject" },
|
|
81
|
+
"subject",
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
|
87
|
+
* @param run {() => void | undefined}
|
|
88
|
+
* @returns {Promise<void> | void}
|
|
89
|
+
*/
|
|
90
|
+
export const down = (pgm) => {
|
|
91
|
+
pgm.dropTable({ schema: "flapjack", name: "feature_flag_group_subject" });
|
|
92
|
+
pgm.dropTable({ schema: "flapjack", name: "feature_flag_subject" });
|
|
93
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brandtg/flapjack",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "A simple feature flags library with PostgreSQL integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"clean": "rm -rf dist",
|
|
28
28
|
"dev": "npx tsc --watch",
|
|
29
29
|
"dev:env": "bash bin/dev/env.sh",
|
|
30
|
-
"dev:migrate": "npx dotenv-cli -e env -- node-pg-migrate up",
|
|
30
|
+
"dev:migrate": "npx dotenv-cli -e .env -- node-pg-migrate up -s flapjack --create-schema -t pgmigrations",
|
|
31
31
|
"dev:docker:up": "docker compose -f docker-compose.yml up -d",
|
|
32
32
|
"dev:docker:down": "docker compose -f docker-compose.yml down -v",
|
|
33
33
|
"create-migration": "npx node-pg-migrate create --",
|