@brandtg/flapjack 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -480,6 +480,12 @@ flapjack hash-user user_123
480
480
 
481
481
  ### Setup
482
482
 
483
+ Install dependencies
484
+
485
+ ```bash
486
+ npm install
487
+ ```
488
+
483
489
  Create an environment file:
484
490
 
485
491
  ```bash
@@ -501,7 +507,7 @@ npm run dev:migrate
501
507
  ### Running Tests
502
508
 
503
509
  ```bash
504
- npm test
510
+ npm run test
505
511
  ```
506
512
 
507
513
  ### Database Management
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { FeatureFlag } from "./types.js";
2
- export { FeatureFlagModel } from "./model.js";
1
+ export type { FeatureFlag, FeatureFlagGroup } from "./types.js";
2
+ export { FeatureFlagModel, FeatureFlagGroupModel } from "./model.js";
3
3
  export { FeatureFlagCache, InMemoryCache, type Cache } from "./cache.js";
4
4
  export { runMigrations, type MigrationOptions } from "./migrate.js";
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACrE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { FeatureFlagModel } from "./model.js";
1
+ export { FeatureFlagModel, FeatureFlagGroupModel } from "./model.js";
2
2
  export { FeatureFlagCache, InMemoryCache } from "./cache.js";
3
3
  export { runMigrations } from "./migrate.js";
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FeatureFlag, FeatureFlagEventHandlers } from "./types.js";
1
+ import type { FeatureFlag, FeatureFlagEventHandlers, FeatureFlagGroup } from "./types.js";
2
2
  import type { QueryResult } from "pg";
3
3
  interface Queryable {
4
4
  query: (text: string, params?: any[]) => Promise<QueryResult>;
@@ -89,6 +89,19 @@ export declare class FeatureFlagModel {
89
89
  * ```
90
90
  */
91
91
  getByName(name: string): Promise<FeatureFlag | null>;
