@brandtg/flapjack 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -13
- package/dist/cache.d.ts +7 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +19 -3
- package/dist/cli.js +867 -9
- package/dist/model.d.ts +167 -17
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +310 -28
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/migrations/1779210257698_add-feature-flag-subjects.js +93 -0
- package/migrations/1781107651000_add-feature-flag-tags.js +38 -0
- package/migrations/1781200000000_add-archived.js +37 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A simple feature flags library with PostgreSQL integration, inspired by [django-
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Multiple targeting strategies**: Enable features for specific users, roles, groups, or percentage rollouts
|
|
12
|
+
- **External subject targeting**: Assign flags (or flag groups) to external subjects like tenants or org IDs
|
|
12
13
|
- **Consistent hashing**: Deterministic user bucketing for A/B testing and experimentation
|
|
13
14
|
- **PostgreSQL-backed**: Reliable, transactional flag storage with your existing database
|
|
14
15
|
- **CLI included**: Manage feature flags from the command line
|
|
@@ -63,6 +64,16 @@ const active = await featureFlag.isActiveForUser({
|
|
|
63
64
|
user: "1234",
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
// Assign a flag to an external subject ID (for example, a tenant)
|
|
68
|
+
await featureFlags.addSubject(flag.id, "tenant:acme");
|
|
69
|
+
|
|
70
|
+
// Check if active for a broader context that includes external subject IDs
|
|
71
|
+
const activeForTenant = await featureFlags.isActiveForContext({
|
|
72
|
+
name: "enable_new_checkout_20250101_gbrandt",
|
|
73
|
+
user: "1234",
|
|
74
|
+
subjects: ["tenant:acme"],
|
|
75
|
+
});
|
|
76
|
+
|
|
66
77
|
// Enable the feature flag for certain roles
|
|
67
78
|
await featureFlags.update(flag.id, {
|
|
68
79
|
roles: ["admin", "staff"],
|
|
@@ -101,9 +112,41 @@ When checking if a feature flag is active for a user, Flapjack evaluates rules i
|
|
|
101
112
|
1. **Everyone Override** (`everyone: true/false`): If set, immediately returns this value, ignoring all other rules
|
|
102
113
|
2. **User List** (`users: [...]`): If the user ID is in this list, returns `true`
|
|
103
114
|
3. **Group Membership** (`groups: [...]`): If the user belongs to any specified group, returns `true`
|
|
104
|
-
4. **
|
|
105
|
-
5. **
|
|
106
|
-
6. **
|
|
115
|
+
4. **External Subject Match** (`subjects: [...]`): If any provided subject matches a direct flag subject or a flag-group subject, returns `true`
|
|
116
|
+
5. **Role Membership** (`roles: [...]`): If the user has any specified role, returns `true`
|
|
117
|
+
6. **Percentage Rollout** (`percent: 0-99.9`): Uses consistent hashing to deterministically bucket users
|
|
118
|
+
7. **Default**: Returns `false` if no conditions are met
|
|
119
|
+
|
|
120
|
+
## External Subject Targeting
|
|
121
|
+
|
|
122
|
+
Use subjects when identity/group membership is managed outside Flapjack (for example, tenant IDs from another system).
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const groupModel = new FeatureFlagGroupModel(pool);
|
|
126
|
+
const flagModel = new FeatureFlagModel(pool);
|
|
127
|
+
|
|
128
|
+
const rolloutGroup = await groupModel.create({
|
|
129
|
+
name: "checkout_rollout",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const checkoutFlag = await flagModel.create({
|
|
133
|
+
name: "enable_checkout_v2",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await groupModel.addFeatureFlag(rolloutGroup.id, checkoutFlag.id);
|
|
137
|
+
|
|
138
|
+
// Attach external subject to all flags in this feature flag group
|
|
139
|
+
await groupModel.addSubject(rolloutGroup.id, "tenant:acme");
|
|
140
|
+
|
|
141
|
+
// You can also attach a subject directly to a single flag
|
|
142
|
+
await flagModel.addSubject(checkoutFlag.id, "tenant:beta");
|
|
143
|
+
|
|
144
|
+
const isActive = await flagModel.isActiveForContext({
|
|
145
|
+
name: "enable_checkout_v2",
|
|
146
|
+
user: "user_123",
|
|
147
|
+
subjects: ["tenant:acme"],
|
|
148
|
+
});
|
|
149
|
+
```
|
|
107
150
|
|
|
108
151
|
### Example Evaluation
|
|
109
152
|
|
|
@@ -138,6 +181,36 @@ await featureFlags.isActiveForUser({
|
|
|
138
181
|
});
|
|
139
182
|
```
|
|
140
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
|
+
|
|
141
214
|
## Performance Considerations
|
|
142
215
|
|
|
143
216
|
⚠️ **Important**: Without caching, Flapjack queries the database on every `isActiveForUser()` call. For high-traffic applications, use the built-in caching layer:
|
|
@@ -299,17 +372,21 @@ await featureFlags.update(flagId, { percent: 100 });
|
|
|
299
372
|
|
|
300
373
|
Creates a new feature flag.
|
|
301
374
|
|
|
302
|
-
#### `getById(id: number): Promise<FeatureFlag | null>`
|
|
375
|
+
#### `getById(id: number, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>`
|
|
303
376
|
|
|
304
|
-
Retrieves a feature flag by its ID.
|
|
377
|
+
Retrieves a feature flag by its ID. Archived flags are excluded unless `includeArchived` is `true`.
|
|
305
378
|
|
|
306
|
-
#### `getByName(name: string): Promise<FeatureFlag | null>`
|
|
379
|
+
#### `getByName(name: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>`
|
|
307
380
|
|
|
308
|
-
Retrieves a feature flag by its name.
|
|
381
|
+
Retrieves a feature flag by its name. Archived flags are excluded unless `includeArchived` is `true`.
|
|
309
382
|
|
|
310
|
-
#### `list(): Promise<FeatureFlag[]>`
|
|
383
|
+
#### `list(options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>`
|
|
311
384
|
|
|
312
|
-
Lists all feature flags, ordered by ID.
|
|
385
|
+
Lists all feature flags, ordered by ID. Archived flags are excluded unless `includeArchived` is `true`.
|
|
386
|
+
|
|
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`.
|
|
313
390
|
|
|
314
391
|
#### `update(id: number, changes: UpdateChanges): Promise<FeatureFlag | null>`
|
|
315
392
|
|
|
@@ -317,7 +394,13 @@ Updates a feature flag. Returns the updated flag or null if not found.
|
|
|
317
394
|
|
|
318
395
|
#### `delete(id: number): Promise<boolean>`
|
|
319
396
|
|
|
320
|
-
|
|
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`.
|
|
321
404
|
|
|
322
405
|
#### `isActiveForUser(params): Promise<boolean>`
|
|
323
406
|
|
|
@@ -341,21 +424,70 @@ flapjack create --name my_feature --roles admin --note "Admin-only feature"
|
|
|
341
424
|
# List all flags
|
|
342
425
|
flapjack list
|
|
343
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
|
+
|
|
344
434
|
# Get a specific flag
|
|
345
435
|
flapjack get-by-name my_feature
|
|
346
436
|
|
|
347
437
|
# Check if active for a user
|
|
348
438
|
flapjack is-active my_feature --user user123 --roles admin
|
|
349
439
|
|
|
440
|
+
# Check if multiple flags are active for a user
|
|
441
|
+
flapjack are-active --names my_feature other_feature --user user123 --roles admin
|
|
442
|
+
|
|
443
|
+
# Check if active for context (with external subject IDs)
|
|
444
|
+
flapjack is-active-context my_feature --user user123 --subjects tenant:acme
|
|
445
|
+
|
|
446
|
+
# Check multiple flags for context
|
|
447
|
+
flapjack are-active-context --names my_feature other_feature --subjects tenant:acme
|
|
448
|
+
|
|
350
449
|
# Update a flag
|
|
351
450
|
flapjack update 1 --percent 50 --everyone false
|
|
352
451
|
|
|
353
452
|
# Clear specific fields
|
|
354
453
|
flapjack update 1 --clear-roles --clear-percent
|
|
355
454
|
|
|
356
|
-
# Delete a flag
|
|
455
|
+
# Delete a flag (permanent — destroys all metadata)
|
|
357
456
|
flapjack delete 1
|
|
358
457
|
|
|
458
|
+
# Archive a flag (hide it but keep its history; irreversible)
|
|
459
|
+
flapjack archive 1
|
|
460
|
+
|
|
461
|
+
# Add/remove/list subject mappings on a flag
|
|
462
|
+
flapjack add-subject 1 tenant:acme
|
|
463
|
+
flapjack remove-subject 1 tenant:acme
|
|
464
|
+
flapjack list-subjects 1
|
|
465
|
+
flapjack list-by-subject tenant:acme
|
|
466
|
+
|
|
467
|
+
# Feature flag groups
|
|
468
|
+
flapjack group-create --name checkout_rollout --note "Checkout launch cohort"
|
|
469
|
+
flapjack group-list
|
|
470
|
+
flapjack group-get 1
|
|
471
|
+
flapjack group-get-by-name checkout_rollout
|
|
472
|
+
flapjack group-update 1 --note "Updated"
|
|
473
|
+
flapjack group-delete 1
|
|
474
|
+
flapjack group-archive 1
|
|
475
|
+
|
|
476
|
+
# Group membership
|
|
477
|
+
flapjack group-add-flag 1 10
|
|
478
|
+
flapjack group-remove-flag 1 10
|
|
479
|
+
flapjack group-list-flags 1
|
|
480
|
+
flapjack group-list-for-flag 10
|
|
481
|
+
|
|
482
|
+
# Bulk update all flags in a group
|
|
483
|
+
flapjack group-update-all 1 --percent 25 --roles admin
|
|
484
|
+
|
|
485
|
+
# Group-level subject mappings
|
|
486
|
+
flapjack group-add-subject 1 tenant:acme
|
|
487
|
+
flapjack group-remove-subject 1 tenant:acme
|
|
488
|
+
flapjack group-list-subjects 1
|
|
489
|
+
flapjack group-list-by-subject tenant:acme
|
|
490
|
+
|
|
359
491
|
# Debug user bucketing
|
|
360
492
|
flapjack hash-user user123
|
|
361
493
|
```
|
|
@@ -409,8 +541,11 @@ await featureFlags.update(flagId, { percent: 10 });
|
|
|
409
541
|
await featureFlags.update(flagId, { everyone: true });
|
|
410
542
|
|
|
411
543
|
// 5. Cleanup (after feature is stable)
|
|
412
|
-
// Remove feature flag checks from code
|
|
413
|
-
|
|
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)
|
|
414
549
|
```
|
|
415
550
|
|
|
416
551
|
### Error Handling
|
package/dist/cache.d.ts
CHANGED
|
@@ -44,5 +44,12 @@ export declare class FeatureFlagCache {
|
|
|
44
44
|
roles?: string[];
|
|
45
45
|
groups?: string[];
|
|
46
46
|
}): Promise<boolean>;
|
|
47
|
+
isActiveForContext({ name, user, roles, groups, subjects, }: {
|
|
48
|
+
name: string;
|
|
49
|
+
user?: string;
|
|
50
|
+
roles?: string[];
|
|
51
|
+
groups?: string[];
|
|
52
|
+
subjects?: string[];
|
|
53
|
+
}): Promise<boolean>;
|
|
47
54
|
}
|
|
48
55
|
//# sourceMappingURL=cache.d.ts.map
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACzC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AASD;;GAEG;AACH,qBAAa,aAAa,CAAC,CAAC,CAAE,YAAW,KAAK,CAAC,CAAC,CAAC;IAC/C,OAAO,CAAC,KAAK,CAAoC;IAE3C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAgBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;OAEG;IACH,YAAY,IAAI,IAAI;IASpB;;OAEG;IACH,IAAI,IAAI,MAAM;IAId;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,GAAG,CAAS;gBAER,EACV,KAAK,EACL,KAAK,EACL,GAAiB,GAClB,EAAE;QACD,KAAK,EAAE,gBAAgB,CAAC;QACxB,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACtB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd;IAMD;;OAEG;IACH,OAAO,CAAC,gBAAgB;
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACzC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AASD;;GAEG;AACH,qBAAa,aAAa,CAAC,CAAC,CAAE,YAAW,KAAK,CAAC,CAAC,CAAC;IAC/C,OAAO,CAAC,KAAK,CAAoC;IAE3C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAgBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;OAEG;IACH,YAAY,IAAI,IAAI;IASpB;;OAEG;IACH,IAAI,IAAI,MAAM;IAId;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,GAAG,CAAS;gBAER,EACV,KAAK,EACL,KAAK,EACL,GAAiB,GAClB,EAAE;QACD,KAAK,EAAE,gBAAgB,CAAC;QACxB,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACtB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd;IAMD;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA4BlB,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;IASd,kBAAkB,CAAC,EACvB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,EACN,QAAQ,GACT,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;QAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,GAAG,OAAO,CAAC,OAAO,CAAC;CA8BrB"}
|
package/dist/cache.js
CHANGED
|
@@ -61,13 +61,14 @@ export class FeatureFlagCache {
|
|
|
61
61
|
/**
|
|
62
62
|
* Generates a cache key using murmur3 hash of the flag name and parameters.
|
|
63
63
|
*/
|
|
64
|
-
generateCacheKey({ name, user, roles, groups, }) {
|
|
64
|
+
generateCacheKey({ name, user, roles, groups, subjects, }) {
|
|
65
65
|
// Create a consistent string representation of the parameters
|
|
66
66
|
const keyParts = [
|
|
67
67
|
name,
|
|
68
68
|
user || "",
|
|
69
69
|
(roles || []).sort().join(","),
|
|
70
70
|
(groups || []).sort().join(","),
|
|
71
|
+
(subjects || []).sort().join(","),
|
|
71
72
|
];
|
|
72
73
|
const keyString = keyParts.join("|");
|
|
73
74
|
// Generate murmur3 hash
|
|
@@ -75,19 +76,34 @@ export class FeatureFlagCache {
|
|
|
75
76
|
return `flag:${hash}`;
|
|
76
77
|
}
|
|
77
78
|
async isActiveForUser({ name, user, roles, groups, }) {
|
|
79
|
+
return this.isActiveForContext({
|
|
80
|
+
name,
|
|
81
|
+
user,
|
|
82
|
+
roles,
|
|
83
|
+
groups,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async isActiveForContext({ name, user, roles, groups, subjects, }) {
|
|
78
87
|
// Generate cache key
|
|
79
|
-
const cacheKey = this.generateCacheKey({
|
|
88
|
+
const cacheKey = this.generateCacheKey({
|
|
89
|
+
name,
|
|
90
|
+
user,
|
|
91
|
+
roles,
|
|
92
|
+
groups,
|
|
93
|
+
subjects,
|
|
94
|
+
});
|
|
80
95
|
// Try to get from cache first
|
|
81
96
|
const cachedResult = await this.cache.get(cacheKey);
|
|
82
97
|
if (cachedResult !== undefined) {
|
|
83
98
|
return cachedResult;
|
|
84
99
|
}
|
|
85
100
|
// Cache miss, get from model
|
|
86
|
-
const result = await this.model.
|
|
101
|
+
const result = await this.model.isActiveForContext({
|
|
87
102
|
name,
|
|
88
103
|
user,
|
|
89
104
|
roles,
|
|
90
105
|
groups,
|
|
106
|
+
subjects,
|
|
91
107
|
});
|
|
92
108
|
// Store in cache with TTL
|
|
93
109
|
this.cache.set(cacheKey, result, this.ttl);
|