@brandtg/flapjack 0.1.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/LICENSE +27 -0
- package/README.md +584 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +356 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/migrate.d.ts +49 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +55 -0
- package/dist/model.d.ts +212 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +365 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/migrations/1759720355601_create-feature-flag.js +65 -0
- package/package.json +80 -0
package/dist/model.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import MurmurHash3 from "imurmurhash";
|
|
2
|
+
const TABLE = "flapjack_feature_flag";
|
|
3
|
+
const COLUMNS = [
|
|
4
|
+
"id",
|
|
5
|
+
"name",
|
|
6
|
+
"everyone",
|
|
7
|
+
"percent",
|
|
8
|
+
"roles",
|
|
9
|
+
"groups",
|
|
10
|
+
"users",
|
|
11
|
+
"note",
|
|
12
|
+
"created",
|
|
13
|
+
"modified",
|
|
14
|
+
];
|
|
15
|
+
function mapRow(row) {
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
name: row.name,
|
|
19
|
+
everyone: row.everyone ?? undefined,
|
|
20
|
+
percent: row.percent === null || row.percent === undefined
|
|
21
|
+
? undefined
|
|
22
|
+
: Number(row.percent),
|
|
23
|
+
roles: row.roles && row.roles.length > 0 ? row.roles : undefined,
|
|
24
|
+
groups: row.groups && row.groups.length > 0
|
|
25
|
+
? row.groups
|
|
26
|
+
: undefined,
|
|
27
|
+
users: row.users && row.users.length > 0 ? row.users : undefined,
|
|
28
|
+
note: row.note ?? undefined,
|
|
29
|
+
created: new Date(row.created),
|
|
30
|
+
modified: new Date(row.modified),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Model for managing feature flags stored in PostgreSQL.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* import { Pool } from "pg";
|
|
39
|
+
* import { FeatureFlagModel } from "@brandtg/flapjack";
|
|
40
|
+
*
|
|
41
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
42
|
+
* const featureFlags = new FeatureFlagModel(pool);
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class FeatureFlagModel {
|
|
46
|
+
db;
|
|
47
|
+
/**
|
|
48
|
+
* Creates a new FeatureFlagModel instance.
|
|
49
|
+
*
|
|
50
|
+
* @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
55
|
+
* const model = new FeatureFlagModel(pool);
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
constructor(db) {
|
|
59
|
+
this.db = db;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates a new feature flag in the database.
|
|
63
|
+
*
|
|
64
|
+
* @param input - Feature flag configuration
|
|
65
|
+
* @param input.name - Unique name for the feature flag (required)
|
|
66
|
+
* @param input.everyone - Optional boolean to enable/disable for everyone (overrides all other settings)
|
|
67
|
+
* @param input.percent - Optional percentage rollout (0-99.9)
|
|
68
|
+
* @param input.roles - Optional list of roles that have this flag enabled
|
|
69
|
+
* @param input.groups - Optional list of user groups that have this flag enabled
|
|
70
|
+
* @param input.users - Optional list of specific user IDs that have this flag enabled
|
|
71
|
+
* @param input.note - Optional description of the flag's purpose
|
|
72
|
+
* @returns The created feature flag with generated id, created, and modified timestamps
|
|
73
|
+
*
|
|
74
|
+
* @throws Will throw an error if a flag with the same name already exists
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const flag = await model.create({
|
|
79
|
+
* name: "new_checkout_flow",
|
|
80
|
+
* roles: ["admin"],
|
|
81
|
+
* note: "New checkout redesign",
|
|
82
|
+
* });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
async create(input) {
|
|
86
|
+
const cols = ["name"];
|
|
87
|
+
const vals = [input.name];
|
|
88
|
+
if ("everyone" in input) {
|
|
89
|
+
cols.push("everyone");
|
|
90
|
+
vals.push(input.everyone ?? null);
|
|
91
|
+
}
|
|
92
|
+
if ("percent" in input) {
|
|
93
|
+
cols.push("percent");
|
|
94
|
+
vals.push(input.percent ?? null);
|
|
95
|
+
}
|
|
96
|
+
if ("roles" in input) {
|
|
97
|
+
cols.push("roles");
|
|
98
|
+
vals.push(input.roles ?? null);
|
|
99
|
+
}
|
|
100
|
+
if ("groups" in input) {
|
|
101
|
+
cols.push("groups");
|
|
102
|
+
vals.push(input.groups ?? null);
|
|
103
|
+
}
|
|
104
|
+
if ("users" in input) {
|
|
105
|
+
cols.push("users");
|
|
106
|
+
vals.push(input.users ?? null);
|
|
107
|
+
}
|
|
108
|
+
if ("note" in input) {
|
|
109
|
+
cols.push("note");
|
|
110
|
+
vals.push(input.note ?? null);
|
|
111
|
+
}
|
|
112
|
+
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
|
|
113
|
+
const sql = `INSERT INTO ${TABLE} (${cols.join(", ")}) VALUES (${placeholders}) RETURNING ${COLUMNS.join(", ")}`;
|
|
114
|
+
const res = await this.db.query(sql, vals);
|
|
115
|
+
return mapRow(res.rows[0]);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Retrieves a feature flag by its ID.
|
|
119
|
+
*
|
|
120
|
+
* @param id - The unique identifier of the feature flag
|
|
121
|
+
* @returns The feature flag if found, null otherwise
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```typescript
|
|
125
|
+
* const flag = await model.getById(123);
|
|
126
|
+
* if (flag) {
|
|
127
|
+
* console.log(`Flag ${flag.name} is configured`);
|
|
128
|
+
* }
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
async getById(id) {
|
|
132
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE id = $1`;
|
|
133
|
+
const res = await this.db.query(sql, [id]);
|
|
134
|
+
if (res.rows.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
return mapRow(res.rows[0]);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Retrieves a feature flag by its name.
|
|
140
|
+
*
|
|
141
|
+
* @param name - The unique name of the feature flag
|
|
142
|
+
* @returns The feature flag if found, null otherwise
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```typescript
|
|
146
|
+
* const flag = await model.getByName("new_checkout_flow");
|
|
147
|
+
* if (flag) {
|
|
148
|
+
* console.log(`Flag is ${flag.everyone ? 'enabled' : 'disabled'} for everyone`);
|
|
149
|
+
* }
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
async getByName(name) {
|
|
153
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} WHERE name = $1`;
|
|
154
|
+
const res = await this.db.query(sql, [name]);
|
|
155
|
+
if (res.rows.length === 0)
|
|
156
|
+
return null;
|
|
157
|
+
return mapRow(res.rows[0]);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Retrieves all feature flags, ordered by ID.
|
|
161
|
+
*
|
|
162
|
+
* @returns Array of all feature flags in the database
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* const flags = await model.list();
|
|
167
|
+
* console.log(`Total flags: ${flags.length}`);
|
|
168
|
+
* flags.forEach(flag => {
|
|
169
|
+
* console.log(`${flag.name}: ${flag.everyone ?? 'conditional'}`);
|
|
170
|
+
* });
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
async list() {
|
|
174
|
+
const sql = `SELECT ${COLUMNS.join(", ")} FROM ${TABLE} ORDER BY id`;
|
|
175
|
+
const res = await this.db.query(sql);
|
|
176
|
+
return res.rows.map(mapRow);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Updates an existing feature flag.
|
|
180
|
+
*
|
|
181
|
+
* @param id - The unique identifier of the feature flag to update
|
|
182
|
+
* @param changes - Object containing the fields to update
|
|
183
|
+
* @returns The updated feature flag if found, null otherwise
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* The modified timestamp is automatically updated by a database trigger.
|
|
187
|
+
* Pass `null` for a field to clear it (e.g., `everyone: null` removes the override).
|
|
188
|
+
* If no changes are provided, returns the flag unchanged.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* // Gradually increase rollout percentage
|
|
193
|
+
* await model.update(flagId, { percent: 25 });
|
|
194
|
+
*
|
|
195
|
+
* // Enable for everyone
|
|
196
|
+
* await model.update(flagId, { everyone: true });
|
|
197
|
+
*
|
|
198
|
+
* // Remove everyone override, reverting to other rules
|
|
199
|
+
* await model.update(flagId, { everyone: null });
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
async update(id, changes) {
|
|
203
|
+
const sets = [];
|
|
204
|
+
const vals = [];
|
|
205
|
+
if ("name" in changes) {
|
|
206
|
+
sets.push(`name = $${sets.length + 1}`);
|
|
207
|
+
vals.push(changes.name ?? null);
|
|
208
|
+
}
|
|
209
|
+
if ("everyone" in changes) {
|
|
210
|
+
sets.push(`everyone = $${sets.length + 1}`);
|
|
211
|
+
vals.push(changes.everyone ?? null);
|
|
212
|
+
}
|
|
213
|
+
if ("percent" in changes) {
|
|
214
|
+
sets.push(`percent = $${sets.length + 1}`);
|
|
215
|
+
vals.push(changes.percent ?? null);
|
|
216
|
+
}
|
|
217
|
+
if ("roles" in changes) {
|
|
218
|
+
sets.push(`roles = $${sets.length + 1}`);
|
|
219
|
+
vals.push(changes.roles ?? null);
|
|
220
|
+
}
|
|
221
|
+
if ("groups" in changes) {
|
|
222
|
+
sets.push(`groups = $${sets.length + 1}`);
|
|
223
|
+
vals.push(changes.groups ?? null);
|
|
224
|
+
}
|
|
225
|
+
if ("users" in changes) {
|
|
226
|
+
sets.push(`users = $${sets.length + 1}`);
|
|
227
|
+
vals.push(changes.users ?? null);
|
|
228
|
+
}
|
|
229
|
+
if ("note" in changes) {
|
|
230
|
+
sets.push(`note = $${sets.length + 1}`);
|
|
231
|
+
vals.push(changes.note ?? null);
|
|
232
|
+
}
|
|
233
|
+
if (sets.length === 0) {
|
|
234
|
+
return this.getById(id);
|
|
235
|
+
}
|
|
236
|
+
const idParamIndex = sets.length + 1;
|
|
237
|
+
vals.push(id);
|
|
238
|
+
const sql = `UPDATE ${TABLE} SET ${sets.join(", ")} WHERE id = $${idParamIndex} RETURNING ${COLUMNS.join(", ")}`;
|
|
239
|
+
const res = await this.db.query(sql, vals);
|
|
240
|
+
if (res.rows.length === 0)
|
|
241
|
+
return null;
|
|
242
|
+
return mapRow(res.rows[0]);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Deletes a feature flag from the database.
|
|
246
|
+
*
|
|
247
|
+
* @param id - The unique identifier of the feature flag to delete
|
|
248
|
+
* @returns true if the flag was deleted, false if not found
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```typescript
|
|
252
|
+
* const deleted = await model.delete(flagId);
|
|
253
|
+
* if (deleted) {
|
|
254
|
+
* console.log("Flag successfully removed");
|
|
255
|
+
* }
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
async delete(id) {
|
|
259
|
+
const res = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
|
|
260
|
+
return res.rowCount > 0;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Checks if a user belongs to any of the specified groups
|
|
264
|
+
*/
|
|
265
|
+
isActiveForGroups(userGroups = [], flagGroups = []) {
|
|
266
|
+
if (flagGroups.length === 0)
|
|
267
|
+
return false;
|
|
268
|
+
return flagGroups.some((group) => userGroups.includes(group));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Computes the hash value for a user ID using MurmurHash3.
|
|
272
|
+
*
|
|
273
|
+
* @param userId - The user ID to hash
|
|
274
|
+
* @returns The hash value used for percentage bucketing
|
|
275
|
+
*
|
|
276
|
+
* @remarks
|
|
277
|
+
* This method is useful for debugging percentage rollouts.
|
|
278
|
+
* The hash value is consistent for the same user ID.
|
|
279
|
+
* The bucket is computed as `hash % 100`.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```typescript
|
|
283
|
+
* const hash = await model.hashUserId("user_123");
|
|
284
|
+
* const bucket = hash % 100;
|
|
285
|
+
* console.log(`User bucket: ${bucket}`);
|
|
286
|
+
* // If bucket is 42 and percent is 50, user is in the rollout
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
async hashUserId(userId) {
|
|
290
|
+
return MurmurHash3(userId).result();
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Checks if a feature flag is active for a user based on configured rules.
|
|
294
|
+
*
|
|
295
|
+
* @param params - Parameters for flag evaluation
|
|
296
|
+
* @param params.name - The name of the feature flag to check
|
|
297
|
+
* @param params.user - Optional user ID
|
|
298
|
+
* @param params.roles - Optional list of roles the user has
|
|
299
|
+
* @param params.groups - Optional list of groups the user belongs to
|
|
300
|
+
* @returns true if the flag is active for the user, false otherwise
|
|
301
|
+
*
|
|
302
|
+
* @remarks
|
|
303
|
+
* Evaluation order (first match wins):
|
|
304
|
+
* 1. Everyone override (if set to true/false, returns immediately)
|
|
305
|
+
* 2. User ID is in the users list
|
|
306
|
+
* 3. User belongs to any group in the groups list
|
|
307
|
+
* 4. User has any role in the roles list
|
|
308
|
+
* 5. User falls within the percentage rollout (based on consistent hashing)
|
|
309
|
+
* 6. Default: returns false
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```typescript
|
|
313
|
+
* // Check for admin user
|
|
314
|
+
* const isActive = await model.isActiveForUser({
|
|
315
|
+
* name: "new_feature",
|
|
316
|
+
* user: "user_123",
|
|
317
|
+
* roles: ["admin"],
|
|
318
|
+
* groups: ["beta_testers"],
|
|
319
|
+
* });
|
|
320
|
+
*
|
|
321
|
+
* if (isActive) {
|
|
322
|
+
* // Show new feature
|
|
323
|
+
* } else {
|
|
324
|
+
* // Show old feature
|
|
325
|
+
* }
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
async isActiveForUser({ name, user, roles, groups, }) {
|
|
329
|
+
const flag = await this.getByName(name);
|
|
330
|
+
// No such flag
|
|
331
|
+
if (!flag) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
// Everyone Override: If everyone is true or false, return that value immediately
|
|
335
|
+
if (flag.everyone !== undefined && flag.everyone !== null) {
|
|
336
|
+
return flag.everyone;
|
|
337
|
+
}
|
|
338
|
+
// User-Specific Check: If user ID is in the users array, return true
|
|
339
|
+
if (user && flag.users && flag.users.includes(user)) {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
// Group Check: If any of the user's groups match any group in the groups array, return true
|
|
343
|
+
if (groups && this.isActiveForGroups(groups, flag.groups)) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
// Role Check: If any of the user's roles match any role in the roles array, return true
|
|
347
|
+
if (flag.roles && roles) {
|
|
348
|
+
for (const role of roles) {
|
|
349
|
+
if (flag.roles.includes(role)) {
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Percentage Check: If percentage rollout applies to this user, return rollout result
|
|
355
|
+
if (user && flag.percent && flag.percent > 0) {
|
|
356
|
+
const userHash = await this.hashUserId(user);
|
|
357
|
+
const bucket = userHash % 100;
|
|
358
|
+
if (bucket < flag.percent) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Default: Return false
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a feature flag used to enable or disable features in an application.
|
|
3
|
+
*/
|
|
4
|
+
export type FeatureFlag = {
|
|
5
|
+
/** Unique identifier for the feature flag */
|
|
6
|
+
id: number;
|
|
7
|
+
/** Human readable of the feature flag */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Flip this flag on or off for everyone, overriding all other settings */
|
|
10
|
+
everyone?: boolean | null;
|
|
11
|
+
/** Number between 0 and 99.9 for percentage rollout */
|
|
12
|
+
percent?: number;
|
|
13
|
+
/** List of roles that have this feature flag enabled */
|
|
14
|
+
roles?: string[];
|
|
15
|
+
/** List of user groups that have this feature flag enabled */
|
|
16
|
+
groups?: string[];
|
|
17
|
+
/** List of specific user IDs that have this feature flag enabled */
|
|
18
|
+
users?: string[];
|
|
19
|
+
/** Description of where this flag is used and what it does */
|
|
20
|
+
note?: string;
|
|
21
|
+
/** Date when the feature flag was created */
|
|
22
|
+
created: Date;
|
|
23
|
+
/** Date when the feature flag was last modified */
|
|
24
|
+
modified: Date;
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +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;CAChB,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
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 table
|
|
13
|
+
pgm.createTable("flapjack_feature_flag", {
|
|
14
|
+
id: "id",
|
|
15
|
+
name: { type: "text", notNull: true, unique: true },
|
|
16
|
+
everyone: { type: "boolean" },
|
|
17
|
+
percent: {
|
|
18
|
+
type: "numeric(3,1)",
|
|
19
|
+
check: "percent >= 0 AND percent <= 99.9",
|
|
20
|
+
},
|
|
21
|
+
roles: { type: "text[]", default: pgm.func("'{}'::text[]") },
|
|
22
|
+
groups: { type: "text[]", default: pgm.func("'{}'::text[]") },
|
|
23
|
+
users: { type: "text[]", default: pgm.func("'{}'::text[]") },
|
|
24
|
+
note: { type: "text" },
|
|
25
|
+
created: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
|
|
26
|
+
modified: {
|
|
27
|
+
type: "timestamptz",
|
|
28
|
+
notNull: true,
|
|
29
|
+
default: pgm.func("now()"),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Create a trigger to update the modified timestamp on row update
|
|
34
|
+
pgm.sql(`
|
|
35
|
+
CREATE OR REPLACE FUNCTION flapjack_set_modified_timestamp()
|
|
36
|
+
RETURNS trigger AS $$
|
|
37
|
+
BEGIN
|
|
38
|
+
NEW.modified = now();
|
|
39
|
+
RETURN NEW;
|
|
40
|
+
END;
|
|
41
|
+
$$ LANGUAGE plpgsql;
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
// Attach the trigger to the flapjack_feature_flag table
|
|
45
|
+
pgm.sql(`
|
|
46
|
+
CREATE TRIGGER flapjack_feature_flag_set_modified
|
|
47
|
+
BEFORE UPDATE ON flapjack_feature_flag
|
|
48
|
+
FOR EACH ROW
|
|
49
|
+
EXECUTE FUNCTION flapjack_set_modified_timestamp();
|
|
50
|
+
`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
|
55
|
+
* @param run {() => void | undefined}
|
|
56
|
+
* @returns {Promise<void> | void}
|
|
57
|
+
*/
|
|
58
|
+
export const down = (pgm) => {
|
|
59
|
+
pgm.sql(`
|
|
60
|
+
DROP TRIGGER IF EXISTS flapjack_feature_flag_set_modified
|
|
61
|
+
ON flapjack_feature_flag;
|
|
62
|
+
`);
|
|
63
|
+
pgm.sql(`DROP FUNCTION IF EXISTS flapjack_set_modified_timestamp;`);
|
|
64
|
+
pgm.dropTable("flapjack_feature_flag");
|
|
65
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brandtg/flapjack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A simple feature flags library with PostgreSQL integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"flapjack": "./dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"migrations",
|
|
21
|
+
"!dist/**/*.test.js",
|
|
22
|
+
"!dist/**/*.test.d.ts",
|
|
23
|
+
"!dist/**/*.test.d.ts.map"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "npm run clean && npx tsc",
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"dev": "npx tsc --watch",
|
|
29
|
+
"dev:env": "bash bin/dev/env.sh",
|
|
30
|
+
"dev:migrate": "npx dotenv-cli -e env -- node-pg-migrate up",
|
|
31
|
+
"dev:docker:up": "docker compose -f docker-compose.yml up -d",
|
|
32
|
+
"dev:docker:down": "docker compose -f docker-compose.yml down -v",
|
|
33
|
+
"create-migration": "npx node-pg-migrate create --",
|
|
34
|
+
"test": "npm run build && vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint": "eslint .",
|
|
37
|
+
"format": "prettier --write .",
|
|
38
|
+
"prepublishOnly": "npm run lint && npm run test && npm run build",
|
|
39
|
+
"pack:dev": "npm run build && npm pack",
|
|
40
|
+
"smoke": "npm run build && node dist/cli.js --help && node dist/cli.js hash-user smoke"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"dotenv": "^16.4.5",
|
|
44
|
+
"imurmurhash": "^0.1.4",
|
|
45
|
+
"node-pg-migrate": "^8.0.3",
|
|
46
|
+
"pg": "^8.12.0",
|
|
47
|
+
"yargs": "^18.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^9.37.0",
|
|
51
|
+
"@types/imurmurhash": "^0.1.4",
|
|
52
|
+
"@types/pg": "^8.11.10",
|
|
53
|
+
"@types/yargs": "^17.0.33",
|
|
54
|
+
"dotenv-cli": "^10.0.0",
|
|
55
|
+
"eslint": "^9.37.0",
|
|
56
|
+
"prettier": "^3.6.2",
|
|
57
|
+
"typescript": "~5.9.3",
|
|
58
|
+
"typescript-eslint": "^8.45.0",
|
|
59
|
+
"vitest": "^3.2.4"
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"feature-flags",
|
|
63
|
+
"postgres",
|
|
64
|
+
"database",
|
|
65
|
+
"configuration"
|
|
66
|
+
],
|
|
67
|
+
"repository": {
|
|
68
|
+
"type": "git",
|
|
69
|
+
"url": "git+https://github.com/brandtg/flapjack.git"
|
|
70
|
+
},
|
|
71
|
+
"bugs": {
|
|
72
|
+
"url": "https://github.com/brandtg/flapjack/issues"
|
|
73
|
+
},
|
|
74
|
+
"homepage": "https://github.com/brandtg/flapjack#readme",
|
|
75
|
+
"license": "BSD-3-Clause",
|
|
76
|
+
"author": "Greg Brandt",
|
|
77
|
+
"engines": {
|
|
78
|
+
"node": ">=18.0.0"
|
|
79
|
+
}
|
|
80
|
+
}
|