92
+ /**
93
+ * Retrieves multiple feature flags by their IDs.
94
+ *
95
+ * @param ids - Array of feature flag IDs to retrieve
96
+ * @returns Array of feature flags found (may be shorter than input if some IDs don't exist)
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const flags = await model.getMany([1, 2, 3]);
101
+ * console.log(`Found ${flags.length} flags`);
102
+ * ```
103
+ */
104
+ getMany(ids: number[]): Promise<FeatureFlag[]>;
92
105
  /**
93
106
  * Retrieves all feature flags, ordered by ID.
94
107
  *
@@ -211,5 +224,197 @@ export declare class FeatureFlagModel {
211
224
  groups?: string[];
212
225
  }): Promise<boolean>;
213
226
  }
227
+ type CreateGroupInput = Omit<FeatureFlagGroup, "id" | "created" | "modified">;
228
+ type UpdateGroupChanges = Partial<Omit<FeatureFlagGroup, "id" | "created" | "modified">>;
229
+ type UpdateAllChanges = Partial<Pick<FeatureFlag, "everyone" | "percent" | "roles" | "groups" | "users">>;
230
+ /**
231
+ * Model for managing feature flag groups stored in PostgreSQL.
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * import { Pool } from "pg";
236
+ * import { FeatureFlagGroupModel } from "@brandtg/flapjack";
237
+ *
238
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
239
+ * const groups = new FeatureFlagGroupModel(pool);
240
+ * ```
241
+ */
242
+ export declare class FeatureFlagGroupModel {
243
+ private db;
244
+ /**
245
+ * Creates a new FeatureFlagGroupModel instance.
246
+ *
247
+ * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
248
+ *
249
+ * @example
250
+ * ```typescript
251
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
252
+ * const model = new FeatureFlagGroupModel(pool);
253
+ * ```
254
+ */
255
+ constructor(db: Queryable);
256
+ /**
257
+ * Creates a new feature flag group in the database.
258
+ *
259
+ * @param input - Feature flag group configuration
260
+ * @param input.name - Unique name for the group (required)
261
+ * @param input.note - Optional description of the group's purpose
262
+ * @returns The created group with generated id, created, and modified timestamps
263
+ *
264
+ * @throws Will throw an error if a group with the same name already exists
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * const group = await model.create({
269
+ * name: "billing_redesign",
270
+ * note: "All flags related to the new billing flow",
271
+ * });
272
+ * ```
273
+ */
274
+ create(input: CreateGroupInput): Promise<FeatureFlagGroup>;
275
+ /**
276
+ * Retrieves a feature flag group by its ID.
277
+ *
278
+ * @param id - The group ID
279
+ * @returns The group if found, null otherwise
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const group = await model.getById(1);
284
+ * if (group) {
285
+ * console.log(group.name);
286
+ * }
287
+ * ```
288
+ */
289
+ getById(id: number): Promise<FeatureFlagGroup | null>;
290
+ /**
291
+ * Retrieves a feature flag group by its name.
292
+ *
293
+ * @param name - The group name
294
+ * @returns The group if found, null otherwise
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * const group = await model.getByName("billing_redesign");
299
+ * ```
300
+ */
301
+ getByName(name: string): Promise<FeatureFlagGroup | null>;
302
+ /**
303
+ * Lists all feature flag groups.
304
+ *
305
+ * @returns Array of all groups
306
+ *
307
+ * @example
308
+ * ```typescript
309
+ * const groups = await model.list();
310
+ * for (const group of groups) {
311
+ * console.log(group.name);
312
+ * }
313
+ * ```
314
+ */
315
+ list(): Promise<FeatureFlagGroup[]>;
316
+ /**
317
+ * Updates a feature flag group.
318
+ *
319
+ * @param id - The group ID to update
320
+ * @param changes - Fields to update
321
+ * @returns The updated group if found, null otherwise
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * const updated = await model.update(1, {
326
+ * note: "Updated description",
327
+ * });
328
+ * ```
329
+ */
330
+ update(id: number, changes: UpdateGroupChanges): Promise<FeatureFlagGroup | null>;
331
+ /**
332
+ * Deletes a feature flag group and all its member relationships.
333
+ *
334
+ * @param id - The group ID to delete
335
+ * @returns true if deleted, false if not found
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * const deleted = await model.delete(1);
340
+ * ```
341
+ */
342
+ delete(id: number): Promise<boolean>;
343
+ /**
344
+ * Adds a feature flag to a group.
345
+ *
346
+ * @param groupId - The group ID
347
+ * @param featureFlagId - The feature flag ID to add
348
+ * @returns true if added successfully, false if already exists
349
+ *
350
+ * @throws Will throw an error if group or feature flag doesn't exist
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * await model.addFeatureFlag(1, 5);
355
+ * ```
356
+ */
357
+ addFeatureFlag(groupId: number, featureFlagId: number): Promise<boolean>;
358
+ /**
359
+ * Removes a feature flag from a group.
360
+ *
361
+ * @param groupId - The group ID
362
+ * @param featureFlagId - The feature flag ID to remove
363
+ * @returns true if removed, false if relationship didn't exist
364
+ *
365
+ * @example
366
+ * ```typescript
367
+ * await model.removeFeatureFlag(1, 5);
368
+ * ```
369
+ */
370
+ removeFeatureFlag(groupId: number, featureFlagId: number): Promise<boolean>;
371
+ /**
372
+ * Gets all feature flags in a group.
373
+ *
374
+ * @param groupId - The group ID
375
+ * @returns Array of feature flags in the group
376
+ *
377
+ * @example
378
+ * ```typescript
379
+ * const flags = await model.getFeatureFlags(1);
380
+ * for (const flag of flags) {
381
+ * console.log(flag.name);
382
+ * }
383
+ * ```
384
+ */
385
+ getFeatureFlags(groupId: number): Promise<FeatureFlag[]>;
386
+ /**
387
+ * Gets all groups that contain a specific feature flag.
388
+ *
389
+ * @param featureFlagId - The feature flag ID
390
+ * @returns Array of groups containing the feature flag
391
+ *
392
+ * @example
393
+ * ```typescript
394
+ * const groups = await model.getGroupsForFeatureFlag(5);
395
+ * ```
396
+ */
397
+ getGroupsForFeatureFlag(featureFlagId: number): Promise<FeatureFlagGroup[]>;
398
+ /**
399
+ * Updates all feature flags in a group with the same changes.
400
+ *
401
+ * @param groupId - The group ID
402
+ * @param changes - Fields to update (only everyone, percent, roles, groups, users allowed)
403
+ * @returns The number of feature flags updated
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * // Enable all flags in a group for everyone
408
+ * const count = await model.updateAll(1, { everyone: true });
409
+ *
410
+ * // Set percentage rollout for all flags in a group
411
+ * const count = await model.updateAll(1, { percent: 25 });
412
+ *
413
+ * // Add roles to all flags in a group
414
+ * const count = await model.updateAll(1, { roles: ["admin", "beta"] });
415
+ * ```
416
+ */
417
+ updateAll(groupId: number, changes: UpdateAllChanges): Promise<number>;
418
+ }
214
419
  export {};
