@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/cli.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import yargs from "yargs";
|
|
3
|
+
import { hideBin } from "yargs/helpers";
|
|
4
|
+
import { Pool } from "pg";
|
|
5
|
+
import { FeatureFlagModel } from "./model.js";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
dotenv.config();
|
|
8
|
+
function createDatabase() {
|
|
9
|
+
const connectionString = process.env.DATABASE_URL;
|
|
10
|
+
if (!connectionString) {
|
|
11
|
+
throw new Error("DATABASE_URL is not set. Provide it in the environment or a .env file.");
|
|
12
|
+
}
|
|
13
|
+
return new Pool({ connectionString });
|
|
14
|
+
}
|
|
15
|
+
const cli = yargs(hideBin(process.argv))
|
|
16
|
+
.scriptName("flapjack")
|
|
17
|
+
.usage("$0 <command> [options]")
|
|
18
|
+
.help()
|
|
19
|
+
.alias("help", "h")
|
|
20
|
+
.version()
|
|
21
|
+
.alias("version", "v")
|
|
22
|
+
.demandCommand(1, "You need to specify a command")
|
|
23
|
+
.strict();
|
|
24
|
+
// Common options for feature flag fields
|
|
25
|
+
const flagOptions = {
|
|
26
|
+
name: {
|
|
27
|
+
type: "string",
|
|
28
|
+
describe: "Feature flag name",
|
|
29
|
+
},
|
|
30
|
+
everyone: {
|
|
31
|
+
type: "boolean",
|
|
32
|
+
describe: "Enable flag for everyone (overrides all other settings)",
|
|
33
|
+
},
|
|
34
|
+
percent: {
|
|
35
|
+
type: "number",
|
|
36
|
+
describe: "Percentage rollout (0-99.9)",
|
|
37
|
+
},
|
|
38
|
+
roles: {
|
|
39
|
+
type: "array",
|
|
40
|
+
describe: "List of roles that have this flag enabled",
|
|
41
|
+
},
|
|
42
|
+
groups: {
|
|
43
|
+
type: "array",
|
|
44
|
+
describe: "List of user groups that have this flag enabled",
|
|
45
|
+
},
|
|
46
|
+
users: {
|
|
47
|
+
type: "array",
|
|
48
|
+
describe: "List of specific user IDs that have this flag enabled",
|
|
49
|
+
},
|
|
50
|
+
note: {
|
|
51
|
+
type: "string",
|
|
52
|
+
describe: "Description of where this flag is used and what it does",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
// Create command
|
|
56
|
+
cli.command("create", "Create a new feature flag", (yargs) => {
|
|
57
|
+
return yargs
|
|
58
|
+
.options({
|
|
59
|
+
...flagOptions,
|
|
60
|
+
})
|
|
61
|
+
.demandOption("name", "Feature flag name is required");
|
|
62
|
+
}, async (argv) => {
|
|
63
|
+
const db = createDatabase();
|
|
64
|
+
const model = new FeatureFlagModel(db);
|
|
65
|
+
try {
|
|
66
|
+
const input = { name: argv.name };
|
|
67
|
+
if (argv.everyone !== undefined)
|
|
68
|
+
input.everyone = argv.everyone;
|
|
69
|
+
if (argv.percent !== undefined)
|
|
70
|
+
input.percent = argv.percent;
|
|
71
|
+
if (argv.roles !== undefined)
|
|
72
|
+
input.roles = argv.roles;
|
|
73
|
+
if (argv.groups !== undefined)
|
|
74
|
+
input.groups = argv.groups;
|
|
75
|
+
if (argv.users !== undefined)
|
|
76
|
+
input.users = argv.users;
|
|
77
|
+
if (argv.note !== undefined)
|
|
78
|
+
input.note = argv.note;
|
|
79
|
+
const flag = await model.create(input);
|
|
80
|
+
console.log(JSON.stringify(flag, null, 2));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error("Error creating feature flag:", error);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await db.end();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// Get by ID command
|
|
91
|
+
cli.command("get <id>", "Get a feature flag by ID", (yargs) => {
|
|
92
|
+
return yargs.positional("id", {
|
|
93
|
+
type: "number",
|
|
94
|
+
describe: "Feature flag ID",
|
|
95
|
+
});
|
|
96
|
+
}, async (argv) => {
|
|
97
|
+
const db = createDatabase();
|
|
98
|
+
const model = new FeatureFlagModel(db);
|
|
99
|
+
try {
|
|
100
|
+
const flag = await model.getById(argv.id);
|
|
101
|
+
if (flag) {
|
|
102
|
+
console.log(JSON.stringify(flag, null, 2));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.log(`Feature flag with ID ${argv.id} not found`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error("Error getting feature flag:", error);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await db.end();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
// Get by name command
|
|
118
|
+
cli.command("get-by-name <name>", "Get a feature flag by name", (yargs) => {
|
|
119
|
+
return yargs.positional("name", {
|
|
120
|
+
type: "string",
|
|
121
|
+
describe: "Feature flag name",
|
|
122
|
+
});
|
|
123
|
+
}, async (argv) => {
|
|
124
|
+
const db = createDatabase();
|
|
125
|
+
const model = new FeatureFlagModel(db);
|
|
126
|
+
try {
|
|
127
|
+
const flag = await model.getByName(argv.name);
|
|
128
|
+
if (flag) {
|
|
129
|
+
console.log(JSON.stringify(flag, null, 2));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
console.log(`Feature flag with name "${argv.name}" not found`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.error("Error getting feature flag:", error);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await db.end();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// List command
|
|
145
|
+
cli.command("list", "List all feature flags", () => { }, async () => {
|
|
146
|
+
const db = createDatabase();
|
|
147
|
+
const model = new FeatureFlagModel(db);
|
|
148
|
+
try {
|
|
149
|
+
const flags = await model.list();
|
|
150
|
+
console.log(JSON.stringify(flags, null, 2));
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
console.error("Error listing feature flags:", error);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await db.end();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
// Update command
|
|
161
|
+
cli.command("update <id>", "Update a feature flag", (yargs) => {
|
|
162
|
+
return yargs
|
|
163
|
+
.positional("id", {
|
|
164
|
+
type: "number",
|
|
165
|
+
describe: "Feature flag ID",
|
|
166
|
+
})
|
|
167
|
+
.options({
|
|
168
|
+
...flagOptions,
|
|
169
|
+
"clear-everyone": {
|
|
170
|
+
type: "boolean",
|
|
171
|
+
describe: "Unset the everyone override (set to null)",
|
|
172
|
+
},
|
|
173
|
+
"clear-percent": {
|
|
174
|
+
type: "boolean",
|
|
175
|
+
describe: "Unset percentage rollout",
|
|
176
|
+
},
|
|
177
|
+
"clear-roles": {
|
|
178
|
+
type: "boolean",
|
|
179
|
+
describe: "Clear roles list",
|
|
180
|
+
},
|
|
181
|
+
"clear-groups": {
|
|
182
|
+
type: "boolean",
|
|
183
|
+
describe: "Clear groups list",
|
|
184
|
+
},
|
|
185
|
+
"clear-users": {
|
|
186
|
+
type: "boolean",
|
|
187
|
+
describe: "Clear users list",
|
|
188
|
+
},
|
|
189
|
+
"clear-note": {
|
|
190
|
+
type: "boolean",
|
|
191
|
+
describe: "Clear the note",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}, async (argv) => {
|
|
195
|
+
const db = createDatabase();
|
|
196
|
+
const model = new FeatureFlagModel(db);
|
|
197
|
+
try {
|
|
198
|
+
const changes = {};
|
|
199
|
+
if (argv.name !== undefined)
|
|
200
|
+
changes.name = argv.name;
|
|
201
|
+
// Everyone: clear flag takes precedence
|
|
202
|
+
if (argv.clearEveryone)
|
|
203
|
+
changes.everyone = null;
|
|
204
|
+
else if (argv.everyone !== undefined)
|
|
205
|
+
changes.everyone = argv.everyone;
|
|
206
|
+
// Percent: clear flag takes precedence
|
|
207
|
+
if (argv.clearPercent)
|
|
208
|
+
changes.percent = null;
|
|
209
|
+
else if (argv.percent !== undefined)
|
|
210
|
+
changes.percent = argv.percent;
|
|
211
|
+
// Roles: clear flag takes precedence
|
|
212
|
+
if (argv.clearRoles)
|
|
213
|
+
changes.roles = null;
|
|
214
|
+
else if (argv.roles !== undefined)
|
|
215
|
+
changes.roles = argv.roles;
|
|
216
|
+
// Groups: clear flag takes precedence
|
|
217
|
+
if (argv.clearGroups)
|
|
218
|
+
changes.groups = null;
|
|
219
|
+
else if (argv.groups !== undefined)
|
|
220
|
+
changes.groups = argv.groups;
|
|
221
|
+
// Users: clear flag takes precedence
|
|
222
|
+
if (argv.clearUsers)
|
|
223
|
+
changes.users = null;
|
|
224
|
+
else if (argv.users !== undefined)
|
|
225
|
+
changes.users = argv.users;
|
|
226
|
+
// Note: clear flag takes precedence
|
|
227
|
+
if (argv.clearNote)
|
|
228
|
+
changes.note = null;
|
|
229
|
+
else if (argv.note !== undefined)
|
|
230
|
+
changes.note = argv.note;
|
|
231
|
+
const flag = await model.update(argv.id, changes);
|
|
232
|
+
if (flag) {
|
|
233
|
+
console.log(JSON.stringify(flag, null, 2));
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
console.log(`Feature flag with ID ${argv.id} not found`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
console.error("Error updating feature flag:", error);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await db.end();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
// Delete command
|
|
249
|
+
cli.command("delete <id>", "Delete a feature flag", (yargs) => {
|
|
250
|
+
return yargs.positional("id", {
|
|
251
|
+
type: "number",
|
|
252
|
+
describe: "Feature flag ID",
|
|
253
|
+
});
|
|
254
|
+
}, async (argv) => {
|
|
255
|
+
const db = createDatabase();
|
|
256
|
+
const model = new FeatureFlagModel(db);
|
|
257
|
+
try {
|
|
258
|
+
const success = await model.delete(argv.id);
|
|
259
|
+
if (success) {
|
|
260
|
+
console.log(`Feature flag with ID ${argv.id} deleted successfully`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
console.log(`Feature flag with ID ${argv.id} not found`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
console.error("Error deleting feature flag:", error);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await db.end();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
// Check if active for user command
|
|
276
|
+
cli.command("is-active <name>", "Check if a feature flag is active for a user", (yargs) => {
|
|
277
|
+
return yargs
|
|
278
|
+
.positional("name", {
|
|
279
|
+
type: "string",
|
|
280
|
+
describe: "Feature flag name",
|
|
281
|
+
})
|
|
282
|
+
.options({
|
|
283
|
+
user: {
|
|
284
|
+
type: "string",
|
|
285
|
+
describe: "User ID",
|
|
286
|
+
},
|
|
287
|
+
roles: {
|
|
288
|
+
type: "array",
|
|
289
|
+
describe: "User roles",
|
|
290
|
+
},
|
|
291
|
+
groups: {
|
|
292
|
+
type: "array",
|
|
293
|
+
describe: "User groups",
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}, async (argv) => {
|
|
297
|
+
const db = createDatabase();
|
|
298
|
+
const model = new FeatureFlagModel(db);
|
|
299
|
+
try {
|
|
300
|
+
const isActive = await model.isActiveForUser({
|
|
301
|
+
name: argv.name,
|
|
302
|
+
user: argv.user,
|
|
303
|
+
roles: argv.roles,
|
|
304
|
+
groups: argv.groups,
|
|
305
|
+
});
|
|
306
|
+
console.log(JSON.stringify({
|
|
307
|
+
name: argv.name,
|
|
308
|
+
isActive,
|
|
309
|
+
user: argv.user,
|
|
310
|
+
roles: argv.roles,
|
|
311
|
+
groups: argv.groups,
|
|
312
|
+
}, null, 2));
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
console.error("Error checking feature flag:", error);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await db.end();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
// Hash user ID command (utility)
|
|
323
|
+
cli.command("hash-user <userId>", "Hash a user ID using the same algorithm as percentage rollout", (yargs) => {
|
|
324
|
+
return yargs.positional("userId", {
|
|
325
|
+
type: "string",
|
|
326
|
+
describe: "User ID to hash",
|
|
327
|
+
});
|
|
328
|
+
}, async (argv) => {
|
|
329
|
+
const db = createDatabase();
|
|
330
|
+
const model = new FeatureFlagModel(db);
|
|
331
|
+
try {
|
|
332
|
+
const hash = await model.hashUserId(argv.userId);
|
|
333
|
+
const bucket = hash % 100;
|
|
334
|
+
console.log(JSON.stringify({
|
|
335
|
+
userId: argv.userId,
|
|
336
|
+
hash,
|
|
337
|
+
bucket,
|
|
338
|
+
}, null, 2));
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
console.error("Error hashing user ID:", error);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await db.end();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
async function main() {
|
|
349
|
+
await cli.parseAsync();
|
|
350
|
+
}
|
|
351
|
+
main()
|
|
352
|
+
.then(() => process.exit(0))
|
|
353
|
+
.catch((err) => {
|
|
354
|
+
console.error("Error:", err);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +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,aAAa,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for running Flapjack database migrations.
|
|
3
|
+
*/
|
|
4
|
+
export interface MigrationOptions {
|
|
5
|
+
/** PostgreSQL connection URL (e.g., "postgresql://user:pass@localhost/dbname") */
|
|
6
|
+
databaseUrl: string;
|
|
7
|
+
/** Name of the migrations tracking table (default: 'pgmigrations') */
|
|
8
|
+
migrationsTable?: string;
|
|
9
|
+
/** Schema name to create tables in (optional) */
|
|
10
|
+
schema?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run Flapjack database migrations.
|
|
14
|
+
*
|
|
15
|
+
* This function applies all pending migrations to create or update the
|
|
16
|
+
* flapjack_feature_flag table in your PostgreSQL database.
|
|
17
|
+
*
|
|
18
|
+
* @param options - Migration configuration options
|
|
19
|
+
* @param options.databaseUrl - PostgreSQL connection URL
|
|
20
|
+
* @param options.migrationsTable - Name of the migrations tracking table (default: 'pgmigrations')
|
|
21
|
+
* @param options.schema - Schema name to create tables in (optional)
|
|
22
|
+
* @returns Promise that resolves when migrations are complete
|
|
23
|
+
*
|
|
24
|
+
* @throws Will throw an error if the database connection fails or migrations cannot be applied
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { runMigrations } from "@brandtg/flapjack";
|
|
29
|
+
*
|
|
30
|
+
* // Basic usage
|
|
31
|
+
* await runMigrations({
|
|
32
|
+
* databaseUrl: process.env.DATABASE_URL,
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // With custom migrations table
|
|
36
|
+
* await runMigrations({
|
|
37
|
+
* databaseUrl: process.env.DATABASE_URL,
|
|
38
|
+
* migrationsTable: "my_migrations",
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* // With custom schema
|
|
42
|
+
* await runMigrations({
|
|
43
|
+
* databaseUrl: process.env.DATABASE_URL,
|
|
44
|
+
* schema: "feature_flags",
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function runMigrations(options: MigrationOptions): Promise<void>;
|
|
49
|
+
//# sourceMappingURL=migrate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB5E"}
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runner } from "node-pg-migrate";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
/**
|
|
7
|
+
* Run Flapjack database migrations.
|
|
8
|
+
*
|
|
9
|
+
* This function applies all pending migrations to create or update the
|
|
10
|
+
* flapjack_feature_flag table in your PostgreSQL database.
|
|
11
|
+
*
|
|
12
|
+
* @param options - Migration configuration options
|
|
13
|
+
* @param options.databaseUrl - PostgreSQL connection URL
|
|
14
|
+
* @param options.migrationsTable - Name of the migrations tracking table (default: 'pgmigrations')
|
|
15
|
+
* @param options.schema - Schema name to create tables in (optional)
|
|
16
|
+
* @returns Promise that resolves when migrations are complete
|
|
17
|
+
*
|
|
18
|
+
* @throws Will throw an error if the database connection fails or migrations cannot be applied
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { runMigrations } from "@brandtg/flapjack";
|
|
23
|
+
*
|
|
24
|
+
* // Basic usage
|
|
25
|
+
* await runMigrations({
|
|
26
|
+
* databaseUrl: process.env.DATABASE_URL,
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* // With custom migrations table
|
|
30
|
+
* await runMigrations({
|
|
31
|
+
* databaseUrl: process.env.DATABASE_URL,
|
|
32
|
+
* migrationsTable: "my_migrations",
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // With custom schema
|
|
36
|
+
* await runMigrations({
|
|
37
|
+
* databaseUrl: process.env.DATABASE_URL,
|
|
38
|
+
* schema: "feature_flags",
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export async function runMigrations(options) {
|
|
43
|
+
const { databaseUrl, migrationsTable = "pgmigrations", schema } = options;
|
|
44
|
+
// Path to migrations directory (relative to dist in production)
|
|
45
|
+
const migrationsDir = path.resolve(__dirname, "../migrations");
|
|
46
|
+
const migrationConfig = {
|
|
47
|
+
databaseUrl,
|
|
48
|
+
dir: migrationsDir,
|
|
49
|
+
direction: "up",
|
|
50
|
+
migrationsTable,
|
|
51
|
+
...(schema && { schema }),
|
|
52
|
+
verbose: false,
|
|
53
|
+
};
|
|
54
|
+
await runner(migrationConfig);
|
|
55
|
+
}
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { FeatureFlag } from "./types.js";
|
|
2
|
+
import type { QueryResult } from "pg";
|
|
3
|
+
interface Queryable {
|
|
4
|
+
query: (text: string, params?: any[]) => Promise<QueryResult>;
|
|
5
|
+
}
|
|
6
|
+
type CreateInput = Omit<FeatureFlag, "id" | "created" | "modified">;
|
|
7
|
+
type UpdateChanges = Partial<Omit<FeatureFlag, "id" | "created" | "modified">>;
|
|
8
|
+
/**
|
|
9
|
+
* Model for managing feature flags stored in PostgreSQL.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { Pool } from "pg";
|
|
14
|
+
* import { FeatureFlagModel } from "@brandtg/flapjack";
|
|
15
|
+
*
|
|
16
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
17
|
+
* const featureFlags = new FeatureFlagModel(pool);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare class FeatureFlagModel {
|
|
21
|
+
private db;
|
|
22
|
+
/**
|
|
23
|
+
* Creates a new FeatureFlagModel instance.
|
|
24
|
+
*
|
|
25
|
+
* @param db - A PostgreSQL Pool or Client instance that implements the Queryable interface
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
30
|
+
* const model = new FeatureFlagModel(pool);
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
constructor(db: Queryable);
|
|
34
|
+
/**
|
|
35
|
+
* Creates a new feature flag in the database.
|
|
36
|
+
*
|
|
37
|
+
* @param input - Feature flag configuration
|
|
38
|
+
* @param input.name - Unique name for the feature flag (required)
|
|
39
|
+
* @param input.everyone - Optional boolean to enable/disable for everyone (overrides all other settings)
|
|
40
|
+
* @param input.percent - Optional percentage rollout (0-99.9)
|
|
41
|
+
* @param input.roles - Optional list of roles that have this flag enabled
|
|
42
|
+
* @param input.groups - Optional list of user groups that have this flag enabled
|
|
43
|
+
* @param input.users - Optional list of specific user IDs that have this flag enabled
|
|
44
|
+
* @param input.note - Optional description of the flag's purpose
|
|
45
|
+
* @returns The created feature flag with generated id, created, and modified timestamps
|
|
46
|
+
*
|
|
47
|
+
* @throws Will throw an error if a flag with the same name already exists
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* const flag = await model.create({
|
|
52
|
+
* name: "new_checkout_flow",
|
|
53
|
+
* roles: ["admin"],
|
|
54
|
+
* note: "New checkout redesign",
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
create(input: CreateInput): Promise<FeatureFlag>;
|
|
59
|
+
/**
|
|
60
|
+
* Retrieves a feature flag by its ID.
|
|
61
|
+
*
|
|
62
|
+
* @param id - The unique identifier of the feature flag
|
|
63
|
+
* @returns The feature flag if found, null otherwise
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const flag = await model.getById(123);
|
|
68
|
+
* if (flag) {
|
|
69
|
+
* console.log(`Flag ${flag.name} is configured`);
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
getById(id: number): Promise<FeatureFlag | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Retrieves a feature flag by its name.
|
|
76
|
+
*
|
|
77
|
+
* @param name - The unique name of the feature flag
|
|
78
|
+
* @returns The feature flag if found, null otherwise
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* const flag = await model.getByName("new_checkout_flow");
|
|
83
|
+
* if (flag) {
|
|
84
|
+
* console.log(`Flag is ${flag.everyone ? 'enabled' : 'disabled'} for everyone`);
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
getByName(name: string): Promise<FeatureFlag | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Retrieves all feature flags, ordered by ID.
|
|
91
|
+
*
|
|
92
|
+
* @returns Array of all feature flags in the database
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* const flags = await model.list();
|
|
97
|
+
* console.log(`Total flags: ${flags.length}`);
|
|
98
|
+
* flags.forEach(flag => {
|
|
99
|
+
* console.log(`${flag.name}: ${flag.everyone ?? 'conditional'}`);
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
list(): Promise<FeatureFlag[]>;
|
|
104
|
+
/**
|
|
105
|
+
* Updates an existing feature flag.
|
|
106
|
+
*
|
|
107
|
+
* @param id - The unique identifier of the feature flag to update
|
|
108
|
+
* @param changes - Object containing the fields to update
|
|
109
|
+
* @returns The updated feature flag if found, null otherwise
|
|
110
|
+
*
|
|
111
|
+
* @remarks
|
|
112
|
+
* The modified timestamp is automatically updated by a database trigger.
|
|
113
|
+
* Pass `null` for a field to clear it (e.g., `everyone: null` removes the override).
|
|
114
|
+
* If no changes are provided, returns the flag unchanged.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* // Gradually increase rollout percentage
|
|
119
|
+
* await model.update(flagId, { percent: 25 });
|
|
120
|
+
*
|
|
121
|
+
* // Enable for everyone
|
|
122
|
+
* await model.update(flagId, { everyone: true });
|
|
123
|
+
*
|
|
124
|
+
* // Remove everyone override, reverting to other rules
|
|
125
|
+
* await model.update(flagId, { everyone: null });
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
update(id: number, changes: UpdateChanges): Promise<FeatureFlag | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Deletes a feature flag from the database.
|
|
131
|
+
*
|
|
132
|
+
* @param id - The unique identifier of the feature flag to delete
|
|
133
|
+
* @returns true if the flag was deleted, false if not found
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* const deleted = await model.delete(flagId);
|
|
138
|
+
* if (deleted) {
|
|
139
|
+
* console.log("Flag successfully removed");
|
|
140
|
+
* }
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
delete(id: number): Promise<boolean>;
|
|
144
|
+
/**
|
|
145
|
+
* Checks if a user belongs to any of the specified groups
|
|
146
|
+
*/
|
|
147
|
+
private isActiveForGroups;
|
|
148
|
+
/**
|
|
149
|
+
* Computes the hash value for a user ID using MurmurHash3.
|
|
150
|
+
*
|
|
151
|
+
* @param userId - The user ID to hash
|
|
152
|
+
* @returns The hash value used for percentage bucketing
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* This method is useful for debugging percentage rollouts.
|
|
156
|
+
* The hash value is consistent for the same user ID.
|
|
157
|
+
* The bucket is computed as `hash % 100`.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* const hash = await model.hashUserId("user_123");
|
|
162
|
+
* const bucket = hash % 100;
|
|
163
|
+
* console.log(`User bucket: ${bucket}`);
|
|
164
|
+
* // If bucket is 42 and percent is 50, user is in the rollout
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
hashUserId(userId: string): Promise<number>;
|
|
168
|
+
/**
|
|
169
|
+
* Checks if a feature flag is active for a user based on configured rules.
|
|
170
|
+
*
|
|
171
|
+
* @param params - Parameters for flag evaluation
|
|
172
|
+
* @param params.name - The name of the feature flag to check
|
|
173
|
+
* @param params.user - Optional user ID
|
|
174
|
+
* @param params.roles - Optional list of roles the user has
|
|
175
|
+
* @param params.groups - Optional list of groups the user belongs to
|
|
176
|
+
* @returns true if the flag is active for the user, false otherwise
|
|
177
|
+
*
|
|
178
|
+
* @remarks
|
|
179
|
+
* Evaluation order (first match wins):
|
|
180
|
+
* 1. Everyone override (if set to true/false, returns immediately)
|
|
181
|
+
* 2. User ID is in the users list
|
|
182
|
+
* 3. User belongs to any group in the groups list
|
|
183
|
+
* 4. User has any role in the roles list
|
|
184
|
+
* 5. User falls within the percentage rollout (based on consistent hashing)
|
|
185
|
+
* 6. Default: returns false
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```typescript
|
|
189
|
+
* // Check for admin user
|
|
190
|
+
* const isActive = await model.isActiveForUser({
|
|
191
|
+
* name: "new_feature",
|
|
192
|
+
* user: "user_123",
|
|
193
|
+
* roles: ["admin"],
|
|
194
|
+
* groups: ["beta_testers"],
|
|
195
|
+
* });
|
|
196
|
+
*
|
|
197
|
+
* if (isActive) {
|
|
198
|
+
* // Show new feature
|
|
199
|
+
* } else {
|
|
200
|
+
* // Show old feature
|
|
201
|
+
* }
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
isActiveForUser({ name, user, roles, groups, }: {
|
|
205
|
+
name: string;
|
|
206
|
+
user?: string;
|
|
207
|
+
roles?: string[];
|
|
208
|
+
groups?: string[];
|
|
209
|
+
}): Promise<boolean>;
|
|
210
|
+
}
|
|
211
|
+
export {};
|
|
212
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,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;AAiBD,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;AAyB/E;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,EAAE,CAAY;IAEtB;;;;;;;;;;OAUG;gBACS,EAAE,EAAE,SAAS;IAIzB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACG,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAmCtD;;;;;;;;;;;;;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;IA6C9B;;;;;;;;;;;;;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;CA4CrB"}
|