@brandtg/flapjack 1.4.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 +64 -10
- package/dist/cli.js +140 -16
- package/dist/model.d.ts +113 -18
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +153 -32
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/migrations/1781107651000_add-feature-flag-tags.js +38 -0
- package/migrations/1781200000000_add-archived.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,6 +181,36 @@ await featureFlags.isActiveForUser({
|
|
|
181
181
|
});
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
+
## Organizing Flags with Tags
|
|
185
|
+
|
|
186
|
+
Tags are freeform labels for organizing flags without encoding that information in
|
|
187
|
+
the flag name. They do **not** affect evaluation — they are purely organizational
|
|
188
|
+
metadata. A common use is associating a release version with a flag, which can then
|
|
189
|
+
be bumped via a database update without any code change.
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Tag a flag with a release version (and any other labels)
|
|
193
|
+
await featureFlags.create({
|
|
194
|
+
name: "checkout_v2",
|
|
195
|
+
tags: ["release:1.5.0", "checkout"],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Find every flag shipping in a given release
|
|
199
|
+
const flags = await featureFlags.listByTag("release:1.5.0");
|
|
200
|
+
|
|
201
|
+
// Bump the release version later — no code change required
|
|
202
|
+
await featureFlags.update(flag.id, { tags: ["release:1.6.0", "checkout"] });
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The equivalent CLI commands:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
flapjack create --name checkout_v2 --tags release:1.5.0 checkout
|
|
209
|
+
flapjack list-by-tag release:1.5.0
|
|
210
|
+
flapjack update <id> --tags release:1.6.0 checkout
|
|
211
|
+
flapjack update <id> --clear-tags
|
|
212
|
+
```
|
|
213
|
+
|
|
184
214
|
## Performance Considerations
|
|
185
215
|
|
|
186
216
|
⚠️ **Important**: Without caching, Flapjack queries the database on every `isActiveForUser()` call. For high-traffic applications, use the built-in caching layer:
|
|
@@ -342,17 +372,21 @@ await featureFlags.update(flagId, { percent: 100 });
|
|
|
342
372
|
|
|
343
373
|
Creates a new feature flag.
|
|
344
374
|
|
|
345
|
-
#### `getById(id: number): Promise<FeatureFlag | null>`
|
|
375
|
+
#### `getById(id: number, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>`
|
|
376
|
+
|
|
377
|
+
Retrieves a feature flag by its ID. Archived flags are excluded unless `includeArchived` is `true`.
|
|
346
378
|
|
|
347
|
-
|
|
379
|
+
#### `getByName(name: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>`
|
|
348
380
|
|
|
349
|
-
|
|
381
|
+
Retrieves a feature flag by its name. Archived flags are excluded unless `includeArchived` is `true`.
|
|
350
382
|
|
|
351
|
-
|
|
383
|
+
#### `list(options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>`
|
|
352
384
|
|
|
353
|
-
|
|
385
|
+
Lists all feature flags, ordered by ID. Archived flags are excluded unless `includeArchived` is `true`.
|
|
354
386
|
|
|
355
|
-
|
|
387
|
+
#### `listByTag(tag: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>`
|
|
388
|
+
|
|
389
|
+
Lists all feature flags carrying the given tag (exact match), ordered by ID. Archived flags are excluded unless `includeArchived` is `true`.
|
|
356
390
|
|
|
357
391
|
#### `update(id: number, changes: UpdateChanges): Promise<FeatureFlag | null>`
|
|
358
392
|
|
|
@@ -360,7 +394,13 @@ Updates a feature flag. Returns the updated flag or null if not found.
|
|
|
360
394
|
|
|
361
395
|
#### `delete(id: number): Promise<boolean>`
|
|
362
396
|
|
|
363
|
-
|
|
397
|
+
Permanently deletes a feature flag and all its relationships. Returns true if deleted, false if not found. To hide a flag while keeping its history, use `archive` instead.
|
|
398
|
+
|
|
399
|
+
#### `archive(id: number): Promise<FeatureFlag | null>`
|
|
400
|
+
|
|
401
|
+
Archives (hides) a feature flag while keeping the row for historical/audit purposes. Archived flags are excluded from normal reads and evaluate as inactive, but remain retrievable via `{ includeArchived: true }`. Returns the archived flag, or null if it does not exist or is already archived.
|
|
402
|
+
|
|
403
|
+
Archiving is **irreversible** (there is no `unarchive`) and the `archived` timestamp is immutable once set. The flag's name stays reserved permanently — a new flag cannot reuse it; create a new flag with a different name instead. The same `archive(id)` / `includeArchived` semantics apply to `FeatureFlagGroupModel`.
|
|
364
404
|
|
|
365
405
|
#### `isActiveForUser(params): Promise<boolean>`
|
|
366
406
|
|
|
@@ -384,6 +424,13 @@ flapjack create --name my_feature --roles admin --note "Admin-only feature"
|
|
|
384
424
|
# List all flags
|
|
385
425
|
flapjack list
|
|
386
426
|
|
|
427
|
+
# Include archived (hidden) flags in any read command
|
|
428
|
+
flapjack list --include-archived
|
|
429
|
+
flapjack get-by-name my_feature --include-archived
|
|
430
|
+
|
|
431
|
+
# List flags carrying a tag (e.g. a release version)
|
|
432
|
+
flapjack list-by-tag release:1.5.0
|
|
433
|
+
|
|
387
434
|
# Get a specific flag
|
|
388
435
|
flapjack get-by-name my_feature
|
|
389
436
|
|
|
@@ -405,9 +452,12 @@ flapjack update 1 --percent 50 --everyone false
|
|
|
405
452
|
# Clear specific fields
|
|
406
453
|
flapjack update 1 --clear-roles --clear-percent
|
|
407
454
|
|
|
408
|
-
# Delete a flag
|
|
455
|
+
# Delete a flag (permanent — destroys all metadata)
|
|
409
456
|
flapjack delete 1
|
|
410
457
|
|
|
458
|
+
# Archive a flag (hide it but keep its history; irreversible)
|
|
459
|
+
flapjack archive 1
|
|
460
|
+
|
|
411
461
|
# Add/remove/list subject mappings on a flag
|
|
412
462
|
flapjack add-subject 1 tenant:acme
|
|
413
463
|
flapjack remove-subject 1 tenant:acme
|
|
@@ -421,6 +471,7 @@ flapjack group-get 1
|
|
|
421
471
|
flapjack group-get-by-name checkout_rollout
|
|
422
472
|
flapjack group-update 1 --note "Updated"
|
|
423
473
|
flapjack group-delete 1
|
|
474
|
+
flapjack group-archive 1
|
|
424
475
|
|
|
425
476
|
# Group membership
|
|
426
477
|
flapjack group-add-flag 1 10
|
|
@@ -490,8 +541,11 @@ await featureFlags.update(flagId, { percent: 10 });
|
|
|
490
541
|
await featureFlags.update(flagId, { everyone: true });
|
|
491
542
|
|
|
492
543
|
// 5. Cleanup (after feature is stable)
|
|
493
|
-
// Remove feature flag checks from code
|
|
494
|
-
|
|
544
|
+
// Remove feature flag checks from code, then either:
|
|
545
|
+
// - delete to remove it entirely, or
|
|
546
|
+
// - archive to hide it while keeping its history for audit
|
|
547
|
+
await featureFlags.archive(flagId);
|
|
548
|
+
// (archive is irreversible and the name stays reserved permanently)
|
|
495
549
|
```
|
|
496
550
|
|
|
497
551
|
### Error Handling
|
package/dist/cli.js
CHANGED
|
@@ -47,6 +47,10 @@ const flagOptions = {
|
|
|
47
47
|
type: "array",
|
|
48
48
|
describe: "List of specific user IDs that have this flag enabled",
|
|
49
49
|
},
|
|
50
|
+
tags: {
|
|
51
|
+
type: "array",
|
|
52
|
+
describe: "List of tags for organizing this flag (e.g. release:1.5.0)",
|
|
53
|
+
},
|
|
50
54
|
note: {
|
|
51
55
|
type: "string",
|
|
52
56
|
describe: "Description of where this flag is used and what it does",
|
|
@@ -56,6 +60,14 @@ const flagOptions = {
|
|
|
56
60
|
describe: "Expiration date (ISO 8601 format)",
|
|
57
61
|
},
|
|
58
62
|
};
|
|
63
|
+
// Option to include archived (hidden) records in read commands
|
|
64
|
+
const includeArchivedOption = {
|
|
65
|
+
"include-archived": {
|
|
66
|
+
type: "boolean",
|
|
67
|
+
default: false,
|
|
68
|
+
describe: "Include archived (hidden) records in the results",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
59
71
|
// Create command
|
|
60
72
|
cli.command("create", "Create a new feature flag", (yargs) => {
|
|
61
73
|
return yargs
|
|
@@ -78,6 +90,8 @@ cli.command("create", "Create a new feature flag", (yargs) => {
|
|
|
78
90
|
input.groups = argv.groups;
|
|
79
91
|
if (argv.users !== undefined)
|
|
80
92
|
input.users = argv.users;
|
|
93
|
+
if (argv.tags !== undefined)
|
|
94
|
+
input.tags = argv.tags;
|
|
81
95
|
if (argv.note !== undefined)
|
|
82
96
|
input.note = argv.note;
|
|
83
97
|
if (argv.expires !== undefined)
|
|
@@ -95,15 +109,19 @@ cli.command("create", "Create a new feature flag", (yargs) => {
|
|
|
95
109
|
});
|
|
96
110
|
// Get by ID command
|
|
97
111
|
cli.command("get <id>", "Get a feature flag by ID", (yargs) => {
|
|
98
|
-
return yargs
|
|
112
|
+
return yargs
|
|
113
|
+
.positional("id", {
|
|
99
114
|
type: "number",
|
|
100
115
|
describe: "Feature flag ID",
|
|
101
|
-
})
|
|
116
|
+
})
|
|
117
|
+
.options(includeArchivedOption);
|
|
102
118
|
}, async (argv) => {
|
|
103
119
|
const db = createDatabase();
|
|
104
120
|
const model = new FeatureFlagModel(db);
|
|
105
121
|
try {
|
|
106
|
-
const flag = await model.getById(argv.id
|
|
122
|
+
const flag = await model.getById(argv.id, {
|
|
123
|
+
includeArchived: argv.includeArchived,
|
|
124
|
+
});
|
|
107
125
|
if (flag) {
|
|
108
126
|
console.log(JSON.stringify(flag, null, 2));
|
|
109
127
|
}
|
|
@@ -122,15 +140,19 @@ cli.command("get <id>", "Get a feature flag by ID", (yargs) => {
|
|
|
122
140
|
});
|
|
123
141
|
// Get by name command
|
|
124
142
|
cli.command("get-by-name <name>", "Get a feature flag by name", (yargs) => {
|
|
125
|
-
return yargs
|
|
143
|
+
return yargs
|
|
144
|
+
.positional("name", {
|
|
126
145
|
type: "string",
|
|
127
146
|
describe: "Feature flag name",
|
|
128
|
-
})
|
|
147
|
+
})
|
|
148
|
+
.options(includeArchivedOption);
|
|
129
149
|
}, async (argv) => {
|
|
130
150
|
const db = createDatabase();
|
|
131
151
|
const model = new FeatureFlagModel(db);
|
|
132
152
|
try {
|
|
133
|
-
const flag = await model.getByName(argv.name
|
|
153
|
+
const flag = await model.getByName(argv.name, {
|
|
154
|
+
includeArchived: argv.includeArchived,
|
|
155
|
+
});
|
|
134
156
|
if (flag) {
|
|
135
157
|
console.log(JSON.stringify(flag, null, 2));
|
|
136
158
|
}
|
|
@@ -148,11 +170,15 @@ cli.command("get-by-name <name>", "Get a feature flag by name", (yargs) => {
|
|
|
148
170
|
}
|
|
149
171
|
});
|
|
150
172
|
// List command
|
|
151
|
-
cli.command("list", "List all feature flags", () => {
|
|
173
|
+
cli.command("list", "List all feature flags", (yargs) => {
|
|
174
|
+
return yargs.options(includeArchivedOption);
|
|
175
|
+
}, async (argv) => {
|
|
152
176
|
const db = createDatabase();
|
|
153
177
|
const model = new FeatureFlagModel(db);
|
|
154
178
|
try {
|
|
155
|
-
const flags = await model.list(
|
|
179
|
+
const flags = await model.list({
|
|
180
|
+
includeArchived: argv.includeArchived,
|
|
181
|
+
});
|
|
156
182
|
console.log(JSON.stringify(flags, null, 2));
|
|
157
183
|
}
|
|
158
184
|
catch (error) {
|
|
@@ -163,6 +189,31 @@ cli.command("list", "List all feature flags", () => { }, async () => {
|
|
|
163
189
|
await db.end();
|
|
164
190
|
}
|
|
165
191
|
});
|
|
192
|
+
// List flags carrying a given tag
|
|
193
|
+
cli.command("list-by-tag <tag>", "List feature flags carrying a given tag (e.g. release:1.5.0)", (yargs) => {
|
|
194
|
+
return yargs
|
|
195
|
+
.positional("tag", {
|
|
196
|
+
type: "string",
|
|
197
|
+
describe: "Tag to filter by",
|
|
198
|
+
})
|
|
199
|
+
.options(includeArchivedOption);
|
|
200
|
+
}, async (argv) => {
|
|
201
|
+
const db = createDatabase();
|
|
202
|
+
const model = new FeatureFlagModel(db);
|
|
203
|
+
try {
|
|
204
|
+
const flags = await model.listByTag(argv.tag, {
|
|
205
|
+
includeArchived: argv.includeArchived,
|
|
206
|
+
});
|
|
207
|
+
console.log(JSON.stringify(flags, null, 2));
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
console.error("Error listing feature flags by tag:", error);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await db.end();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
166
217
|
// Update command
|
|
167
218
|
cli.command("update <id>", "Update a feature flag", (yargs) => {
|
|
168
219
|
return yargs
|
|
@@ -192,6 +243,10 @@ cli.command("update <id>", "Update a feature flag", (yargs) => {
|
|
|
192
243
|
type: "boolean",
|
|
193
244
|
describe: "Clear users list",
|
|
194
245
|
},
|
|
246
|
+
"clear-tags": {
|
|
247
|
+
type: "boolean",
|
|
248
|
+
describe: "Clear tags list",
|
|
249
|
+
},
|
|
195
250
|
"clear-note": {
|
|
196
251
|
type: "boolean",
|
|
197
252
|
describe: "Clear the note",
|
|
@@ -233,6 +288,11 @@ cli.command("update <id>", "Update a feature flag", (yargs) => {
|
|
|
233
288
|
changes.users = null;
|
|
234
289
|
else if (argv.users !== undefined)
|
|
235
290
|
changes.users = argv.users;
|
|
291
|
+
// Tags: clear flag takes precedence
|
|
292
|
+
if (argv.clearTags)
|
|
293
|
+
changes.tags = null;
|
|
294
|
+
else if (argv.tags !== undefined)
|
|
295
|
+
changes.tags = argv.tags;
|
|
236
296
|
// Note: clear flag takes precedence
|
|
237
297
|
if (argv.clearNote)
|
|
238
298
|
changes.note = null;
|
|
@@ -287,6 +347,33 @@ cli.command("delete <id>", "Delete a feature flag", (yargs) => {
|
|
|
287
347
|
await db.end();
|
|
288
348
|
}
|
|
289
349
|
});
|
|
350
|
+
// Archive command (soft delete; irreversible, hides the flag but keeps history)
|
|
351
|
+
cli.command("archive <id>", "Archive (hide) a feature flag while keeping its history. Irreversible.", (yargs) => {
|
|
352
|
+
return yargs.positional("id", {
|
|
353
|
+
type: "number",
|
|
354
|
+
describe: "Feature flag ID",
|
|
355
|
+
});
|
|
356
|
+
}, async (argv) => {
|
|
357
|
+
const db = createDatabase();
|
|
358
|
+
const model = new FeatureFlagModel(db);
|
|
359
|
+
try {
|
|
360
|
+
const flag = await model.archive(argv.id);
|
|
361
|
+
if (flag) {
|
|
362
|
+
console.log(JSON.stringify(flag, null, 2));
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
console.log(`Feature flag with ID ${argv.id} not found or already archived`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
console.error("Error archiving feature flag:", error);
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
await db.end();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
290
377
|
// Check if active for user command
|
|
291
378
|
cli.command("is-active <name>", "Check if a feature flag is active for a user", (yargs) => {
|
|
292
379
|
return yargs
|
|
@@ -626,11 +713,15 @@ cli.command("group-create", "Create a feature flag group", (yargs) => {
|
|
|
626
713
|
}
|
|
627
714
|
});
|
|
628
715
|
// List feature flag groups command
|
|
629
|
-
cli.command("group-list", "List all feature flag groups", () => {
|
|
716
|
+
cli.command("group-list", "List all feature flag groups", (yargs) => {
|
|
717
|
+
return yargs.options(includeArchivedOption);
|
|
718
|
+
}, async (argv) => {
|
|
630
719
|
const db = createDatabase();
|
|
631
720
|
const model = new FeatureFlagGroupModel(db);
|
|
632
721
|
try {
|
|
633
|
-
const groups = await model.list(
|
|
722
|
+
const groups = await model.list({
|
|
723
|
+
includeArchived: argv.includeArchived,
|
|
724
|
+
});
|
|
634
725
|
console.log(JSON.stringify(groups, null, 2));
|
|
635
726
|
}
|
|
636
727
|
catch (error) {
|
|
@@ -643,15 +734,19 @@ cli.command("group-list", "List all feature flag groups", () => { }, async () =>
|
|
|
643
734
|
});
|
|
644
735
|
// Get feature flag group by ID command
|
|
645
736
|
cli.command("group-get <id>", "Get a feature flag group by ID", (yargs) => {
|
|
646
|
-
return yargs
|
|
737
|
+
return yargs
|
|
738
|
+
.positional("id", {
|
|
647
739
|
type: "number",
|
|
648
740
|
describe: "Feature flag group ID",
|
|
649
|
-
})
|
|
741
|
+
})
|
|
742
|
+
.options(includeArchivedOption);
|
|
650
743
|
}, async (argv) => {
|
|
651
744
|
const db = createDatabase();
|
|
652
745
|
const model = new FeatureFlagGroupModel(db);
|
|
653
746
|
try {
|
|
654
|
-
const group = await model.getById(argv.id
|
|
747
|
+
const group = await model.getById(argv.id, {
|
|
748
|
+
includeArchived: argv.includeArchived,
|
|
749
|
+
});
|
|
655
750
|
if (!group) {
|
|
656
751
|
console.log(`Feature flag group with ID ${argv.id} not found`);
|
|
657
752
|
process.exit(1);
|
|
@@ -668,15 +763,19 @@ cli.command("group-get <id>", "Get a feature flag group by ID", (yargs) => {
|
|
|
668
763
|
});
|
|
669
764
|
// Get feature flag group by name command
|
|
670
765
|
cli.command("group-get-by-name <name>", "Get a feature flag group by name", (yargs) => {
|
|
671
|
-
return yargs
|
|
766
|
+
return yargs
|
|
767
|
+
.positional("name", {
|
|
672
768
|
type: "string",
|
|
673
769
|
describe: "Feature flag group name",
|
|
674
|
-
})
|
|
770
|
+
})
|
|
771
|
+
.options(includeArchivedOption);
|
|
675
772
|
}, async (argv) => {
|
|
676
773
|
const db = createDatabase();
|
|
677
774
|
const model = new FeatureFlagGroupModel(db);
|
|
678
775
|
try {
|
|
679
|
-
const group = await model.getByName(argv.name
|
|
776
|
+
const group = await model.getByName(argv.name, {
|
|
777
|
+
includeArchived: argv.includeArchived,
|
|
778
|
+
});
|
|
680
779
|
if (!group) {
|
|
681
780
|
console.log(`Feature flag group with name "${argv.name}" not found`);
|
|
682
781
|
process.exit(1);
|
|
@@ -763,6 +862,31 @@ cli.command("group-delete <id>", "Delete a feature flag group", (yargs) => {
|
|
|
763
862
|
await db.end();
|
|
764
863
|
}
|
|
765
864
|
});
|
|
865
|
+
// Archive a feature flag group (soft delete; irreversible, keeps history)
|
|
866
|
+
cli.command("group-archive <id>", "Archive (hide) a feature flag group while keeping its history. Irreversible.", (yargs) => {
|
|
867
|
+
return yargs.positional("id", {
|
|
868
|
+
type: "number",
|
|
869
|
+
describe: "Feature flag group ID",
|
|
870
|
+
});
|
|
871
|
+
}, async (argv) => {
|
|
872
|
+
const db = createDatabase();
|
|
873
|
+
const model = new FeatureFlagGroupModel(db);
|
|
874
|
+
try {
|
|
875
|
+
const group = await model.archive(argv.id);
|
|
876
|
+
if (!group) {
|
|
877
|
+
console.log(`Feature flag group with ID ${argv.id} not found or already archived`);
|
|
878
|
+
process.exit(1);
|
|
879
|
+
}
|
|
880
|
+
console.log(JSON.stringify(group, null, 2));
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
console.error("Error archiving feature flag group:", error);
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
await db.end();
|
|
888
|
+
}
|
|
889
|
+
});
|
|
766
890
|
// Add flag to feature flag group command
|
|
767
891
|
cli.command("group-add-flag <groupId> <flagId>", "Add a feature flag to a group", (yargs) => {
|
|
768
892
|
return yargs
|
package/dist/model.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ import type { QueryResult } from "pg";
|
|
|
3
3
|
interface Queryable {
|
|
4
4
|
query: (text: string, params?: any[]) => Promise<QueryResult>;
|
|
5
5
|
}
|
|
6
|
-
type CreateInput = Omit<FeatureFlag, "id" | "created" | "modified">;
|
|
7
|
-
type UpdateChanges = Partial<Omit<FeatureFlag, "id" | "created" | "modified">>;
|
|
6
|
+
type CreateInput = Omit<FeatureFlag, "id" | "created" | "modified" | "archived">;
|
|
7
|
+
type UpdateChanges = Partial<Omit<FeatureFlag, "id" | "created" | "modified" | "archived">>;
|
|
8
8
|
/**
|
|
9
9
|
* Model for managing feature flags stored in PostgreSQL.
|
|
10
10
|
*
|
|
@@ -43,6 +43,7 @@ export declare class FeatureFlagModel {
|
|
|
43
43
|
* @param input.roles - Optional list of roles that have this flag enabled
|
|
44
44
|
* @param input.groups - Optional list of user groups that have this flag enabled
|
|
45
45
|
* @param input.users - Optional list of specific user IDs that have this flag enabled
|
|
46
|
+
* @param input.tags - Optional list of tags for organizing the flag (e.g. a release version)
|
|
46
47
|
* @param input.note - Optional description of the flag's purpose
|
|
47
48
|
* @param input.expires - Optional expiration date for the feature flag
|
|
48
49
|
* @returns The created feature flag with generated id, created, and modified timestamps
|
|
@@ -73,7 +74,9 @@ export declare class FeatureFlagModel {
|
|
|
73
74
|
* }
|
|
74
75
|
* ```
|
|
75
76
|
*/
|
|
76
|
-
getById(id: number
|
|
77
|
+
getById(id: number, { includeArchived }?: {
|
|
78
|
+
includeArchived?: boolean;
|
|
79
|
+
}): Promise<FeatureFlag | null>;
|
|
77
80
|
/**
|
|
78
81
|
* Retrieves a feature flag by its name.
|
|
79
82
|
*
|
|
@@ -88,7 +91,9 @@ export declare class FeatureFlagModel {
|
|
|
88
91
|
* }
|
|
89
92
|
* ```
|
|
90
93
|
*/
|
|
91
|
-
getByName(name: string
|
|
94
|
+
getByName(name: string, { includeArchived }?: {
|
|
95
|
+
includeArchived?: boolean;
|
|
96
|
+
}): Promise<FeatureFlag | null>;
|
|
92
97
|
/**
|
|
93
98
|
* Retrieves multiple feature flags by their names.
|
|
94
99
|
*
|
|
@@ -101,7 +106,9 @@ export declare class FeatureFlagModel {
|
|
|
101
106
|
* console.log(`Found ${flags.length} flags`);
|
|
102
107
|
* ```
|
|
103
108
|
*/
|
|
104
|
-
getManyByName(names: string[]
|
|
109
|
+
getManyByName(names: string[], { includeArchived }?: {
|
|
110
|
+
includeArchived?: boolean;
|
|
111
|
+
}): Promise<FeatureFlag[]>;
|
|
105
112
|
/**
|
|
106
113
|
* Retrieves multiple feature flags by their IDs.
|
|
107
114
|
*
|
|
@@ -114,7 +121,9 @@ export declare class FeatureFlagModel {
|
|
|
114
121
|
* console.log(`Found ${flags.length} flags`);
|
|
115
122
|
* ```
|
|
116
123
|
*/
|
|
117
|
-
getMany(ids: number[]
|
|
124
|
+
getMany(ids: number[], { includeArchived }?: {
|
|
125
|
+
includeArchived?: boolean;
|
|
126
|
+
}): Promise<FeatureFlag[]>;
|
|
118
127
|
/**
|
|
119
128
|
* Retrieves all feature flags, ordered by ID.
|
|
120
129
|
*
|
|
@@ -129,7 +138,24 @@ export declare class FeatureFlagModel {
|
|
|
129
138
|
* });
|
|
130
139
|
* ```
|
|
131
140
|
*/
|
|
132
|
-
list(
|
|
141
|
+
list({ includeArchived, }?: {
|
|
142
|
+
includeArchived?: boolean;
|
|
143
|
+
}): Promise<FeatureFlag[]>;
|
|
144
|
+
/**
|
|
145
|
+
* Retrieves all feature flags that carry the given tag.
|
|
146
|
+
*
|
|
147
|
+
* @param tag - The tag to filter by (exact match against a tag in the flag's tags array)
|
|
148
|
+
* @returns Array of feature flags tagged with the given value
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```typescript
|
|
152
|
+
* // Find every flag associated with a release version
|
|
153
|
+
* const flags = await model.listByTag("release:1.5.0");
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
listByTag(tag: string, { includeArchived }?: {
|
|
157
|
+
includeArchived?: boolean;
|
|
158
|
+
}): Promise<FeatureFlag[]>;
|
|
133
159
|
/**
|
|
134
160
|
* Updates an existing feature flag.
|
|
135
161
|
*
|
|
@@ -156,11 +182,16 @@ export declare class FeatureFlagModel {
|
|
|
156
182
|
*/
|
|
157
183
|
update(id: number, changes: UpdateChanges): Promise<FeatureFlag | null>;
|
|
158
184
|
/**
|
|
159
|
-
*
|
|
185
|
+
* Permanently deletes a feature flag from the database, including its
|
|
186
|
+
* subject and group-member relationships (via ON DELETE CASCADE).
|
|
160
187
|
*
|
|
161
188
|
* @param id - The unique identifier of the feature flag to delete
|
|
162
189
|
* @returns true if the flag was deleted, false if not found
|
|
163
190
|
*
|
|
191
|
+
* @remarks
|
|
192
|
+
* This is a hard delete that destroys all metadata. To hide a flag while
|
|
193
|
+
* retaining its history for audit purposes, use {@link archive} instead.
|
|
194
|
+
*
|
|
164
195
|
* @example
|
|
165
196
|
* ```typescript
|
|
166
197
|
* const deleted = await model.delete(flagId);
|
|
@@ -170,6 +201,30 @@ export declare class FeatureFlagModel {
|
|
|
170
201
|
* ```
|
|
171
202
|
*/
|
|
172
203
|
delete(id: number): Promise<boolean>;
|
|
204
|
+
/**
|
|
205
|
+
* Archives (hides) a feature flag while keeping the row for historical/audit
|
|
206
|
+
* purposes. Archived flags are excluded from normal reads and evaluation
|
|
207
|
+
* (an archived flag evaluates to inactive), but can still be retrieved by
|
|
208
|
+
* passing `{ includeArchived: true }` to the read methods.
|
|
209
|
+
*
|
|
210
|
+
* @param id - The unique identifier of the feature flag to archive
|
|
211
|
+
* @returns The archived feature flag, or null if it does not exist or is already archived
|
|
212
|
+
*
|
|
213
|
+
* @remarks
|
|
214
|
+
* Archiving is irreversible: there is intentionally no `unarchive` method.
|
|
215
|
+
* The `archived` timestamp is set once and never changed (a second call is a
|
|
216
|
+
* no-op that returns null). The flag's name remains reserved permanently, so
|
|
217
|
+
* a new flag cannot reuse it — create a new flag with a different name.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```typescript
|
|
221
|
+
* const archived = await model.archive(flagId);
|
|
222
|
+
* if (archived) {
|
|
223
|
+
* console.log(`Archived at ${archived.archived}`);
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
archive(id: number): Promise<FeatureFlag | null>;
|
|
173
228
|
/**
|
|
174
229
|
* Adds an external subject identifier to a feature flag.
|
|
175
230
|
*/
|
|
@@ -185,7 +240,9 @@ export declare class FeatureFlagModel {
|
|
|
185
240
|
/**
|
|
186
241
|
* Gets all feature flags directly associated with an external subject identifier.
|
|
187
242
|
*/
|
|
188
|
-
getFeatureFlagsForSubject(subject: string
|
|
243
|
+
getFeatureFlagsForSubject(subject: string, { includeArchived }?: {
|
|
244
|
+
includeArchived?: boolean;
|
|
245
|
+
}): Promise<FeatureFlag[]>;
|
|
189
246
|
private getSubjectMatchedFlagIds;
|
|
190
247
|
/**
|
|
191
248
|
* Checks if a user belongs to any of the specified groups
|
|
@@ -354,8 +411,8 @@ export declare class FeatureFlagModel {
|
|
|
354
411
|
*/
|
|
355
412
|
getLastModified(): Promise<number>;
|
|
356
413
|
}
|
|
357
|
-
type CreateGroupInput = Omit<FeatureFlagGroup, "id" | "created" | "modified">;
|
|
358
|
-
type UpdateGroupChanges = Partial<Omit<FeatureFlagGroup, "id" | "created" | "modified">>;
|
|
414
|
+
type CreateGroupInput = Omit<FeatureFlagGroup, "id" | "created" | "modified" | "archived">;
|
|
415
|
+
type UpdateGroupChanges = Partial<Omit<FeatureFlagGroup, "id" | "created" | "modified" | "archived">>;
|
|
359
416
|
type UpdateAllChanges = Partial<Pick<FeatureFlag, "everyone" | "percent" | "roles" | "groups" | "users">>;
|
|
360
417
|
/**
|
|
361
418
|
* Model for managing feature flag groups stored in PostgreSQL.
|
|
@@ -416,7 +473,9 @@ export declare class FeatureFlagGroupModel {
|
|
|
416
473
|
* }
|
|
417
474
|
* ```
|
|
418
475
|
*/
|
|
419
|
-
getById(id: number
|
|
476
|
+
getById(id: number, { includeArchived }?: {
|
|
477
|
+
includeArchived?: boolean;
|
|
478
|
+
}): Promise<FeatureFlagGroup | null>;
|
|
420
479
|
/**
|
|
421
480
|
* Retrieves a feature flag group by its name.
|
|
422
481
|
*
|
|
@@ -428,7 +487,9 @@ export declare class FeatureFlagGroupModel {
|
|
|
428
487
|
* const group = await model.getByName("billing_redesign");
|
|
429
488
|
* ```
|
|
430
489
|
*/
|
|
431
|
-
getByName(name: string
|
|
490
|
+
getByName(name: string, { includeArchived }?: {
|
|
491
|
+
includeArchived?: boolean;
|
|
492
|
+
}): Promise<FeatureFlagGroup | null>;
|
|
432
493
|
/**
|
|
433
494
|
* Lists all feature flag groups.
|
|
434
495
|
*
|
|
@@ -442,7 +503,9 @@ export declare class FeatureFlagGroupModel {
|
|
|
442
503
|
* }
|
|
443
504
|
* ```
|
|
444
505
|
*/
|
|
445
|
-
list(
|
|
506
|
+
list({ includeArchived, }?: {
|
|
507
|
+
includeArchived?: boolean;
|
|
508
|
+
}): Promise<FeatureFlagGroup[]>;
|
|
446
509
|
/**
|
|
447
510
|
* Updates a feature flag group.
|
|
448
511
|
*
|
|
@@ -459,17 +522,43 @@ export declare class FeatureFlagGroupModel {
|
|
|
459
522
|
*/
|
|
460
523
|
update(id: number, changes: UpdateGroupChanges): Promise<FeatureFlagGroup | null>;
|
|
461
524
|
/**
|
|
462
|
-
*
|
|
525
|
+
* Permanently deletes a feature flag group and all its member and subject
|
|
526
|
+
* relationships (via ON DELETE CASCADE).
|
|
463
527
|
*
|
|
464
528
|
* @param id - The group ID to delete
|
|
465
529
|
* @returns true if deleted, false if not found
|
|
466
530
|
*
|
|
531
|
+
* @remarks
|
|
532
|
+
* This is a hard delete that destroys all metadata. To hide a group while
|
|
533
|
+
* retaining its history for audit purposes, use {@link archive} instead.
|
|
534
|
+
*
|
|
467
535
|
* @example
|
|
468
536
|
* ```typescript
|
|
469
537
|
* const deleted = await model.delete(1);
|
|
470
538
|
* ```
|
|
471
539
|
*/
|
|
472
540
|
delete(id: number): Promise<boolean>;
|
|
541
|
+
/**
|
|
542
|
+
* Archives (hides) a feature flag group while keeping the row for
|
|
543
|
+
* historical/audit purposes. Archived groups are excluded from normal reads
|
|
544
|
+
* and no longer contribute subject-based targeting. Member flags are left
|
|
545
|
+
* untouched and remain active; the membership and subject rows are kept for
|
|
546
|
+
* audit but become inert.
|
|
547
|
+
*
|
|
548
|
+
* @param id - The group ID to archive
|
|
549
|
+
* @returns The archived group, or null if it does not exist or is already archived
|
|
550
|
+
*
|
|
551
|
+
* @remarks
|
|
552
|
+
* Archiving is irreversible: there is intentionally no `unarchive` method.
|
|
553
|
+
* The `archived` timestamp is set once and never changed (a second call is a
|
|
554
|
+
* no-op that returns null). The group's name remains reserved permanently.
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* ```typescript
|
|
558
|
+
* const archived = await model.archive(1);
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
archive(id: number): Promise<FeatureFlagGroup | null>;
|
|
473
562
|
/**
|
|
474
563
|
* Adds a feature flag to a group.
|
|
475
564
|
*
|
|
@@ -512,7 +601,9 @@ export declare class FeatureFlagGroupModel {
|
|
|
512
601
|
* }
|
|
513
602
|
* ```
|
|
514
603
|
*/
|
|
515
|
-
getFeatureFlags(groupId: number
|
|
604
|
+
getFeatureFlags(groupId: number, { includeArchived }?: {
|
|
605
|
+
includeArchived?: boolean;
|
|
606
|
+
}): Promise<FeatureFlag[]>;
|
|
516
607
|
/**
|
|
517
608
|
* Gets all groups that contain a specific feature flag.
|
|
518
609
|
*
|
|
@@ -524,7 +615,9 @@ export declare class FeatureFlagGroupModel {
|
|
|
524
615
|
* const groups = await model.getGroupsForFeatureFlag(5);
|
|
525
616
|
* ```
|
|
526
617
|
*/
|
|
527
|
-
getGroupsForFeatureFlag(featureFlagId: number
|
|
618
|
+
getGroupsForFeatureFlag(featureFlagId: number, { includeArchived }?: {
|
|
619
|
+
includeArchived?: boolean;
|
|
620
|
+
}): Promise<FeatureFlagGroup[]>;
|
|
528
621
|
/**
|
|
529
622
|
* Adds an external subject identifier to a feature flag group.
|
|
530
623
|
*/
|
|
@@ -540,7 +633,9 @@ export declare class FeatureFlagGroupModel {
|
|
|
540
633
|
/**
|
|
541
634
|
* Gets all groups that are associated with a specific external subject identifier.
|
|
542
635
|
*/
|
|
543
|
-
getGroupsForSubject(subject: string
|
|
636
|
+
getGroupsForSubject(subject: string, { includeArchived }?: {
|
|
637
|
+
includeArchived?: boolean;
|
|
638
|
+
}): Promise<FeatureFlagGroup[]>;
|
|
544
639
|
/**
|
|
545
640
|
* Updates all feature flags in a group with the same changes.
|
|
546
641
|
*
|
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
|
@@ -10,10 +10,12 @@ const COLUMNS = [
|
|
|
10
10
|
"roles",
|
|
11
11
|
"groups",
|
|
12
12
|
"users",
|
|
13
|
+
"tags",
|
|
13
14
|
"note",
|
|
14
15
|
"created",
|
|
15
16
|
"modified",
|
|
16
17
|
"expires",
|
|
18
|
+
"archived",
|
|
17
19
|
];
|
|
18
20
|
function mapRow(row) {
|
|
19
21
|
return {
|
|
@@ -28,10 +30,12 @@ function mapRow(row) {
|
|
|
28
30
|
? row.groups
|
|
29
31
|
: undefined,
|
|
30
32
|
users: row.users && row.users.length > 0 ? row.users : undefined,
|
|
33
|
+
tags: row.tags && row.tags.length > 0 ? row.tags : undefined,
|
|
31
34
|
note: row.note ?? undefined,
|
|
32
35
|
created: new Date(row.created),
|
|
33
36
|
modified: new Date(row.modified),
|
|
34
37
|
expires: row.expires ? new Date(row.expires) : undefined,
|
|
38
|
+
archived: row.archived ? new Date(row.archived) : undefined,
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
/**
|
|
@@ -75,6 +79,7 @@ export class FeatureFlagModel {
|
|
|
75
79
|
* @param input.roles - Optional list of roles that have this flag enabled
|
|
76
80
|
* @param input.groups - Optional list of user groups that have this flag enabled
|
|
77
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)
|
|
78
83
|
* @param input.note - Optional description of the flag's purpose
|
|
79
84
|
* @param input.expires - Optional expiration date for the feature flag
|
|
80
85
|
* @returns The created feature flag with generated id, created, and modified timestamps
|
|
@@ -113,6 +118,10 @@ export class FeatureFlagModel {
|
|
|
113
118
|
cols.push("users");
|
|
114
119
|
vals.push(input.users ?? null);
|
|
115
120
|
}
|
|
121
|
+
if ("tags" in input) {
|
|
122
|
+
cols.push("tags");
|
|
123
|
+
vals.push(input.tags ?? null);
|
|
124
|
+
}
|
|
116
125
|
if ("note" in input) {
|
|
117
126
|
cols.push("note");
|
|
118
127
|
vals.push(input.note ?? null);
|
|
@@ -140,8 +149,9 @@ export class FeatureFlagModel {
|
|
|
140
149
|
* }
|
|
141
150
|
* ```
|
|
142
151
|
*/
|
|
143
|
-
async getById(id) {
|
|
144
|
-
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}`;
|
|
145
155
|
const res = await this.db.query(sql, [id]);
|
|
146
156
|
if (res.rows.length === 0)
|
|
147
157
|
return null;
|
|
@@ -161,8 +171,9 @@ export class FeatureFlagModel {
|
|
|
161
171
|
* }
|
|
162
172
|
* ```
|
|
163
173
|
*/
|
|
164
|
-
async getByName(name) {
|
|
165
|
-
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}`;
|
|
166
177
|
const res = await this.db.query(sql, [name]);
|
|
167
178
|
if (res.rows.length === 0)
|
|
168
179
|
return null;
|
|
@@ -180,10 +191,11 @@ export class FeatureFlagModel {
|
|
|
180
191
|
* console.log(`Found ${flags.length} flags`);
|
|
181
192
|
* ```
|
|
182
193
|
*/
|
|
183
|
-
async getManyByName(names) {
|
|
194
|
+
async getManyByName(names, { includeArchived = false } = {}) {
|
|
184
195
|
if (names.length === 0)
|
|
185
196
|
return [];
|
|
186
|
-
const
|
|
197
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
198
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = ANY($1)${filter}`;
|
|
187
199
|
const res = await this.db.query(sql, [names]);
|
|
188
200
|
return res.rows.map(mapRow);
|
|
189
201
|
}
|
|
@@ -199,10 +211,11 @@ export class FeatureFlagModel {
|
|
|
199
211
|
* console.log(`Found ${flags.length} flags`);
|
|
200
212
|
* ```
|
|
201
213
|
*/
|
|
202
|
-
async getMany(ids) {
|
|
214
|
+
async getMany(ids, { includeArchived = false } = {}) {
|
|
203
215
|
if (ids.length === 0)
|
|
204
216
|
return [];
|
|
205
|
-
const
|
|
217
|
+
const filter = includeArchived ? "" : " AND archived IS NULL";
|
|
218
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = ANY($1)${filter}`;
|
|
206
219
|
const res = await this.db.query(sql, [ids]);
|
|
207
220
|
return res.rows.map(mapRow);
|
|
208
221
|
}
|
|
@@ -220,11 +233,30 @@ export class FeatureFlagModel {
|
|
|
220
233
|
* });
|
|
221
234
|
* ```
|
|
222
235
|
*/
|
|
223
|
-
async list() {
|
|
224
|
-
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`;
|
|
225
239
|
const res = await this.db.query(sql);
|
|
226
240
|
return res.rows.map(mapRow);
|
|
227
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
|
+
}
|
|
228
260
|
/**
|
|
229
261
|
* Updates an existing feature flag.
|
|
230
262
|
*
|
|
@@ -276,6 +308,10 @@ export class FeatureFlagModel {
|
|
|
276
308
|
sets.push(`users = $${sets.length + 1}`);
|
|
277
309
|
vals.push(changes.users ?? null);
|
|
278
310
|
}
|
|
311
|
+
if ("tags" in changes) {
|
|
312
|
+
sets.push(`tags = $${sets.length + 1}`);
|
|
313
|
+
vals.push(changes.tags ?? null);
|
|
314
|
+
}
|
|
279
315
|
if ("note" in changes) {
|
|
280
316
|
sets.push(`note = $${sets.length + 1}`);
|
|
281
317
|
vals.push(changes.note ?? null);
|
|
@@ -296,11 +332,16 @@ export class FeatureFlagModel {
|
|
|
296
332
|
return mapRow(res.rows[0]);
|
|
297
333
|
}
|
|
298
334
|
/**
|
|
299
|
-
*
|
|
335
|
+
* Permanently deletes a feature flag from the database, including its
|
|
336
|
+
* subject and group-member relationships (via ON DELETE CASCADE).
|
|
300
337
|
*
|
|
301
338
|
* @param id - The unique identifier of the feature flag to delete
|
|
302
339
|
* @returns true if the flag was deleted, false if not found
|
|
303
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
|
+
*
|
|
304
345
|
* @example
|
|
305
346
|
* ```typescript
|
|
306
347
|
* const deleted = await model.delete(flagId);
|
|
@@ -313,6 +354,36 @@ export class FeatureFlagModel {
|
|
|
313
354
|
const res = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
|
|
314
355
|
return res.rowCount > 0;
|
|
315
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
|
+
}
|
|
316
387
|
/**
|
|
317
388
|
* Adds an external subject identifier to a feature flag.
|
|
318
389
|
*/
|
|
@@ -348,12 +419,13 @@ export class FeatureFlagModel {
|
|
|
348
419
|
/**
|
|
349
420
|
* Gets all feature flags directly associated with an external subject identifier.
|
|
350
421
|
*/
|
|
351
|
-
async getFeatureFlagsForSubject(subject) {
|
|
422
|
+
async getFeatureFlagsForSubject(subject, { includeArchived = false } = {}) {
|
|
423
|
+
const filter = includeArchived ? "" : " AND f.archived IS NULL";
|
|
352
424
|
const sql = `
|
|
353
425
|
SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
|
|
354
426
|
FROM ${TABLE} f
|
|
355
427
|
INNER JOIN ${SUBJECT_TABLE} s ON f.id = s.feature_flag_id
|
|
356
|
-
WHERE s.subject = $1
|
|
428
|
+
WHERE s.subject = $1${filter}
|
|
357
429
|
ORDER BY f.created DESC
|
|
358
430
|
`;
|
|
359
431
|
const res = await this.db.query(sql, [subject]);
|
|
@@ -363,15 +435,20 @@ export class FeatureFlagModel {
|
|
|
363
435
|
if (subjects.length === 0) {
|
|
364
436
|
return new Set();
|
|
365
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.
|
|
366
441
|
const sql = `
|
|
367
442
|
SELECT s.feature_flag_id AS feature_flag_id
|
|
368
443
|
FROM ${SUBJECT_TABLE} s
|
|
369
|
-
|
|
444
|
+
INNER JOIN ${TABLE} f ON f.id = s.feature_flag_id
|
|
445
|
+
WHERE s.subject = ANY($1) AND f.archived IS NULL
|
|
370
446
|
UNION
|
|
371
447
|
SELECT gm.feature_flag_id AS feature_flag_id
|
|
372
448
|
FROM ${GROUP_SUBJECT_TABLE} gs
|
|
373
449
|
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON gs.feature_flag_group_id = gm.group_id
|
|
374
|
-
|
|
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
|
|
375
452
|
`;
|
|
376
453
|
const res = await this.db.query(sql, [subjects]);
|
|
377
454
|
return new Set(res.rows.map((row) => Number(row.feature_flag_id)));
|
|
@@ -652,7 +729,14 @@ export class FeatureFlagModel {
|
|
|
652
729
|
}
|
|
653
730
|
const GROUP_TABLE = "flapjack.feature_flag_group";
|
|
654
731
|
const GROUP_MEMBER_TABLE = "flapjack.feature_flag_group_member";
|
|
655
|
-
const GROUP_COLUMNS = [
|
|
732
|
+
const GROUP_COLUMNS = [
|
|
733
|
+
"id",
|
|
734
|
+
"name",
|
|
735
|
+
"note",
|
|
736
|
+
"created",
|
|
737
|
+
"modified",
|
|
738
|
+
"archived",
|
|
739
|
+
];
|
|
656
740
|
function mapGroupRow(row) {
|
|
657
741
|
return {
|
|
658
742
|
id: row.id,
|
|
@@ -660,6 +744,7 @@ function mapGroupRow(row) {
|
|
|
660
744
|
note: row.note ?? undefined,
|
|
661
745
|
created: new Date(row.created),
|
|
662
746
|
modified: new Date(row.modified),
|
|
747
|
+
archived: row.archived ? new Date(row.archived) : undefined,
|
|
663
748
|
};
|
|
664
749
|
}
|
|
665
750
|
/**
|
|
@@ -736,8 +821,9 @@ export class FeatureFlagGroupModel {
|
|
|
736
821
|
* }
|
|
737
822
|
* ```
|
|
738
823
|
*/
|
|
739
|
-
async getById(id) {
|
|
740
|
-
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}`;
|
|
741
827
|
const res = await this.db.query(sql, [id]);
|
|
742
828
|
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
743
829
|
}
|
|
@@ -752,8 +838,9 @@ export class FeatureFlagGroupModel {
|
|
|
752
838
|
* const group = await model.getByName("billing_redesign");
|
|
753
839
|
* ```
|
|
754
840
|
*/
|
|
755
|
-
async getByName(name) {
|
|
756
|
-
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}`;
|
|
757
844
|
const res = await this.db.query(sql, [name]);
|
|
758
845
|
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
759
846
|
}
|
|
@@ -770,8 +857,9 @@ export class FeatureFlagGroupModel {
|
|
|
770
857
|
* }
|
|
771
858
|
* ```
|
|
772
859
|
*/
|
|
773
|
-
async list() {
|
|
774
|
-
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`;
|
|
775
863
|
const res = await this.db.query(sql);
|
|
776
864
|
return res.rows.map(mapGroupRow);
|
|
777
865
|
}
|
|
@@ -813,11 +901,16 @@ export class FeatureFlagGroupModel {
|
|
|
813
901
|
return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
|
|
814
902
|
}
|
|
815
903
|
/**
|
|
816
|
-
*
|
|
904
|
+
* Permanently deletes a feature flag group and all its member and subject
|
|
905
|
+
* relationships (via ON DELETE CASCADE).
|
|
817
906
|
*
|
|
818
907
|
* @param id - The group ID to delete
|
|
819
908
|
* @returns true if deleted, false if not found
|
|
820
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
|
+
*
|
|
821
914
|
* @example
|
|
822
915
|
* ```typescript
|
|
823
916
|
* const deleted = await model.delete(1);
|
|
@@ -827,6 +920,31 @@ export class FeatureFlagGroupModel {
|
|
|
827
920
|
const res = await this.db.query(`DELETE FROM ${GROUP_TABLE} WHERE id = $1`, [id]);
|
|
828
921
|
return res.rowCount > 0;
|
|
829
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
|
+
}
|
|
830
948
|
/**
|
|
831
949
|
* Adds a feature flag to a group.
|
|
832
950
|
*
|
|
@@ -888,12 +1006,13 @@ export class FeatureFlagGroupModel {
|
|
|
888
1006
|
* }
|
|
889
1007
|
* ```
|
|
890
1008
|
*/
|
|
891
|
-
async getFeatureFlags(groupId) {
|
|
1009
|
+
async getFeatureFlags(groupId, { includeArchived = false } = {}) {
|
|
1010
|
+
const filter = includeArchived ? "" : " AND f.archived IS NULL";
|
|
892
1011
|
const sql = `
|
|
893
1012
|
SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
|
|
894
1013
|
FROM ${TABLE} f
|
|
895
1014
|
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON f.id = gm.feature_flag_id
|
|
896
|
-
WHERE gm.group_id = $1
|
|
1015
|
+
WHERE gm.group_id = $1${filter}
|
|
897
1016
|
ORDER BY f.created DESC
|
|
898
1017
|
`;
|
|
899
1018
|
const res = await this.db.query(sql, [groupId]);
|
|
@@ -910,12 +1029,13 @@ export class FeatureFlagGroupModel {
|
|
|
910
1029
|
* const groups = await model.getGroupsForFeatureFlag(5);
|
|
911
1030
|
* ```
|
|
912
1031
|
*/
|
|
913
|
-
async getGroupsForFeatureFlag(featureFlagId) {
|
|
1032
|
+
async getGroupsForFeatureFlag(featureFlagId, { includeArchived = false } = {}) {
|
|
1033
|
+
const filter = includeArchived ? "" : " AND g.archived IS NULL";
|
|
914
1034
|
const sql = `
|
|
915
1035
|
SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
|
|
916
1036
|
FROM ${GROUP_TABLE} g
|
|
917
1037
|
INNER JOIN ${GROUP_MEMBER_TABLE} gm ON g.id = gm.group_id
|
|
918
|
-
WHERE gm.feature_flag_id = $1
|
|
1038
|
+
WHERE gm.feature_flag_id = $1${filter}
|
|
919
1039
|
ORDER BY g.created DESC
|
|
920
1040
|
`;
|
|
921
1041
|
const res = await this.db.query(sql, [featureFlagId]);
|
|
@@ -960,12 +1080,13 @@ export class FeatureFlagGroupModel {
|
|
|
960
1080
|
/**
|
|
961
1081
|
* Gets all groups that are associated with a specific external subject identifier.
|
|
962
1082
|
*/
|
|
963
|
-
async getGroupsForSubject(subject) {
|
|
1083
|
+
async getGroupsForSubject(subject, { includeArchived = false } = {}) {
|
|
1084
|
+
const filter = includeArchived ? "" : " AND g.archived IS NULL";
|
|
964
1085
|
const sql = `
|
|
965
1086
|
SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
|
|
966
1087
|
FROM ${GROUP_TABLE} g
|
|
967
1088
|
INNER JOIN ${GROUP_SUBJECT_TABLE} gs ON g.id = gs.feature_flag_group_id
|
|
968
|
-
WHERE gs.subject = $1
|
|
1089
|
+
WHERE gs.subject = $1${filter}
|
|
969
1090
|
ORDER BY g.created DESC
|
|
970
1091
|
`;
|
|
971
1092
|
const res = await this.db.query(sql, [subject]);
|
|
@@ -1019,10 +1140,10 @@ export class FeatureFlagGroupModel {
|
|
|
1019
1140
|
}
|
|
1020
1141
|
vals.push(groupId);
|
|
1021
1142
|
const sql = `
|
|
1022
|
-
UPDATE ${TABLE}
|
|
1143
|
+
UPDATE ${TABLE}
|
|
1023
1144
|
SET ${updates.join(", ")}
|
|
1024
|
-
WHERE id IN (
|
|
1025
|
-
SELECT feature_flag_id
|
|
1145
|
+
WHERE archived IS NULL AND id IN (
|
|
1146
|
+
SELECT feature_flag_id
|
|
1026
1147
|
FROM ${GROUP_MEMBER_TABLE}
|
|
1027
1148
|
WHERE group_id = $${paramIdx}
|
|
1028
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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
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(
|
|
13
|
+
{ schema: "flapjack", name: "feature_flag" },
|
|
14
|
+
{
|
|
15
|
+
tags: {
|
|
16
|
+
type: "text[]",
|
|
17
|
+
default: pgm.func("'{}'::text[]"),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// GIN index for efficient lookups by tag (e.g. WHERE $1 = ANY(tags))
|
|
23
|
+
pgm.createIndex({ schema: "flapjack", name: "feature_flag" }, "tags", {
|
|
24
|
+
method: "gin",
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
|
30
|
+
* @param run {() => void | undefined}
|
|
31
|
+
* @returns {Promise<void> | void}
|
|
32
|
+
*/
|
|
33
|
+
export const down = (pgm) => {
|
|
34
|
+
pgm.dropIndex({ schema: "flapjack", name: "feature_flag" }, "tags", {
|
|
35
|
+
method: "gin",
|
|
36
|
+
});
|
|
37
|
+
pgm.dropColumn({ schema: "flapjack", name: "feature_flag" }, "tags");
|
|
38
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
// Add a nullable archived timestamp to the core data models. When set, the
|
|
13
|
+
// record is considered archived (hidden) but kept for historical/audit
|
|
14
|
+
// purposes. Archiving is irreversible and the name remains reserved forever
|
|
15
|
+
// (the existing UNIQUE(name) constraint is intentionally left unchanged).
|
|
16
|
+
pgm.addColumn(
|
|
17
|
+
{ schema: "flapjack", name: "feature_flag" },
|
|
18
|
+
{ archived: { type: "timestamptz" } },
|
|
19
|
+
);
|
|
20
|
+
pgm.addColumn(
|
|
21
|
+
{ schema: "flapjack", name: "feature_flag_group" },
|
|
22
|
+
{ archived: { type: "timestamptz" } },
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
|
28
|
+
* @param run {() => void | undefined}
|
|
29
|
+
* @returns {Promise<void> | void}
|
|
30
|
+
*/
|
|
31
|
+
export const down = (pgm) => {
|
|
32
|
+
pgm.dropColumn({ schema: "flapjack", name: "feature_flag" }, "archived");
|
|
33
|
+
pgm.dropColumn(
|
|
34
|
+
{ schema: "flapjack", name: "feature_flag_group" },
|
|
35
|
+
"archived",
|
|
36
|
+
);
|
|
37
|
+
};
|