215
420
  //# sourceMappingURL=model.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AACxE,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;AAkBD,KAAK,WAAW,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,SAAS,GAAG,UAAU,CAAC,CAAC;AACpE,KAAK,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,SAAS,GAAG,UAAU,CAAC,CAAC,CAAC;AA0B/E;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,EAAE,CAAY;IACtB,OAAO,CAAC,aAAa,CAAC,CAA2B;IAEjD;;;;;;;;;;;OAWG;gBACS,EAAE,EAAE,SAAS,EAAE,aAAa,CAAC,EAAE,wBAAwB;IAKnE;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACG,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAuCtD;;;;;;;;;;;;;OAaG;IACG,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAOtD;;;;;;;;;;;;;OAaG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAO1D;;;;;;;;;;;;;OAaG;IACG,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAMpC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACG,MAAM,CACV,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAiD9B;;;;;;;;;;;;;OAaG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK1C;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAQzB;;;;;;;;;;;;;;;;;;OAkBG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAmCG;IACG,eAAe,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,GACP,EAAE;QACD,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;KACnB,GAAG,OAAO,CAAC,OAAO,CAAC;CAyDrB"}
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
package/dist/model.js CHANGED
@@ -166,6 +166,25 @@ export class FeatureFlagModel {
166
166
  return null;
167
167
  return mapRow(res.rows[0]);
168
168
  }
169
+ /**
170
+ * Retrieves multiple feature flags by their IDs.
171
+ *
172
+ * @param ids - Array of feature flag IDs to retrieve
173
+ * @returns Array of feature flags found (may be shorter than input if some IDs don't exist)
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * const flags = await model.getMany([1, 2, 3]);
178
+ * console.log(`Found ${flags.length} flags`);
179
+ * ```
180
+ */
181
+ async getMany(ids) {
182
+ if (ids.length === 0)
183
+ return [];
184
+ const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = ANY($1)`;
185
+ const res = await this.db.query(sql, [ids]);
186
+ return res.rows.map(mapRow);
187
+ }
169
188
  /**
170
189
  * Retrieves all feature flags, ordered by ID.
171
190
  *
@@ -387,3 +406,334 @@ export class FeatureFlagModel {
387
406
  return false;
388
407
  }
389
408
  }
409
+ const GROUP_TABLE = "flapjack_feature_flag_group";
410
+ const GROUP_MEMBER_TABLE = "flapjack_feature_flag_group_member";
411
+ const GROUP_COLUMNS = ["id", "name", "note", "created", "modified"];
412
+ function mapGroupRow(row) {
413
+ return {
414
+ id: row.id,
415
+ name: row.name,
416
+ note: row.note ?? undefined,
417
+ created: new Date(row.created),
418
+ modified: new Date(row.modified),
419
+ };
420
+ }
421
+ /**
422
+ * Model for managing feature flag groups stored in PostgreSQL.
423
+ *
424
+ * @example
425
+ * ```typescript
426
+ * import { Pool } from "pg";
427
+ * import { FeatureFlagGroupModel } from "@brandtg/flapjack";
428
+ *
429
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
430
+ * const groups = new FeatureFlagGroupModel(pool);
431
+ * ```
432
+ */
433
+ export class FeatureFlagGroupModel {
434
+ db;
435
+ /**
436
+ * Creates a new FeatureFlagGroupModel instance.
437
+ *
438
+ * @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
439
+ *
440
+ * @example
441
+ * ```typescript
442
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
443
+ * const model = new FeatureFlagGroupModel(pool);
444
+ * ```
445
+ */
446
+ constructor(db) {
447
+ this.db = db;
448
+ }
449
+ /**
450
+ * Creates a new feature flag group in the database.
451
+ *
452
+ * @param input - Feature flag group configuration
453
+ * @param input.name - Unique name for the group (required)
454
+ * @param input.note - Optional description of the group's purpose
455
+ * @returns The created group with generated id, created, and modified timestamps
456
+ *
457
+ * @throws Will throw an error if a group with the same name already exists
458
+ *
459
+ * @example
460
+ * ```typescript
461
+ * const group = await model.create({
462
+ * name: "billing_redesign",
463
+ * note: "All flags related to the new billing flow",
464
+ * });
465
+ * ```
466
+ */
467
+ async create(input) {
468
+ const cols = ["name"];
469
+ const vals = [input.name];
470
+ if ("note" in input) {
471
+ cols.push("note");
472
+ vals.push(input.note ?? null);
473
+ }
474
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
475
+ const sql = `INSERT INTO ${GROUP_TABLE} (${cols.join(", ")})
476
+ VALUES (${placeholders})
477
+ RETURNING ${GROUP_COLUMNS.join(", ")}`;
478
+ const res = await this.db.query(sql, vals);
479
+ return mapGroupRow(res.rows[0]);
480
+ }
481
+ /**
482
+ * Retrieves a feature flag group by its ID.
483
+ *
484
+ * @param id - The group ID
485
+ * @returns The group if found, null otherwise
486
+ *
487
+ * @example
488
+ * ```typescript
489
+ * const group = await model.getById(1);
490
+ * if (group) {
491
+ * console.log(group.name);
492
+ * }
493
+ * ```
494
+ */
495
+ async getById(id) {
496
+ const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE id = $1`;
497
+ const res = await this.db.query(sql, [id]);
498
+ return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
499
+ }
500
+ /**
501
+ * Retrieves a feature flag group by its name.
502
+ *
503
+ * @param name - The group name
504
+ * @returns The group if found, null otherwise
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const group = await model.getByName("billing_redesign");
509
+ * ```
510
+ */
511
+ async getByName(name) {
512
+ const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} WHERE name = $1`;
513
+ const res = await this.db.query(sql, [name]);
514
+ return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
515
+ }
516
+ /**
517
+ * Lists all feature flag groups.
518
+ *
519
+ * @returns Array of all groups
520
+ *
521
+ * @example
522
+ * ```typescript
523
+ * const groups = await model.list();
524
+ * for (const group of groups) {
525
+ * console.log(group.name);
526
+ * }
527
+ * ```
528
+ */
529
+ async list() {
530
+ const sql = `SELECT ${GROUP_COLUMNS.join(", ")} FROM ${GROUP_TABLE} ORDER BY created DESC`;
531
+ const res = await this.db.query(sql);
532
+ return res.rows.map(mapGroupRow);
533
+ }
534
+ /**
535
+ * Updates a feature flag group.
536
+ *
537
+ * @param id - The group ID to update
538
+ * @param changes - Fields to update
539
+ * @returns The updated group if found, null otherwise
540
+ *
541
+ * @example
542
+ * ```typescript
543
+ * const updated = await model.update(1, {
544
+ * note: "Updated description",
545
+ * });
546
+ * ```
547
+ */
548
+ async update(id, changes) {
549
+ const updates = [];
550
+ const vals = [];
551
+ let paramIdx = 1;
552
+ if ("name" in changes) {
553
+ updates.push(`name = $${paramIdx++}`);
554
+ vals.push(changes.name);
555
+ }
556
+ if ("note" in changes) {
557
+ updates.push(`note = $${paramIdx++}`);
558
+ vals.push(changes.note ?? null);
559
+ }
560
+ if (updates.length === 0) {
561
+ return this.getById(id);
562
+ }
563
+ vals.push(id);
564
+ const sql = `UPDATE ${GROUP_TABLE}
565
+ SET ${updates.join(", ")}
566
+ WHERE id = $${paramIdx}
567
+ RETURNING ${GROUP_COLUMNS.join(", ")}`;
568
+ const res = await this.db.query(sql, vals);
569
+ return res.rows.length > 0 ? mapGroupRow(res.rows[0]) : null;
570
+ }
571
+ /**
572
+ * Deletes a feature flag group and all its member relationships.
573
+ *
574
+ * @param id - The group ID to delete
575
+ * @returns true if deleted, false if not found
576
+ *
577
+ * @example
578
+ * ```typescript
579
+ * const deleted = await model.delete(1);
580
+ * ```
581
+ */
582
+ async delete(id) {
583
+ const res = await this.db.query(`DELETE FROM ${GROUP_TABLE} WHERE id = $1`, [id]);
584
+ return res.rowCount > 0;
585
+ }
586
+ /**
587
+ * Adds a feature flag to a group.
588
+ *
589
+ * @param groupId - The group ID
590
+ * @param featureFlagId - The feature flag ID to add
591
+ * @returns true if added successfully, false if already exists
592
+ *
593
+ * @throws Will throw an error if group or feature flag doesn't exist
594
+ *
595
+ * @example
596
+ * ```typescript
597
+ * await model.addFeatureFlag(1, 5);
598
+ * ```
599
+ */
600
+ async addFeatureFlag(groupId, featureFlagId) {
601
+ try {
602
+ const sql = `INSERT INTO ${GROUP_MEMBER_TABLE} (group_id, feature_flag_id)
603
+ VALUES ($1, $2)`;
604
+ await this.db.query(sql, [groupId, featureFlagId]);
605
+ return true;
606
+ }
607
+ catch (err) {
608
+ // If unique constraint violation, return false
609
+ if (err.code === "23505") {
610
+ return false;
611
+ }
612
+ throw err;
613
+ }
614
+ }
615
+ /**
616
+ * Removes a feature flag from a group.
617
+ *
618
+ * @param groupId - The group ID
619
+ * @param featureFlagId - The feature flag ID to remove
620
+ * @returns true if removed, false if relationship didn't exist
621
+ *
622
+ * @example
623
+ * ```typescript
624
+ * await model.removeFeatureFlag(1, 5);
625
+ * ```
626
+ */
627
+ async removeFeatureFlag(groupId, featureFlagId) {
628
+ const sql = `DELETE FROM ${GROUP_MEMBER_TABLE}
629
+ WHERE group_id = $1 AND feature_flag_id = $2`;
630
+ const res = await this.db.query(sql, [groupId, featureFlagId]);
631
+ return res.rowCount > 0;
632
+ }
633
+ /**
634
+ * Gets all feature flags in a group.
635
+ *
636
+ * @param groupId - The group ID
637
+ * @returns Array of feature flags in the group
638
+ *
639
+ * @example
640
+ * ```typescript
641
+ * const flags = await model.getFeatureFlags(1);
642
+ * for (const flag of flags) {
643
+ * console.log(flag.name);
644
+ * }
645
+ * ```
646
+ */
647
+ async getFeatureFlags(groupId) {
648
+ const sql = `
649
+ SELECT ${COLUMNS.map((c) => `f.${c}`).join(", ")}
650
+ FROM ${TABLE} f
651
+ INNER JOIN ${GROUP_MEMBER_TABLE} gm ON f.id = gm.feature_flag_id
652
+ WHERE gm.group_id = $1
653
+ ORDER BY f.created DESC
654
+ `;
655
+ const res = await this.db.query(sql, [groupId]);
656
+ return res.rows.map(mapRow);
657
+ }
658
+ /**
659
+ * Gets all groups that contain a specific feature flag.
660
+ *
661
+ * @param featureFlagId - The feature flag ID
662
+ * @returns Array of groups containing the feature flag
663
+ *
664
+ * @example
665
+ * ```typescript
666
+ * const groups = await model.getGroupsForFeatureFlag(5);
667
+ * ```
668
+ */
669
+ async getGroupsForFeatureFlag(featureFlagId) {
670
+ const sql = `
671
+ SELECT ${GROUP_COLUMNS.map((c) => `g.${c}`).join(", ")}
672
+ FROM ${GROUP_TABLE} g
673
+ INNER JOIN ${GROUP_MEMBER_TABLE} gm ON g.id = gm.group_id
674
+ WHERE gm.feature_flag_id = $1
675
+ ORDER BY g.created DESC
676
+ `;
677
+ const res = await this.db.query(sql, [featureFlagId]);
678
+ return res.rows.map(mapGroupRow);
679
+ }
680
+ /**
681
+ * Updates all feature flags in a group with the same changes.
682
+ *
683
+ * @param groupId - The group ID
684
+ * @param changes - Fields to update (only everyone, percent, roles, groups, users allowed)
685
+ * @returns The number of feature flags updated
686
+ *
687
+ * @example
688
+ * ```typescript
689
+ * // Enable all flags in a group for everyone
690
+ * const count = await model.updateAll(1, { everyone: true });
691
+ *
692
+ * // Set percentage rollout for all flags in a group
693
+ * const count = await model.updateAll(1, { percent: 25 });
694
+ *
695
+ * // Add roles to all flags in a group
696
+ * const count = await model.updateAll(1, { roles: ["admin", "beta"] });
697
+ * ```
698
+ */
699
+ async updateAll(groupId, changes) {
700
+ const updates = [];
701
+ const vals = [];
702
+ let paramIdx = 1;
703
+ if ("everyone" in changes) {
704
+ updates.push(`everyone = $${paramIdx++}`);
705
+ vals.push(changes.everyone ?? null);
706
+ }
707
+ if ("percent" in changes) {
708
+ updates.push(`percent = $${paramIdx++}`);
709
+ vals.push(changes.percent ?? null);
710
+ }
711
+ if ("roles" in changes) {
712
+ updates.push(`roles = $${paramIdx++}`);
713
+ vals.push(changes.roles ?? null);
714
+ }
715
+ if ("groups" in changes) {
716
+ updates.push(`groups = $${paramIdx++}`);
717
+ vals.push(changes.groups ?? null);
718
+ }
719
+ if ("users" in changes) {
720
+ updates.push(`users = $${paramIdx++}`);
721
+ vals.push(changes.users ?? null);
722
+ }
723
+ if (updates.length === 0) {
724
+ return 0;
725
+ }
726
+ vals.push(groupId);
727
+ const sql = `
728
+ UPDATE ${TABLE}
729
+ SET ${updates.join(", ")}
730
+ WHERE id IN (
731
+ SELECT feature_flag_id
732
+ FROM ${GROUP_MEMBER_TABLE}
733
+ WHERE group_id = $${paramIdx}
734
+ )
735
+ `;
736
+ const res = await this.db.query(sql, vals);
737
+ return res.rowCount;
738
+ }
739
+ }
package/dist/types.d.ts CHANGED
@@ -42,4 +42,19 @@ export type ExpiredFeatureFlagEventHandler = ({ flag, }: {
42
42
  export type FeatureFlagEventHandlers = {
43
43
  onExpired?: ExpiredFeatureFlagEventHandler;
44
44
  };
45
+ /**
46
+ * Represents a feature flag group used to manage multiple feature flags together.
47
+ */
48
+ export type FeatureFlagGroup = {
49
+ /** Unique identifier for the feature flag group */
50
+ id: number;
51
+ /** Human readable name of the feature flag group */
52
+ name: string;
53
+ /** Description of what this group is for */
54
+ note?: string;
55
+ /** Date when the group was created */
56
+ created: Date;
57
+ /** Date when the group was last modified */
58
+ modified: Date;
59
+ };
45
60
  //# 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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAC;IACX,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1B,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,OAAO,EAAE,IAAI,CAAC;IACd,mDAAmD;IACnD,QAAQ,EAAE,IAAI,CAAC;IACf,oDAAoD;IACpD,OAAO,CAAC,EAAE,IAAI,CAAC;CAChB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,8BAA8B,GAAG,CAAC,EAC5C,IAAI,GACL,EAAE;IACD,IAAI,EAAE,WAAW,CAAC;CACnB,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC;AAEnC;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,CAAC,EAAE,8BAA8B,CAAC;CAC5C,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,mDAAmD;IACnD,EAAE,EAAE,MAAM,CAAC;IACX,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,OAAO,EAAE,IAAI,CAAC;IACd,4CAA4C;IAC5C,QAAQ,EAAE,IAAI,CAAC;CAChB,CAAC"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @type {import('node-pg-migrate').ColumnDefinitions | undefined}
3
+ */
4
+ export const shorthands = undefined;
5
+
6
+ /**
7
+ * @param pgm {import('node-pg-migrate').MigrationBuilder}
8
+ * @param run {() => void | undefined}
9
+ * @returns {Promise<void> | void}
10
+ */
11
+ export const up = (pgm) => {
12
+ // Create the flapjack_feature_flag_group table
13
+ pgm.createTable("flapjack_feature_flag_group", {
14
+ id: "id",
15
+ name: { type: "text", notNull: true, unique: true },
16
+ note: { type: "text" },
17
+ created: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
18
+ modified: {
19
+ type: "timestamptz",
20
+ notNull: true,
21
+ default: pgm.func("now()"),
22
+ },
23
+ });
24
+
25
+ // Create the flapjack_feature_flag_group_member relation table
26
+ pgm.createTable("flapjack_feature_flag_group_member", {
27
+ id: "id",
28
+ group_id: {
29
+ type: "integer",
30
+ notNull: true,
31
+ references: "flapjack_feature_flag_group",
32
+ onDelete: "CASCADE",
33
+ },
34
+ feature_flag_id: {
35
+ type: "integer",
36
+ notNull: true,
37
+ references: "flapjack_feature_flag",
38
+ onDelete: "CASCADE",
39
+ },
40
+ created: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
41
+ });
42
+
43
+ // Add unique constraint on group_id and feature_flag_id
44
+ pgm.addConstraint(
45
+ "flapjack_feature_flag_group_member",
46
+ "unique_group_feature_flag",
47
+ {
48
+ unique: ["group_id", "feature_flag_id"],
49
+ },
50
+ );
51
+
52
+ // Create indexes for efficient lookups
53
+ pgm.createIndex("flapjack_feature_flag_group_member", "group_id");
54
+ pgm.createIndex("flapjack_feature_flag_group_member", "feature_flag_id");
55
+
56
+ // Attach the modified timestamp trigger to the group table
57
+ pgm.sql(`
58
+ CREATE TRIGGER flapjack_feature_flag_group_set_modified
59
+ BEFORE UPDATE ON flapjack_feature_flag_group
60
+ FOR EACH ROW
61
+ EXECUTE FUNCTION flapjack_set_modified_timestamp();
62
+ `);
63
+ };
64
+
65
+ /**
66
+ * @param pgm {import('node-pg-migrate').MigrationBuilder}
67
+ * @param run {() => void | undefined}
68
+ * @returns {Promise<void> | void}
69
+ */
70
+ export const down = (pgm) => {
71
+ pgm.sql(`
72
+ DROP TRIGGER IF EXISTS flapjack_feature_flag_group_set_modified
73
+ ON flapjack_feature_flag_group;
74
+ `);
75
+ pgm.dropTable("flapjack_feature_flag_group_member");
76
+ pgm.dropTable("flapjack_feature_flag_group");
77
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandtg/flapjack",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A simple feature flags library with PostgreSQL integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",