@contractspec/lib.feature-flags 1.57.0 → 1.58.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/dist/browser/contracts/index.js +636 -0
- package/dist/browser/docs/feature-flags.docblock.js +71 -0
- package/dist/browser/docs/index.js +71 -0
- package/dist/browser/entities/index.js +306 -0
- package/dist/browser/evaluation/index.js +223 -0
- package/dist/browser/events.js +296 -0
- package/dist/browser/feature-flags.capability.js +28 -0
- package/dist/browser/feature-flags.feature.js +55 -0
- package/dist/browser/index.js +1583 -0
- package/dist/contracts/index.d.ts +944 -950
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +635 -906
- package/dist/docs/feature-flags.docblock.d.ts +2 -1
- package/dist/docs/feature-flags.docblock.d.ts.map +1 -0
- package/dist/docs/feature-flags.docblock.js +18 -22
- package/dist/docs/index.d.ts +2 -1
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +72 -1
- package/dist/entities/index.d.ts +159 -164
- package/dist/entities/index.d.ts.map +1 -1
- package/dist/entities/index.js +297 -315
- package/dist/evaluation/index.d.ts +119 -122
- package/dist/evaluation/index.d.ts.map +1 -1
- package/dist/evaluation/index.js +215 -212
- package/dist/events.d.ts +480 -486
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +272 -511
- package/dist/feature-flags.capability.d.ts +2 -7
- package/dist/feature-flags.capability.d.ts.map +1 -1
- package/dist/feature-flags.capability.js +29 -25
- package/dist/feature-flags.feature.d.ts +1 -6
- package/dist/feature-flags.feature.d.ts.map +1 -1
- package/dist/feature-flags.feature.js +54 -146
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1584 -8
- package/dist/node/contracts/index.js +636 -0
- package/dist/node/docs/feature-flags.docblock.js +71 -0
- package/dist/node/docs/index.js +71 -0
- package/dist/node/entities/index.js +306 -0
- package/dist/node/evaluation/index.js +223 -0
- package/dist/node/events.js +296 -0
- package/dist/node/feature-flags.capability.js +28 -0
- package/dist/node/feature-flags.feature.js +55 -0
- package/dist/node/index.js +1583 -0
- package/package.json +117 -30
- package/dist/contracts/index.js.map +0 -1
- package/dist/docs/feature-flags.docblock.js.map +0 -1
- package/dist/entities/index.js.map +0 -1
- package/dist/evaluation/index.js.map +0 -1
- package/dist/events.js.map +0 -1
- package/dist/feature-flags.capability.js.map +0 -1
- package/dist/feature-flags.feature.js.map +0 -1
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
// src/contracts/index.ts
|
|
2
|
+
import { ScalarTypeEnum, defineSchemaModel } from "@contractspec/lib.schema";
|
|
3
|
+
import { defineCommand, defineQuery } from "@contractspec/lib.contracts";
|
|
4
|
+
var OWNERS = ["platform.feature-flags"];
|
|
5
|
+
var FeatureFlagModel = defineSchemaModel({
|
|
6
|
+
name: "FeatureFlag",
|
|
7
|
+
description: "Represents a feature flag",
|
|
8
|
+
fields: {
|
|
9
|
+
id: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
10
|
+
key: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
11
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
12
|
+
description: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
13
|
+
status: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
14
|
+
defaultValue: { type: ScalarTypeEnum.Boolean(), isOptional: false },
|
|
15
|
+
variants: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
16
|
+
orgId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
17
|
+
tags: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
18
|
+
createdAt: { type: ScalarTypeEnum.DateTime(), isOptional: false },
|
|
19
|
+
updatedAt: { type: ScalarTypeEnum.DateTime(), isOptional: false }
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
var TargetingRuleModel = defineSchemaModel({
|
|
23
|
+
name: "TargetingRule",
|
|
24
|
+
description: "Represents a targeting rule",
|
|
25
|
+
fields: {
|
|
26
|
+
id: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
27
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
28
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
29
|
+
priority: { type: ScalarTypeEnum.Int_unsecure(), isOptional: false },
|
|
30
|
+
enabled: { type: ScalarTypeEnum.Boolean(), isOptional: false },
|
|
31
|
+
attribute: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
32
|
+
operator: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
33
|
+
value: { type: ScalarTypeEnum.JSON(), isOptional: false },
|
|
34
|
+
rolloutPercentage: {
|
|
35
|
+
type: ScalarTypeEnum.Int_unsecure(),
|
|
36
|
+
isOptional: true
|
|
37
|
+
},
|
|
38
|
+
serveValue: { type: ScalarTypeEnum.Boolean(), isOptional: true },
|
|
39
|
+
serveVariant: { type: ScalarTypeEnum.String_unsecure(), isOptional: true }
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
var ExperimentModel = defineSchemaModel({
|
|
43
|
+
name: "Experiment",
|
|
44
|
+
description: "Represents an experiment",
|
|
45
|
+
fields: {
|
|
46
|
+
id: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
47
|
+
key: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
48
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
49
|
+
description: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
50
|
+
hypothesis: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
51
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
52
|
+
status: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
53
|
+
variants: { type: ScalarTypeEnum.JSON(), isOptional: false },
|
|
54
|
+
metrics: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
55
|
+
audiencePercentage: {
|
|
56
|
+
type: ScalarTypeEnum.Int_unsecure(),
|
|
57
|
+
isOptional: false
|
|
58
|
+
},
|
|
59
|
+
startedAt: { type: ScalarTypeEnum.DateTime(), isOptional: true },
|
|
60
|
+
endedAt: { type: ScalarTypeEnum.DateTime(), isOptional: true },
|
|
61
|
+
winningVariant: {
|
|
62
|
+
type: ScalarTypeEnum.String_unsecure(),
|
|
63
|
+
isOptional: true
|
|
64
|
+
},
|
|
65
|
+
results: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
66
|
+
createdAt: { type: ScalarTypeEnum.DateTime(), isOptional: false }
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
var EvaluationResultModel = defineSchemaModel({
|
|
70
|
+
name: "EvaluationResult",
|
|
71
|
+
description: "Result of flag evaluation",
|
|
72
|
+
fields: {
|
|
73
|
+
enabled: { type: ScalarTypeEnum.Boolean(), isOptional: false },
|
|
74
|
+
variant: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
75
|
+
reason: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
76
|
+
ruleId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
77
|
+
experimentId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true }
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
var CreateFlagInput = defineSchemaModel({
|
|
81
|
+
name: "CreateFlagInput",
|
|
82
|
+
description: "Input for creating a feature flag",
|
|
83
|
+
fields: {
|
|
84
|
+
key: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
85
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
86
|
+
description: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
87
|
+
defaultValue: { type: ScalarTypeEnum.Boolean(), isOptional: true },
|
|
88
|
+
variants: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
89
|
+
orgId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
90
|
+
tags: { type: ScalarTypeEnum.JSON(), isOptional: true }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
var UpdateFlagInput = defineSchemaModel({
|
|
94
|
+
name: "UpdateFlagInput",
|
|
95
|
+
description: "Input for updating a feature flag",
|
|
96
|
+
fields: {
|
|
97
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
98
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
99
|
+
description: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
100
|
+
defaultValue: { type: ScalarTypeEnum.Boolean(), isOptional: true },
|
|
101
|
+
variants: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
102
|
+
tags: { type: ScalarTypeEnum.JSON(), isOptional: true }
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
var DeleteFlagInput = defineSchemaModel({
|
|
106
|
+
name: "DeleteFlagInput",
|
|
107
|
+
description: "Input for deleting a feature flag",
|
|
108
|
+
fields: {
|
|
109
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false }
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
var ToggleFlagInput = defineSchemaModel({
|
|
113
|
+
name: "ToggleFlagInput",
|
|
114
|
+
description: "Input for toggling a feature flag",
|
|
115
|
+
fields: {
|
|
116
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
117
|
+
status: { type: ScalarTypeEnum.String_unsecure(), isOptional: false }
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
var GetFlagInput = defineSchemaModel({
|
|
121
|
+
name: "GetFlagInput",
|
|
122
|
+
description: "Input for getting a feature flag",
|
|
123
|
+
fields: {
|
|
124
|
+
key: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
125
|
+
orgId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true }
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
var ListFlagsInput = defineSchemaModel({
|
|
129
|
+
name: "ListFlagsInput",
|
|
130
|
+
description: "Input for listing feature flags",
|
|
131
|
+
fields: {
|
|
132
|
+
orgId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
133
|
+
status: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
134
|
+
tags: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
135
|
+
limit: { type: ScalarTypeEnum.Int_unsecure(), isOptional: true },
|
|
136
|
+
offset: { type: ScalarTypeEnum.Int_unsecure(), isOptional: true }
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
var ListFlagsOutput = defineSchemaModel({
|
|
140
|
+
name: "ListFlagsOutput",
|
|
141
|
+
description: "Output for listing feature flags",
|
|
142
|
+
fields: {
|
|
143
|
+
flags: { type: FeatureFlagModel, isArray: true, isOptional: false },
|
|
144
|
+
total: { type: ScalarTypeEnum.Int_unsecure(), isOptional: false }
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
var EvaluateFlagInput = defineSchemaModel({
|
|
148
|
+
name: "EvaluateFlagInput",
|
|
149
|
+
description: "Input for evaluating a feature flag",
|
|
150
|
+
fields: {
|
|
151
|
+
key: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
152
|
+
context: { type: ScalarTypeEnum.JSON(), isOptional: false }
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
var CreateRuleInput = defineSchemaModel({
|
|
156
|
+
name: "CreateRuleInput",
|
|
157
|
+
description: "Input for creating a targeting rule",
|
|
158
|
+
fields: {
|
|
159
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
160
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
161
|
+
priority: { type: ScalarTypeEnum.Int_unsecure(), isOptional: true },
|
|
162
|
+
attribute: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
163
|
+
operator: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
164
|
+
value: { type: ScalarTypeEnum.JSON(), isOptional: false },
|
|
165
|
+
rolloutPercentage: {
|
|
166
|
+
type: ScalarTypeEnum.Int_unsecure(),
|
|
167
|
+
isOptional: true
|
|
168
|
+
},
|
|
169
|
+
serveValue: { type: ScalarTypeEnum.Boolean(), isOptional: true },
|
|
170
|
+
serveVariant: { type: ScalarTypeEnum.String_unsecure(), isOptional: true }
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
var DeleteRuleInput = defineSchemaModel({
|
|
174
|
+
name: "DeleteRuleInput",
|
|
175
|
+
description: "Input for deleting a targeting rule",
|
|
176
|
+
fields: {
|
|
177
|
+
ruleId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false }
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
var CreateExperimentInput = defineSchemaModel({
|
|
181
|
+
name: "CreateExperimentInput",
|
|
182
|
+
description: "Input for creating an experiment",
|
|
183
|
+
fields: {
|
|
184
|
+
key: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
185
|
+
name: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
186
|
+
description: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
187
|
+
hypothesis: { type: ScalarTypeEnum.String_unsecure(), isOptional: true },
|
|
188
|
+
flagId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
189
|
+
variants: { type: ScalarTypeEnum.JSON(), isOptional: false },
|
|
190
|
+
metrics: { type: ScalarTypeEnum.JSON(), isOptional: true },
|
|
191
|
+
audiencePercentage: {
|
|
192
|
+
type: ScalarTypeEnum.Int_unsecure(),
|
|
193
|
+
isOptional: true
|
|
194
|
+
},
|
|
195
|
+
scheduledStartAt: { type: ScalarTypeEnum.DateTime(), isOptional: true },
|
|
196
|
+
scheduledEndAt: { type: ScalarTypeEnum.DateTime(), isOptional: true },
|
|
197
|
+
orgId: { type: ScalarTypeEnum.String_unsecure(), isOptional: true }
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
var StartExperimentInput = defineSchemaModel({
|
|
201
|
+
name: "StartExperimentInput",
|
|
202
|
+
description: "Input for starting an experiment",
|
|
203
|
+
fields: {
|
|
204
|
+
experimentId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false }
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
var StopExperimentInput = defineSchemaModel({
|
|
208
|
+
name: "StopExperimentInput",
|
|
209
|
+
description: "Input for stopping an experiment",
|
|
210
|
+
fields: {
|
|
211
|
+
experimentId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
212
|
+
reason: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
213
|
+
winningVariant: {
|
|
214
|
+
type: ScalarTypeEnum.String_unsecure(),
|
|
215
|
+
isOptional: true
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
var GetExperimentInput = defineSchemaModel({
|
|
220
|
+
name: "GetExperimentInput",
|
|
221
|
+
description: "Input for getting an experiment",
|
|
222
|
+
fields: {
|
|
223
|
+
experimentId: { type: ScalarTypeEnum.String_unsecure(), isOptional: false }
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
var SuccessOutput = defineSchemaModel({
|
|
227
|
+
name: "SuccessOutput",
|
|
228
|
+
description: "Generic success output",
|
|
229
|
+
fields: {
|
|
230
|
+
success: { type: ScalarTypeEnum.Boolean(), isOptional: false }
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
var CreateFlagContract = defineCommand({
|
|
234
|
+
meta: {
|
|
235
|
+
key: "flag.create",
|
|
236
|
+
version: "1.0.0",
|
|
237
|
+
stability: "stable",
|
|
238
|
+
owners: [...OWNERS],
|
|
239
|
+
tags: ["feature-flags", "create"],
|
|
240
|
+
description: "Create a new feature flag.",
|
|
241
|
+
goal: "Define a new feature flag for toggling features.",
|
|
242
|
+
context: "Called when setting up a new feature flag."
|
|
243
|
+
},
|
|
244
|
+
io: {
|
|
245
|
+
input: CreateFlagInput,
|
|
246
|
+
output: FeatureFlagModel,
|
|
247
|
+
errors: {
|
|
248
|
+
KEY_ALREADY_EXISTS: {
|
|
249
|
+
description: "Flag key already exists",
|
|
250
|
+
http: 409,
|
|
251
|
+
gqlCode: "FLAG_KEY_EXISTS",
|
|
252
|
+
when: "A flag with this key already exists"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
policy: {
|
|
257
|
+
auth: "admin"
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
var UpdateFlagContract = defineCommand({
|
|
261
|
+
meta: {
|
|
262
|
+
key: "flag.update",
|
|
263
|
+
version: "1.0.0",
|
|
264
|
+
stability: "stable",
|
|
265
|
+
owners: [...OWNERS],
|
|
266
|
+
tags: ["feature-flags", "update"],
|
|
267
|
+
description: "Update an existing feature flag.",
|
|
268
|
+
goal: "Modify flag configuration.",
|
|
269
|
+
context: "Called when adjusting flag settings."
|
|
270
|
+
},
|
|
271
|
+
io: {
|
|
272
|
+
input: UpdateFlagInput,
|
|
273
|
+
output: FeatureFlagModel,
|
|
274
|
+
errors: {
|
|
275
|
+
FLAG_NOT_FOUND: {
|
|
276
|
+
description: "Flag does not exist",
|
|
277
|
+
http: 404,
|
|
278
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
279
|
+
when: "Flag ID is invalid"
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
policy: {
|
|
284
|
+
auth: "admin"
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
var DeleteFlagContract = defineCommand({
|
|
288
|
+
meta: {
|
|
289
|
+
key: "flag.delete",
|
|
290
|
+
version: "1.0.0",
|
|
291
|
+
stability: "stable",
|
|
292
|
+
owners: [...OWNERS],
|
|
293
|
+
tags: ["feature-flags", "delete"],
|
|
294
|
+
description: "Delete a feature flag.",
|
|
295
|
+
goal: "Remove a feature flag and all its rules.",
|
|
296
|
+
context: "Called when a flag is no longer needed."
|
|
297
|
+
},
|
|
298
|
+
io: {
|
|
299
|
+
input: DeleteFlagInput,
|
|
300
|
+
output: SuccessOutput,
|
|
301
|
+
errors: {
|
|
302
|
+
FLAG_NOT_FOUND: {
|
|
303
|
+
description: "Flag does not exist",
|
|
304
|
+
http: 404,
|
|
305
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
306
|
+
when: "Flag ID is invalid"
|
|
307
|
+
},
|
|
308
|
+
FLAG_HAS_ACTIVE_EXPERIMENT: {
|
|
309
|
+
description: "Flag has an active experiment",
|
|
310
|
+
http: 409,
|
|
311
|
+
gqlCode: "FLAG_HAS_ACTIVE_EXPERIMENT",
|
|
312
|
+
when: "Cannot delete flag with running experiment"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
policy: {
|
|
317
|
+
auth: "admin"
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
var ToggleFlagContract = defineCommand({
|
|
321
|
+
meta: {
|
|
322
|
+
key: "flag.toggle",
|
|
323
|
+
version: "1.0.0",
|
|
324
|
+
stability: "stable",
|
|
325
|
+
owners: [...OWNERS],
|
|
326
|
+
tags: ["feature-flags", "toggle"],
|
|
327
|
+
description: "Toggle a feature flag status.",
|
|
328
|
+
goal: "Quickly enable or disable a feature.",
|
|
329
|
+
context: "Called when turning a feature on or off."
|
|
330
|
+
},
|
|
331
|
+
io: {
|
|
332
|
+
input: ToggleFlagInput,
|
|
333
|
+
output: FeatureFlagModel,
|
|
334
|
+
errors: {
|
|
335
|
+
FLAG_NOT_FOUND: {
|
|
336
|
+
description: "Flag does not exist",
|
|
337
|
+
http: 404,
|
|
338
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
339
|
+
when: "Flag ID is invalid"
|
|
340
|
+
},
|
|
341
|
+
INVALID_STATUS: {
|
|
342
|
+
description: "Invalid status value",
|
|
343
|
+
http: 400,
|
|
344
|
+
gqlCode: "INVALID_STATUS",
|
|
345
|
+
when: "Status must be OFF, ON, or GRADUAL"
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
policy: {
|
|
350
|
+
auth: "admin"
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
var GetFlagContract = defineQuery({
|
|
354
|
+
meta: {
|
|
355
|
+
key: "flag.get",
|
|
356
|
+
version: "1.0.0",
|
|
357
|
+
stability: "stable",
|
|
358
|
+
owners: [...OWNERS],
|
|
359
|
+
tags: ["feature-flags", "get"],
|
|
360
|
+
description: "Get a feature flag by key.",
|
|
361
|
+
goal: "Retrieve flag configuration.",
|
|
362
|
+
context: "Called to inspect flag details."
|
|
363
|
+
},
|
|
364
|
+
io: {
|
|
365
|
+
input: GetFlagInput,
|
|
366
|
+
output: FeatureFlagModel,
|
|
367
|
+
errors: {
|
|
368
|
+
FLAG_NOT_FOUND: {
|
|
369
|
+
description: "Flag does not exist",
|
|
370
|
+
http: 404,
|
|
371
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
372
|
+
when: "Flag key is invalid"
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
policy: {
|
|
377
|
+
auth: "user"
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
var ListFlagsContract = defineQuery({
|
|
381
|
+
meta: {
|
|
382
|
+
key: "flag.list",
|
|
383
|
+
version: "1.0.0",
|
|
384
|
+
stability: "stable",
|
|
385
|
+
owners: [...OWNERS],
|
|
386
|
+
tags: ["feature-flags", "list"],
|
|
387
|
+
description: "List all feature flags.",
|
|
388
|
+
goal: "View all configured flags.",
|
|
389
|
+
context: "Admin dashboard."
|
|
390
|
+
},
|
|
391
|
+
io: {
|
|
392
|
+
input: ListFlagsInput,
|
|
393
|
+
output: ListFlagsOutput
|
|
394
|
+
},
|
|
395
|
+
policy: {
|
|
396
|
+
auth: "admin"
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
var EvaluateFlagContract = defineQuery({
|
|
400
|
+
meta: {
|
|
401
|
+
key: "flag.evaluate",
|
|
402
|
+
version: "1.0.0",
|
|
403
|
+
stability: "stable",
|
|
404
|
+
owners: [...OWNERS],
|
|
405
|
+
tags: ["feature-flags", "evaluate"],
|
|
406
|
+
description: "Evaluate a feature flag for a given context.",
|
|
407
|
+
goal: "Determine if a feature should be enabled.",
|
|
408
|
+
context: "Called at runtime to check feature availability."
|
|
409
|
+
},
|
|
410
|
+
io: {
|
|
411
|
+
input: EvaluateFlagInput,
|
|
412
|
+
output: EvaluationResultModel,
|
|
413
|
+
errors: {
|
|
414
|
+
FLAG_NOT_FOUND: {
|
|
415
|
+
description: "Flag does not exist",
|
|
416
|
+
http: 404,
|
|
417
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
418
|
+
when: "Flag key is invalid"
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
policy: {
|
|
423
|
+
auth: "anonymous"
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
var CreateRuleContract = defineCommand({
|
|
427
|
+
meta: {
|
|
428
|
+
key: "flag.rule.create",
|
|
429
|
+
version: "1.0.0",
|
|
430
|
+
stability: "stable",
|
|
431
|
+
owners: [...OWNERS],
|
|
432
|
+
tags: ["feature-flags", "rule", "create"],
|
|
433
|
+
description: "Create a targeting rule for a flag.",
|
|
434
|
+
goal: "Add conditional targeting to a flag.",
|
|
435
|
+
context: "Called when setting up targeting."
|
|
436
|
+
},
|
|
437
|
+
io: {
|
|
438
|
+
input: CreateRuleInput,
|
|
439
|
+
output: TargetingRuleModel,
|
|
440
|
+
errors: {
|
|
441
|
+
FLAG_NOT_FOUND: {
|
|
442
|
+
description: "Flag does not exist",
|
|
443
|
+
http: 404,
|
|
444
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
445
|
+
when: "Flag ID is invalid"
|
|
446
|
+
},
|
|
447
|
+
INVALID_OPERATOR: {
|
|
448
|
+
description: "Invalid operator",
|
|
449
|
+
http: 400,
|
|
450
|
+
gqlCode: "INVALID_OPERATOR",
|
|
451
|
+
when: "Operator is not supported"
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
policy: {
|
|
456
|
+
auth: "admin"
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
var DeleteRuleContract = defineCommand({
|
|
460
|
+
meta: {
|
|
461
|
+
key: "flag.rule.delete",
|
|
462
|
+
version: "1.0.0",
|
|
463
|
+
stability: "stable",
|
|
464
|
+
owners: [...OWNERS],
|
|
465
|
+
tags: ["feature-flags", "rule", "delete"],
|
|
466
|
+
description: "Delete a targeting rule.",
|
|
467
|
+
goal: "Remove a targeting rule from a flag.",
|
|
468
|
+
context: "Called when removing targeting conditions."
|
|
469
|
+
},
|
|
470
|
+
io: {
|
|
471
|
+
input: DeleteRuleInput,
|
|
472
|
+
output: SuccessOutput,
|
|
473
|
+
errors: {
|
|
474
|
+
RULE_NOT_FOUND: {
|
|
475
|
+
description: "Rule does not exist",
|
|
476
|
+
http: 404,
|
|
477
|
+
gqlCode: "RULE_NOT_FOUND",
|
|
478
|
+
when: "Rule ID is invalid"
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
policy: {
|
|
483
|
+
auth: "admin"
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
var CreateExperimentContract = defineCommand({
|
|
487
|
+
meta: {
|
|
488
|
+
key: "experiment.create",
|
|
489
|
+
version: "1.0.0",
|
|
490
|
+
stability: "stable",
|
|
491
|
+
owners: [...OWNERS],
|
|
492
|
+
tags: ["feature-flags", "experiment", "create"],
|
|
493
|
+
description: "Create an A/B test experiment.",
|
|
494
|
+
goal: "Set up an experiment with variants.",
|
|
495
|
+
context: "Called when setting up A/B testing."
|
|
496
|
+
},
|
|
497
|
+
io: {
|
|
498
|
+
input: CreateExperimentInput,
|
|
499
|
+
output: ExperimentModel,
|
|
500
|
+
errors: {
|
|
501
|
+
FLAG_NOT_FOUND: {
|
|
502
|
+
description: "Flag does not exist",
|
|
503
|
+
http: 404,
|
|
504
|
+
gqlCode: "FLAG_NOT_FOUND",
|
|
505
|
+
when: "Flag ID is invalid"
|
|
506
|
+
},
|
|
507
|
+
EXPERIMENT_KEY_EXISTS: {
|
|
508
|
+
description: "Experiment key already exists",
|
|
509
|
+
http: 409,
|
|
510
|
+
gqlCode: "EXPERIMENT_KEY_EXISTS",
|
|
511
|
+
when: "An experiment with this key already exists"
|
|
512
|
+
},
|
|
513
|
+
INVALID_VARIANTS: {
|
|
514
|
+
description: "Invalid variant configuration",
|
|
515
|
+
http: 400,
|
|
516
|
+
gqlCode: "INVALID_VARIANTS",
|
|
517
|
+
when: "Variant percentages must sum to 100"
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
policy: {
|
|
522
|
+
auth: "admin"
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
var StartExperimentContract = defineCommand({
|
|
526
|
+
meta: {
|
|
527
|
+
key: "experiment.start",
|
|
528
|
+
version: "1.0.0",
|
|
529
|
+
stability: "stable",
|
|
530
|
+
owners: [...OWNERS],
|
|
531
|
+
tags: ["feature-flags", "experiment", "start"],
|
|
532
|
+
description: "Start an experiment.",
|
|
533
|
+
goal: "Begin collecting data for an experiment.",
|
|
534
|
+
context: "Called when ready to run an A/B test."
|
|
535
|
+
},
|
|
536
|
+
io: {
|
|
537
|
+
input: StartExperimentInput,
|
|
538
|
+
output: ExperimentModel,
|
|
539
|
+
errors: {
|
|
540
|
+
EXPERIMENT_NOT_FOUND: {
|
|
541
|
+
description: "Experiment does not exist",
|
|
542
|
+
http: 404,
|
|
543
|
+
gqlCode: "EXPERIMENT_NOT_FOUND",
|
|
544
|
+
when: "Experiment ID is invalid"
|
|
545
|
+
},
|
|
546
|
+
EXPERIMENT_ALREADY_RUNNING: {
|
|
547
|
+
description: "Experiment is already running",
|
|
548
|
+
http: 409,
|
|
549
|
+
gqlCode: "EXPERIMENT_ALREADY_RUNNING",
|
|
550
|
+
when: "Cannot start an experiment that is already running"
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
policy: {
|
|
555
|
+
auth: "admin"
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
var StopExperimentContract = defineCommand({
|
|
559
|
+
meta: {
|
|
560
|
+
key: "experiment.stop",
|
|
561
|
+
version: "1.0.0",
|
|
562
|
+
stability: "stable",
|
|
563
|
+
owners: [...OWNERS],
|
|
564
|
+
tags: ["feature-flags", "experiment", "stop"],
|
|
565
|
+
description: "Stop an experiment.",
|
|
566
|
+
goal: "End an experiment and optionally declare a winner.",
|
|
567
|
+
context: "Called when concluding an A/B test."
|
|
568
|
+
},
|
|
569
|
+
io: {
|
|
570
|
+
input: StopExperimentInput,
|
|
571
|
+
output: ExperimentModel,
|
|
572
|
+
errors: {
|
|
573
|
+
EXPERIMENT_NOT_FOUND: {
|
|
574
|
+
description: "Experiment does not exist",
|
|
575
|
+
http: 404,
|
|
576
|
+
gqlCode: "EXPERIMENT_NOT_FOUND",
|
|
577
|
+
when: "Experiment ID is invalid"
|
|
578
|
+
},
|
|
579
|
+
EXPERIMENT_NOT_RUNNING: {
|
|
580
|
+
description: "Experiment is not running",
|
|
581
|
+
http: 409,
|
|
582
|
+
gqlCode: "EXPERIMENT_NOT_RUNNING",
|
|
583
|
+
when: "Cannot stop an experiment that is not running"
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
policy: {
|
|
588
|
+
auth: "admin"
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
var GetExperimentContract = defineQuery({
|
|
592
|
+
meta: {
|
|
593
|
+
key: "experiment.get",
|
|
594
|
+
version: "1.0.0",
|
|
595
|
+
stability: "stable",
|
|
596
|
+
owners: [...OWNERS],
|
|
597
|
+
tags: ["feature-flags", "experiment", "get"],
|
|
598
|
+
description: "Get experiment details.",
|
|
599
|
+
goal: "View experiment configuration and results.",
|
|
600
|
+
context: "Called to inspect experiment status."
|
|
601
|
+
},
|
|
602
|
+
io: {
|
|
603
|
+
input: GetExperimentInput,
|
|
604
|
+
output: ExperimentModel,
|
|
605
|
+
errors: {
|
|
606
|
+
EXPERIMENT_NOT_FOUND: {
|
|
607
|
+
description: "Experiment does not exist",
|
|
608
|
+
http: 404,
|
|
609
|
+
gqlCode: "EXPERIMENT_NOT_FOUND",
|
|
610
|
+
when: "Experiment ID is invalid"
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
policy: {
|
|
615
|
+
auth: "user"
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// src/docs/feature-flags.docblock.ts
|
|
620
|
+
import { registerDocBlocks } from "@contractspec/lib.contracts/docs";
|
|
621
|
+
var featureFlagsDocBlocks = [
|
|
622
|
+
{
|
|
623
|
+
id: "docs.feature-flags.overview",
|
|
624
|
+
title: "Feature Flags & Experiments",
|
|
625
|
+
summary: "Reusable, spec-first feature flag and experiment module with targeting, gradual rollout, multivariate variants, and evaluation logging.",
|
|
626
|
+
kind: "reference",
|
|
627
|
+
visibility: "public",
|
|
628
|
+
route: "/docs/feature-flags/overview",
|
|
629
|
+
tags: ["feature-flags", "experiments", "progressive-delivery"],
|
|
630
|
+
body: `## What this module provides
|
|
631
|
+
|
|
632
|
+
- **Entities**: FeatureFlag, FlagTargetingRule, Experiment, ExperimentAssignment, FlagEvaluation.
|
|
633
|
+
- **Contracts**: create/update/delete/toggle/list/get flags; create/delete rules; evaluate flags; create/start/stop/get experiments.
|
|
634
|
+
- **Events**: flag.created/updated/deleted/toggled, rule.created/deleted, experiment.created/started/stopped, flag.evaluated, experiment.variant_assigned.
|
|
635
|
+
- **Evaluation Engine**: Deterministic evaluator with gradual rollout, rule priority, audience filters, and experiment bucketing.
|
|
636
|
+
|
|
637
|
+
## How to use
|
|
638
|
+
|
|
639
|
+
1) Compose schema
|
|
640
|
+
- Add \`featureFlagsSchemaContribution\` to your module composition.
|
|
641
|
+
|
|
642
|
+
2) Register contracts/events
|
|
643
|
+
- Import exports from \`@contractspec/lib.feature-flags\` into your spec registry.
|
|
644
|
+
|
|
645
|
+
3) Evaluate at runtime
|
|
646
|
+
- Instantiate \`FlagEvaluator\` with a repository implementation and optional logger.
|
|
647
|
+
- Evaluate with context attributes (userId, orgId, plan, segment, sessionId, attributes).
|
|
648
|
+
|
|
649
|
+
4) Wire observability
|
|
650
|
+
- Emit audit trail on config changes; emit \`flag.evaluated\` for analytics.
|
|
651
|
+
|
|
652
|
+
## Usage example
|
|
653
|
+
|
|
654
|
+
${"```"}ts
|
|
655
|
+
import {
|
|
656
|
+
FlagEvaluator,
|
|
657
|
+
InMemoryFlagRepository,
|
|
658
|
+
} from '@contractspec/lib.feature-flags';
|
|
659
|
+
|
|
660
|
+
const repo = new InMemoryFlagRepository();
|
|
661
|
+
repo.addFlag({
|
|
662
|
+
id: 'flag-1',
|
|
663
|
+
key: 'new_dashboard',
|
|
664
|
+
status: 'GRADUAL',
|
|
665
|
+
defaultValue: false,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const evaluator = new FlagEvaluator({ repository: repo });
|
|
669
|
+
const result = await evaluator.evaluate('new_dashboard', {
|
|
670
|
+
userId: 'user-123',
|
|
671
|
+
orgId: 'org-456',
|
|
672
|
+
plan: 'pro',
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
if (result.enabled) {
|
|
676
|
+
// serve the new dashboard
|
|
677
|
+
}
|
|
678
|
+
${"```"},
|
|
679
|
+
|
|
680
|
+
## Guardrails
|
|
681
|
+
|
|
682
|
+
- Keep flag keys stable and human-readable; avoid PII in context.
|
|
683
|
+
- Ensure experiments’ variant percentages sum to 100; default flag status to OFF.
|
|
684
|
+
- Use org-scoped flags for multi-tenant isolation.
|
|
685
|
+
- Log evaluations only when needed to control volume; prefer sampling for noisy paths.
|
|
686
|
+
`
|
|
687
|
+
}
|
|
688
|
+
];
|
|
689
|
+
registerDocBlocks(featureFlagsDocBlocks);
|
|
690
|
+
// src/entities/index.ts
|
|
691
|
+
import {
|
|
692
|
+
defineEntity,
|
|
693
|
+
defineEntityEnum,
|
|
694
|
+
field,
|
|
695
|
+
index
|
|
696
|
+
} from "@contractspec/lib.schema";
|
|
697
|
+
var FlagStatusEnum = defineEntityEnum({
|
|
698
|
+
name: "FlagStatus",
|
|
699
|
+
values: ["OFF", "ON", "GRADUAL"],
|
|
700
|
+
schema: "lssm_feature_flags",
|
|
701
|
+
description: "Status of a feature flag."
|
|
702
|
+
});
|
|
703
|
+
var RuleOperatorEnum = defineEntityEnum({
|
|
704
|
+
name: "RuleOperator",
|
|
705
|
+
values: [
|
|
706
|
+
"EQ",
|
|
707
|
+
"NEQ",
|
|
708
|
+
"IN",
|
|
709
|
+
"NIN",
|
|
710
|
+
"CONTAINS",
|
|
711
|
+
"NOT_CONTAINS",
|
|
712
|
+
"GT",
|
|
713
|
+
"GTE",
|
|
714
|
+
"LT",
|
|
715
|
+
"LTE",
|
|
716
|
+
"PERCENTAGE"
|
|
717
|
+
],
|
|
718
|
+
schema: "lssm_feature_flags",
|
|
719
|
+
description: "Operator for targeting rule conditions."
|
|
720
|
+
});
|
|
721
|
+
var ExperimentStatusEnum = defineEntityEnum({
|
|
722
|
+
name: "ExperimentStatus",
|
|
723
|
+
values: ["DRAFT", "RUNNING", "PAUSED", "COMPLETED", "CANCELLED"],
|
|
724
|
+
schema: "lssm_feature_flags",
|
|
725
|
+
description: "Status of an experiment."
|
|
726
|
+
});
|
|
727
|
+
var FeatureFlagEntity = defineEntity({
|
|
728
|
+
name: "FeatureFlag",
|
|
729
|
+
description: "A feature flag for controlling feature availability.",
|
|
730
|
+
schema: "lssm_feature_flags",
|
|
731
|
+
map: "feature_flag",
|
|
732
|
+
fields: {
|
|
733
|
+
id: field.id({ description: "Unique flag identifier" }),
|
|
734
|
+
key: field.string({
|
|
735
|
+
isUnique: true,
|
|
736
|
+
description: "Flag key (e.g., new_dashboard)"
|
|
737
|
+
}),
|
|
738
|
+
name: field.string({ description: "Human-readable name" }),
|
|
739
|
+
description: field.string({
|
|
740
|
+
isOptional: true,
|
|
741
|
+
description: "Description of the flag"
|
|
742
|
+
}),
|
|
743
|
+
status: field.enum("FlagStatus", {
|
|
744
|
+
default: "OFF",
|
|
745
|
+
description: "Flag status"
|
|
746
|
+
}),
|
|
747
|
+
defaultValue: field.boolean({
|
|
748
|
+
default: false,
|
|
749
|
+
description: "Default value when no rules match"
|
|
750
|
+
}),
|
|
751
|
+
variants: field.json({
|
|
752
|
+
isOptional: true,
|
|
753
|
+
description: "Variant definitions for multivariate flags"
|
|
754
|
+
}),
|
|
755
|
+
orgId: field.string({
|
|
756
|
+
isOptional: true,
|
|
757
|
+
description: "Organization scope (null = global)"
|
|
758
|
+
}),
|
|
759
|
+
tags: field.json({
|
|
760
|
+
isOptional: true,
|
|
761
|
+
description: "Tags for categorization"
|
|
762
|
+
}),
|
|
763
|
+
metadata: field.json({
|
|
764
|
+
isOptional: true,
|
|
765
|
+
description: "Additional metadata"
|
|
766
|
+
}),
|
|
767
|
+
createdAt: field.createdAt(),
|
|
768
|
+
updatedAt: field.updatedAt(),
|
|
769
|
+
targetingRules: field.hasMany("FlagTargetingRule"),
|
|
770
|
+
experiments: field.hasMany("Experiment"),
|
|
771
|
+
evaluations: field.hasMany("FlagEvaluation")
|
|
772
|
+
},
|
|
773
|
+
indexes: [index.on(["orgId", "key"]), index.on(["status"])],
|
|
774
|
+
enums: [FlagStatusEnum]
|
|
775
|
+
});
|
|
776
|
+
var FlagTargetingRuleEntity = defineEntity({
|
|
777
|
+
name: "FlagTargetingRule",
|
|
778
|
+
description: "A targeting rule for conditional flag evaluation.",
|
|
779
|
+
schema: "lssm_feature_flags",
|
|
780
|
+
map: "flag_targeting_rule",
|
|
781
|
+
fields: {
|
|
782
|
+
id: field.id({ description: "Unique rule identifier" }),
|
|
783
|
+
flagId: field.foreignKey({ description: "Parent feature flag" }),
|
|
784
|
+
name: field.string({
|
|
785
|
+
isOptional: true,
|
|
786
|
+
description: "Rule name for debugging"
|
|
787
|
+
}),
|
|
788
|
+
priority: field.int({
|
|
789
|
+
default: 0,
|
|
790
|
+
description: "Rule priority (lower = higher priority)"
|
|
791
|
+
}),
|
|
792
|
+
enabled: field.boolean({
|
|
793
|
+
default: true,
|
|
794
|
+
description: "Whether rule is active"
|
|
795
|
+
}),
|
|
796
|
+
attribute: field.string({
|
|
797
|
+
description: "Target attribute (userId, orgId, plan, segment, etc.)"
|
|
798
|
+
}),
|
|
799
|
+
operator: field.enum("RuleOperator", {
|
|
800
|
+
description: "Comparison operator"
|
|
801
|
+
}),
|
|
802
|
+
value: field.json({ description: "Target value(s)" }),
|
|
803
|
+
rolloutPercentage: field.int({
|
|
804
|
+
isOptional: true,
|
|
805
|
+
description: "Percentage for gradual rollout (0-100)"
|
|
806
|
+
}),
|
|
807
|
+
serveValue: field.boolean({
|
|
808
|
+
isOptional: true,
|
|
809
|
+
description: "Boolean value to serve"
|
|
810
|
+
}),
|
|
811
|
+
serveVariant: field.string({
|
|
812
|
+
isOptional: true,
|
|
813
|
+
description: "Variant key to serve (for multivariate)"
|
|
814
|
+
}),
|
|
815
|
+
createdAt: field.createdAt(),
|
|
816
|
+
updatedAt: field.updatedAt(),
|
|
817
|
+
flag: field.belongsTo("FeatureFlag", ["flagId"], ["id"], {
|
|
818
|
+
onDelete: "Cascade"
|
|
819
|
+
})
|
|
820
|
+
},
|
|
821
|
+
indexes: [index.on(["flagId", "priority"]), index.on(["attribute"])],
|
|
822
|
+
enums: [RuleOperatorEnum]
|
|
823
|
+
});
|
|
824
|
+
var ExperimentEntity = defineEntity({
|
|
825
|
+
name: "Experiment",
|
|
826
|
+
description: "An A/B test experiment.",
|
|
827
|
+
schema: "lssm_feature_flags",
|
|
828
|
+
map: "experiment",
|
|
829
|
+
fields: {
|
|
830
|
+
id: field.id({ description: "Unique experiment identifier" }),
|
|
831
|
+
key: field.string({ isUnique: true, description: "Experiment key" }),
|
|
832
|
+
name: field.string({ description: "Human-readable name" }),
|
|
833
|
+
description: field.string({
|
|
834
|
+
isOptional: true,
|
|
835
|
+
description: "Experiment description"
|
|
836
|
+
}),
|
|
837
|
+
hypothesis: field.string({
|
|
838
|
+
isOptional: true,
|
|
839
|
+
description: "Experiment hypothesis"
|
|
840
|
+
}),
|
|
841
|
+
flagId: field.foreignKey({ description: "Associated feature flag" }),
|
|
842
|
+
status: field.enum("ExperimentStatus", {
|
|
843
|
+
default: "DRAFT",
|
|
844
|
+
description: "Experiment status"
|
|
845
|
+
}),
|
|
846
|
+
variants: field.json({
|
|
847
|
+
description: "Variant definitions with split ratios"
|
|
848
|
+
}),
|
|
849
|
+
metrics: field.json({ isOptional: true, description: "Metrics to track" }),
|
|
850
|
+
audiencePercentage: field.int({
|
|
851
|
+
default: 100,
|
|
852
|
+
description: "Percentage of audience to include"
|
|
853
|
+
}),
|
|
854
|
+
audienceFilter: field.json({
|
|
855
|
+
isOptional: true,
|
|
856
|
+
description: "Audience filter criteria"
|
|
857
|
+
}),
|
|
858
|
+
scheduledStartAt: field.dateTime({
|
|
859
|
+
isOptional: true,
|
|
860
|
+
description: "Scheduled start time"
|
|
861
|
+
}),
|
|
862
|
+
scheduledEndAt: field.dateTime({
|
|
863
|
+
isOptional: true,
|
|
864
|
+
description: "Scheduled end time"
|
|
865
|
+
}),
|
|
866
|
+
startedAt: field.dateTime({
|
|
867
|
+
isOptional: true,
|
|
868
|
+
description: "Actual start time"
|
|
869
|
+
}),
|
|
870
|
+
endedAt: field.dateTime({
|
|
871
|
+
isOptional: true,
|
|
872
|
+
description: "Actual end time"
|
|
873
|
+
}),
|
|
874
|
+
winningVariant: field.string({
|
|
875
|
+
isOptional: true,
|
|
876
|
+
description: "Declared winning variant"
|
|
877
|
+
}),
|
|
878
|
+
results: field.json({
|
|
879
|
+
isOptional: true,
|
|
880
|
+
description: "Experiment results summary"
|
|
881
|
+
}),
|
|
882
|
+
orgId: field.string({
|
|
883
|
+
isOptional: true,
|
|
884
|
+
description: "Organization scope"
|
|
885
|
+
}),
|
|
886
|
+
createdAt: field.createdAt(),
|
|
887
|
+
updatedAt: field.updatedAt(),
|
|
888
|
+
flag: field.belongsTo("FeatureFlag", ["flagId"], ["id"], {
|
|
889
|
+
onDelete: "Cascade"
|
|
890
|
+
}),
|
|
891
|
+
assignments: field.hasMany("ExperimentAssignment")
|
|
892
|
+
},
|
|
893
|
+
indexes: [
|
|
894
|
+
index.on(["status"]),
|
|
895
|
+
index.on(["orgId", "status"]),
|
|
896
|
+
index.on(["flagId"])
|
|
897
|
+
],
|
|
898
|
+
enums: [ExperimentStatusEnum]
|
|
899
|
+
});
|
|
900
|
+
var ExperimentAssignmentEntity = defineEntity({
|
|
901
|
+
name: "ExperimentAssignment",
|
|
902
|
+
description: "Tracks experiment variant assignments.",
|
|
903
|
+
schema: "lssm_feature_flags",
|
|
904
|
+
map: "experiment_assignment",
|
|
905
|
+
fields: {
|
|
906
|
+
id: field.id({ description: "Unique assignment identifier" }),
|
|
907
|
+
experimentId: field.foreignKey({ description: "Parent experiment" }),
|
|
908
|
+
subjectType: field.string({
|
|
909
|
+
description: "Subject type (user, org, session)"
|
|
910
|
+
}),
|
|
911
|
+
subjectId: field.string({ description: "Subject identifier" }),
|
|
912
|
+
variant: field.string({ description: "Assigned variant key" }),
|
|
913
|
+
bucket: field.int({ description: "Hash bucket (0-99)" }),
|
|
914
|
+
context: field.json({
|
|
915
|
+
isOptional: true,
|
|
916
|
+
description: "Context at assignment time"
|
|
917
|
+
}),
|
|
918
|
+
assignedAt: field.dateTime({ description: "Assignment timestamp" }),
|
|
919
|
+
experiment: field.belongsTo("Experiment", ["experimentId"], ["id"], {
|
|
920
|
+
onDelete: "Cascade"
|
|
921
|
+
})
|
|
922
|
+
},
|
|
923
|
+
indexes: [
|
|
924
|
+
index.unique(["experimentId", "subjectType", "subjectId"], {
|
|
925
|
+
name: "experiment_assignment_unique"
|
|
926
|
+
}),
|
|
927
|
+
index.on(["subjectType", "subjectId"])
|
|
928
|
+
]
|
|
929
|
+
});
|
|
930
|
+
var FlagEvaluationEntity = defineEntity({
|
|
931
|
+
name: "FlagEvaluation",
|
|
932
|
+
description: "Log of flag evaluations for debugging and analytics.",
|
|
933
|
+
schema: "lssm_feature_flags",
|
|
934
|
+
map: "flag_evaluation",
|
|
935
|
+
fields: {
|
|
936
|
+
id: field.id({ description: "Unique evaluation identifier" }),
|
|
937
|
+
flagId: field.foreignKey({ description: "Evaluated flag" }),
|
|
938
|
+
flagKey: field.string({
|
|
939
|
+
description: "Flag key (denormalized for queries)"
|
|
940
|
+
}),
|
|
941
|
+
subjectType: field.string({
|
|
942
|
+
description: "Subject type (user, org, anonymous)"
|
|
943
|
+
}),
|
|
944
|
+
subjectId: field.string({ description: "Subject identifier" }),
|
|
945
|
+
result: field.boolean({ description: "Evaluation result" }),
|
|
946
|
+
variant: field.string({
|
|
947
|
+
isOptional: true,
|
|
948
|
+
description: "Served variant (for multivariate)"
|
|
949
|
+
}),
|
|
950
|
+
matchedRuleId: field.string({
|
|
951
|
+
isOptional: true,
|
|
952
|
+
description: "Rule that matched (if any)"
|
|
953
|
+
}),
|
|
954
|
+
reason: field.string({
|
|
955
|
+
description: "Evaluation reason (default, rule, experiment, etc.)"
|
|
956
|
+
}),
|
|
957
|
+
context: field.json({
|
|
958
|
+
isOptional: true,
|
|
959
|
+
description: "Evaluation context"
|
|
960
|
+
}),
|
|
961
|
+
evaluatedAt: field.dateTime({ description: "Evaluation timestamp" }),
|
|
962
|
+
flag: field.belongsTo("FeatureFlag", ["flagId"], ["id"], {
|
|
963
|
+
onDelete: "Cascade"
|
|
964
|
+
})
|
|
965
|
+
},
|
|
966
|
+
indexes: [
|
|
967
|
+
index.on(["flagKey", "evaluatedAt"]),
|
|
968
|
+
index.on(["subjectType", "subjectId", "evaluatedAt"]),
|
|
969
|
+
index.on(["flagId", "evaluatedAt"])
|
|
970
|
+
]
|
|
971
|
+
});
|
|
972
|
+
var featureFlagEntities = [
|
|
973
|
+
FeatureFlagEntity,
|
|
974
|
+
FlagTargetingRuleEntity,
|
|
975
|
+
ExperimentEntity,
|
|
976
|
+
ExperimentAssignmentEntity,
|
|
977
|
+
FlagEvaluationEntity
|
|
978
|
+
];
|
|
979
|
+
var featureFlagsSchemaContribution = {
|
|
980
|
+
moduleId: "@contractspec/lib.feature-flags",
|
|
981
|
+
entities: featureFlagEntities,
|
|
982
|
+
enums: [FlagStatusEnum, RuleOperatorEnum, ExperimentStatusEnum]
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
// src/evaluation/index.ts
|
|
986
|
+
function hashToBucket(value, seed = "") {
|
|
987
|
+
const input = `${seed}:${value}`;
|
|
988
|
+
let hash = 0;
|
|
989
|
+
for (let i = 0;i < input.length; i++) {
|
|
990
|
+
const char = input.charCodeAt(i);
|
|
991
|
+
hash = (hash << 5) - hash + char;
|
|
992
|
+
hash = hash & hash;
|
|
993
|
+
}
|
|
994
|
+
return Math.abs(hash % 100);
|
|
995
|
+
}
|
|
996
|
+
function getSubjectId(context) {
|
|
997
|
+
return context.userId || context.sessionId || context.orgId || "anonymous";
|
|
998
|
+
}
|
|
999
|
+
function evaluateRuleCondition(rule, context) {
|
|
1000
|
+
const attributeValue = getAttributeValue(rule.attribute, context);
|
|
1001
|
+
switch (rule.operator) {
|
|
1002
|
+
case "EQ":
|
|
1003
|
+
return attributeValue === rule.value;
|
|
1004
|
+
case "NEQ":
|
|
1005
|
+
return attributeValue !== rule.value;
|
|
1006
|
+
case "IN":
|
|
1007
|
+
if (!Array.isArray(rule.value))
|
|
1008
|
+
return false;
|
|
1009
|
+
return rule.value.includes(attributeValue);
|
|
1010
|
+
case "NIN":
|
|
1011
|
+
if (!Array.isArray(rule.value))
|
|
1012
|
+
return true;
|
|
1013
|
+
return !rule.value.includes(attributeValue);
|
|
1014
|
+
case "CONTAINS":
|
|
1015
|
+
if (typeof attributeValue !== "string" || typeof rule.value !== "string")
|
|
1016
|
+
return false;
|
|
1017
|
+
return attributeValue.includes(rule.value);
|
|
1018
|
+
case "NOT_CONTAINS":
|
|
1019
|
+
if (typeof attributeValue !== "string" || typeof rule.value !== "string")
|
|
1020
|
+
return true;
|
|
1021
|
+
return !attributeValue.includes(rule.value);
|
|
1022
|
+
case "GT":
|
|
1023
|
+
if (typeof attributeValue !== "number" || typeof rule.value !== "number")
|
|
1024
|
+
return false;
|
|
1025
|
+
return attributeValue > rule.value;
|
|
1026
|
+
case "GTE":
|
|
1027
|
+
if (typeof attributeValue !== "number" || typeof rule.value !== "number")
|
|
1028
|
+
return false;
|
|
1029
|
+
return attributeValue >= rule.value;
|
|
1030
|
+
case "LT":
|
|
1031
|
+
if (typeof attributeValue !== "number" || typeof rule.value !== "number")
|
|
1032
|
+
return false;
|
|
1033
|
+
return attributeValue < rule.value;
|
|
1034
|
+
case "LTE":
|
|
1035
|
+
if (typeof attributeValue !== "number" || typeof rule.value !== "number")
|
|
1036
|
+
return false;
|
|
1037
|
+
return attributeValue <= rule.value;
|
|
1038
|
+
case "PERCENTAGE":
|
|
1039
|
+
return hashToBucket(getSubjectId(context), rule.attribute) < (typeof rule.value === "number" ? rule.value : 0);
|
|
1040
|
+
default:
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
function getAttributeValue(attribute, context) {
|
|
1045
|
+
switch (attribute) {
|
|
1046
|
+
case "userId":
|
|
1047
|
+
return context.userId;
|
|
1048
|
+
case "orgId":
|
|
1049
|
+
return context.orgId;
|
|
1050
|
+
case "plan":
|
|
1051
|
+
return context.plan;
|
|
1052
|
+
case "segment":
|
|
1053
|
+
return context.segment;
|
|
1054
|
+
case "sessionId":
|
|
1055
|
+
return context.sessionId;
|
|
1056
|
+
default:
|
|
1057
|
+
return context.attributes?.[attribute];
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
class FlagEvaluator {
|
|
1062
|
+
repository;
|
|
1063
|
+
logger;
|
|
1064
|
+
logEvaluations;
|
|
1065
|
+
constructor(options) {
|
|
1066
|
+
this.repository = options.repository;
|
|
1067
|
+
this.logger = options.logger;
|
|
1068
|
+
this.logEvaluations = options.logEvaluations ?? false;
|
|
1069
|
+
}
|
|
1070
|
+
async evaluate(key, context) {
|
|
1071
|
+
const orgId = context.orgId;
|
|
1072
|
+
const flag = await this.repository.getFlag(key, orgId);
|
|
1073
|
+
if (!flag) {
|
|
1074
|
+
return this.makeResult(false, "FLAG_NOT_FOUND");
|
|
1075
|
+
}
|
|
1076
|
+
if (flag.status === "OFF") {
|
|
1077
|
+
return this.logAndReturn(flag, context, this.makeResult(false, "FLAG_OFF"));
|
|
1078
|
+
}
|
|
1079
|
+
if (flag.status === "ON") {
|
|
1080
|
+
return this.logAndReturn(flag, context, this.makeResult(true, "FLAG_ON"));
|
|
1081
|
+
}
|
|
1082
|
+
const rules = await this.repository.getRules(flag.id);
|
|
1083
|
+
const sortedRules = [...rules].filter((r) => r.enabled).sort((a, b) => a.priority - b.priority);
|
|
1084
|
+
for (const rule of sortedRules) {
|
|
1085
|
+
if (evaluateRuleCondition(rule, context)) {
|
|
1086
|
+
if (rule.rolloutPercentage !== undefined && rule.rolloutPercentage !== null) {
|
|
1087
|
+
const bucket = hashToBucket(getSubjectId(context), flag.key);
|
|
1088
|
+
if (bucket >= rule.rolloutPercentage) {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const enabled = rule.serveValue ?? true;
|
|
1093
|
+
return this.logAndReturn(flag, context, this.makeResult(enabled, "RULE_MATCH", rule.serveVariant, rule.id));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const experiment = await this.repository.getActiveExperiment(flag.id);
|
|
1097
|
+
if (experiment && experiment.status === "RUNNING") {
|
|
1098
|
+
const result = await this.evaluateExperiment(experiment, context);
|
|
1099
|
+
if (result) {
|
|
1100
|
+
return this.logAndReturn(flag, context, result);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return this.logAndReturn(flag, context, this.makeResult(flag.defaultValue, "DEFAULT_VALUE"));
|
|
1104
|
+
}
|
|
1105
|
+
async evaluateExperiment(experiment, context) {
|
|
1106
|
+
const subjectId = getSubjectId(context);
|
|
1107
|
+
const subjectType = context.userId ? "user" : context.orgId ? "org" : "session";
|
|
1108
|
+
const audienceBucket = hashToBucket(subjectId, `${experiment.key}:audience`);
|
|
1109
|
+
if (audienceBucket >= experiment.audiencePercentage) {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
let variant = await this.repository.getExperimentAssignment(experiment.id, subjectType, subjectId);
|
|
1113
|
+
if (!variant) {
|
|
1114
|
+
const variantBucket = hashToBucket(subjectId, experiment.key);
|
|
1115
|
+
variant = this.assignVariant(experiment.variants, variantBucket);
|
|
1116
|
+
await this.repository.saveExperimentAssignment(experiment.id, subjectType, subjectId, variant, variantBucket);
|
|
1117
|
+
}
|
|
1118
|
+
const enabled = variant !== "control";
|
|
1119
|
+
return this.makeResult(enabled, "EXPERIMENT_VARIANT", variant, undefined, experiment.id);
|
|
1120
|
+
}
|
|
1121
|
+
assignVariant(variants, bucket) {
|
|
1122
|
+
let cumulative = 0;
|
|
1123
|
+
for (const variant of variants) {
|
|
1124
|
+
cumulative += variant.percentage;
|
|
1125
|
+
if (bucket < cumulative) {
|
|
1126
|
+
return variant.key;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return variants[variants.length - 1]?.key ?? "control";
|
|
1130
|
+
}
|
|
1131
|
+
makeResult(enabled, reason, variant, ruleId, experimentId) {
|
|
1132
|
+
return {
|
|
1133
|
+
enabled,
|
|
1134
|
+
variant,
|
|
1135
|
+
reason,
|
|
1136
|
+
ruleId,
|
|
1137
|
+
experimentId
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
logAndReturn(flag, context, result) {
|
|
1141
|
+
if (this.logEvaluations && this.logger) {
|
|
1142
|
+
const subjectId = getSubjectId(context);
|
|
1143
|
+
const subjectType = context.userId ? "user" : context.orgId ? "org" : "session";
|
|
1144
|
+
this.logger.log({
|
|
1145
|
+
flagId: flag.id,
|
|
1146
|
+
flagKey: flag.key,
|
|
1147
|
+
subjectType,
|
|
1148
|
+
subjectId,
|
|
1149
|
+
result: result.enabled,
|
|
1150
|
+
variant: result.variant,
|
|
1151
|
+
reason: result.reason,
|
|
1152
|
+
ruleId: result.ruleId,
|
|
1153
|
+
experimentId: result.experimentId,
|
|
1154
|
+
context
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
return result;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
class InMemoryFlagRepository {
|
|
1162
|
+
flags = new Map;
|
|
1163
|
+
rules = new Map;
|
|
1164
|
+
experiments = new Map;
|
|
1165
|
+
assignments = new Map;
|
|
1166
|
+
addFlag(flag) {
|
|
1167
|
+
this.flags.set(flag.key, flag);
|
|
1168
|
+
}
|
|
1169
|
+
addRule(flagId, rule) {
|
|
1170
|
+
const existing = this.rules.get(flagId) || [];
|
|
1171
|
+
existing.push(rule);
|
|
1172
|
+
this.rules.set(flagId, existing);
|
|
1173
|
+
}
|
|
1174
|
+
addExperiment(experiment, flagId) {
|
|
1175
|
+
this.experiments.set(flagId, experiment);
|
|
1176
|
+
}
|
|
1177
|
+
async getFlag(key) {
|
|
1178
|
+
return this.flags.get(key) || null;
|
|
1179
|
+
}
|
|
1180
|
+
async getRules(flagId) {
|
|
1181
|
+
return this.rules.get(flagId) || [];
|
|
1182
|
+
}
|
|
1183
|
+
async getActiveExperiment(flagId) {
|
|
1184
|
+
return this.experiments.get(flagId) || null;
|
|
1185
|
+
}
|
|
1186
|
+
async getExperimentAssignment(experimentId, subjectType, subjectId) {
|
|
1187
|
+
const key = `${experimentId}:${subjectType}:${subjectId}`;
|
|
1188
|
+
return this.assignments.get(key) || null;
|
|
1189
|
+
}
|
|
1190
|
+
async saveExperimentAssignment(experimentId, subjectType, subjectId, variant) {
|
|
1191
|
+
const key = `${experimentId}:${subjectType}:${subjectId}`;
|
|
1192
|
+
this.assignments.set(key, variant);
|
|
1193
|
+
}
|
|
1194
|
+
clear() {
|
|
1195
|
+
this.flags.clear();
|
|
1196
|
+
this.rules.clear();
|
|
1197
|
+
this.experiments.clear();
|
|
1198
|
+
this.assignments.clear();
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/events.ts
|
|
1203
|
+
import { ScalarTypeEnum as ScalarTypeEnum2, defineSchemaModel as defineSchemaModel2 } from "@contractspec/lib.schema";
|
|
1204
|
+
import { defineEvent } from "@contractspec/lib.contracts";
|
|
1205
|
+
var FlagCreatedPayload = defineSchemaModel2({
|
|
1206
|
+
name: "FlagCreatedEventPayload",
|
|
1207
|
+
description: "Payload when a feature flag is created",
|
|
1208
|
+
fields: {
|
|
1209
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1210
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1211
|
+
name: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1212
|
+
status: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1213
|
+
orgId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1214
|
+
createdBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1215
|
+
createdAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
var FlagUpdatedPayload = defineSchemaModel2({
|
|
1219
|
+
name: "FlagUpdatedEventPayload",
|
|
1220
|
+
description: "Payload when a feature flag is updated",
|
|
1221
|
+
fields: {
|
|
1222
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1223
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1224
|
+
changes: { type: ScalarTypeEnum2.JSON(), isOptional: false },
|
|
1225
|
+
updatedBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1226
|
+
updatedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
var FlagDeletedPayload = defineSchemaModel2({
|
|
1230
|
+
name: "FlagDeletedEventPayload",
|
|
1231
|
+
description: "Payload when a feature flag is deleted",
|
|
1232
|
+
fields: {
|
|
1233
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1234
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1235
|
+
deletedBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1236
|
+
deletedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
var FlagToggledPayload = defineSchemaModel2({
|
|
1240
|
+
name: "FlagToggledEventPayload",
|
|
1241
|
+
description: "Payload when a feature flag status is toggled",
|
|
1242
|
+
fields: {
|
|
1243
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1244
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1245
|
+
previousStatus: {
|
|
1246
|
+
type: ScalarTypeEnum2.String_unsecure(),
|
|
1247
|
+
isOptional: false
|
|
1248
|
+
},
|
|
1249
|
+
newStatus: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1250
|
+
toggledBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1251
|
+
toggledAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
var RuleCreatedPayload = defineSchemaModel2({
|
|
1255
|
+
name: "RuleCreatedEventPayload",
|
|
1256
|
+
description: "Payload when a targeting rule is created",
|
|
1257
|
+
fields: {
|
|
1258
|
+
ruleId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1259
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1260
|
+
flagKey: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1261
|
+
attribute: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1262
|
+
operator: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1263
|
+
createdAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
var RuleDeletedPayload = defineSchemaModel2({
|
|
1267
|
+
name: "RuleDeletedEventPayload",
|
|
1268
|
+
description: "Payload when a targeting rule is deleted",
|
|
1269
|
+
fields: {
|
|
1270
|
+
ruleId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1271
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1272
|
+
flagKey: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1273
|
+
deletedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
var ExperimentCreatedPayload = defineSchemaModel2({
|
|
1277
|
+
name: "ExperimentCreatedEventPayload",
|
|
1278
|
+
description: "Payload when an experiment is created",
|
|
1279
|
+
fields: {
|
|
1280
|
+
experimentId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1281
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1282
|
+
name: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1283
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1284
|
+
variants: { type: ScalarTypeEnum2.JSON(), isOptional: false },
|
|
1285
|
+
createdBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1286
|
+
createdAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
var ExperimentStartedPayload = defineSchemaModel2({
|
|
1290
|
+
name: "ExperimentStartedEventPayload",
|
|
1291
|
+
description: "Payload when an experiment starts",
|
|
1292
|
+
fields: {
|
|
1293
|
+
experimentId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1294
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1295
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1296
|
+
variants: { type: ScalarTypeEnum2.JSON(), isOptional: false },
|
|
1297
|
+
audiencePercentage: {
|
|
1298
|
+
type: ScalarTypeEnum2.Int_unsecure(),
|
|
1299
|
+
isOptional: false
|
|
1300
|
+
},
|
|
1301
|
+
startedBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1302
|
+
startedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
var ExperimentStoppedPayload = defineSchemaModel2({
|
|
1306
|
+
name: "ExperimentStoppedEventPayload",
|
|
1307
|
+
description: "Payload when an experiment stops",
|
|
1308
|
+
fields: {
|
|
1309
|
+
experimentId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1310
|
+
key: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1311
|
+
reason: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1312
|
+
winningVariant: {
|
|
1313
|
+
type: ScalarTypeEnum2.String_unsecure(),
|
|
1314
|
+
isOptional: true
|
|
1315
|
+
},
|
|
1316
|
+
stoppedBy: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1317
|
+
stoppedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
var FlagEvaluatedPayload = defineSchemaModel2({
|
|
1321
|
+
name: "FlagEvaluatedEventPayload",
|
|
1322
|
+
description: "Payload when a flag is evaluated (for analytics)",
|
|
1323
|
+
fields: {
|
|
1324
|
+
flagId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1325
|
+
flagKey: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1326
|
+
subjectType: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1327
|
+
subjectId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1328
|
+
result: { type: ScalarTypeEnum2.Boolean(), isOptional: false },
|
|
1329
|
+
variant: { type: ScalarTypeEnum2.String_unsecure(), isOptional: true },
|
|
1330
|
+
reason: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1331
|
+
evaluatedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
var VariantAssignedPayload = defineSchemaModel2({
|
|
1335
|
+
name: "VariantAssignedEventPayload",
|
|
1336
|
+
description: "Payload when a subject is assigned to an experiment variant",
|
|
1337
|
+
fields: {
|
|
1338
|
+
experimentId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1339
|
+
experimentKey: {
|
|
1340
|
+
type: ScalarTypeEnum2.String_unsecure(),
|
|
1341
|
+
isOptional: false
|
|
1342
|
+
},
|
|
1343
|
+
subjectType: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1344
|
+
subjectId: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1345
|
+
variant: { type: ScalarTypeEnum2.String_unsecure(), isOptional: false },
|
|
1346
|
+
bucket: { type: ScalarTypeEnum2.Int_unsecure(), isOptional: false },
|
|
1347
|
+
assignedAt: { type: ScalarTypeEnum2.DateTime(), isOptional: false }
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
var FlagCreatedEvent = defineEvent({
|
|
1351
|
+
meta: {
|
|
1352
|
+
key: "flag.created",
|
|
1353
|
+
version: "1.0.0",
|
|
1354
|
+
description: "A feature flag has been created.",
|
|
1355
|
+
stability: "stable",
|
|
1356
|
+
owners: ["@platform.feature-flags"],
|
|
1357
|
+
tags: ["feature-flags", "create"]
|
|
1358
|
+
},
|
|
1359
|
+
payload: FlagCreatedPayload
|
|
1360
|
+
});
|
|
1361
|
+
var FlagUpdatedEvent = defineEvent({
|
|
1362
|
+
meta: {
|
|
1363
|
+
key: "flag.updated",
|
|
1364
|
+
version: "1.0.0",
|
|
1365
|
+
description: "A feature flag has been updated.",
|
|
1366
|
+
stability: "stable",
|
|
1367
|
+
owners: ["@platform.feature-flags"],
|
|
1368
|
+
tags: ["feature-flags", "update"]
|
|
1369
|
+
},
|
|
1370
|
+
payload: FlagUpdatedPayload
|
|
1371
|
+
});
|
|
1372
|
+
var FlagDeletedEvent = defineEvent({
|
|
1373
|
+
meta: {
|
|
1374
|
+
key: "flag.deleted",
|
|
1375
|
+
version: "1.0.0",
|
|
1376
|
+
description: "A feature flag has been deleted.",
|
|
1377
|
+
stability: "stable",
|
|
1378
|
+
owners: ["@platform.feature-flags"],
|
|
1379
|
+
tags: ["feature-flags", "delete"]
|
|
1380
|
+
},
|
|
1381
|
+
payload: FlagDeletedPayload
|
|
1382
|
+
});
|
|
1383
|
+
var FlagToggledEvent = defineEvent({
|
|
1384
|
+
meta: {
|
|
1385
|
+
key: "flag.toggled",
|
|
1386
|
+
version: "1.0.0",
|
|
1387
|
+
description: "A feature flag status has been toggled.",
|
|
1388
|
+
stability: "stable",
|
|
1389
|
+
owners: ["@platform.feature-flags"],
|
|
1390
|
+
tags: ["feature-flags", "toggle"]
|
|
1391
|
+
},
|
|
1392
|
+
payload: FlagToggledPayload
|
|
1393
|
+
});
|
|
1394
|
+
var RuleCreatedEvent = defineEvent({
|
|
1395
|
+
meta: {
|
|
1396
|
+
key: "flag.rule_created",
|
|
1397
|
+
version: "1.0.0",
|
|
1398
|
+
description: "A targeting rule has been created.",
|
|
1399
|
+
stability: "stable",
|
|
1400
|
+
owners: ["@platform.feature-flags"],
|
|
1401
|
+
tags: ["feature-flags", "rule", "create"]
|
|
1402
|
+
},
|
|
1403
|
+
payload: RuleCreatedPayload
|
|
1404
|
+
});
|
|
1405
|
+
var RuleDeletedEvent = defineEvent({
|
|
1406
|
+
meta: {
|
|
1407
|
+
key: "flag.rule_deleted",
|
|
1408
|
+
version: "1.0.0",
|
|
1409
|
+
description: "A targeting rule has been deleted.",
|
|
1410
|
+
stability: "stable",
|
|
1411
|
+
owners: ["@platform.feature-flags"],
|
|
1412
|
+
tags: ["feature-flags", "rule", "delete"]
|
|
1413
|
+
},
|
|
1414
|
+
payload: RuleDeletedPayload
|
|
1415
|
+
});
|
|
1416
|
+
var ExperimentCreatedEvent = defineEvent({
|
|
1417
|
+
meta: {
|
|
1418
|
+
key: "experiment.created",
|
|
1419
|
+
version: "1.0.0",
|
|
1420
|
+
description: "An experiment has been created.",
|
|
1421
|
+
stability: "stable",
|
|
1422
|
+
owners: ["@platform.feature-flags"],
|
|
1423
|
+
tags: ["feature-flags", "experiment", "create"]
|
|
1424
|
+
},
|
|
1425
|
+
payload: ExperimentCreatedPayload
|
|
1426
|
+
});
|
|
1427
|
+
var ExperimentStartedEvent = defineEvent({
|
|
1428
|
+
meta: {
|
|
1429
|
+
key: "experiment.started",
|
|
1430
|
+
version: "1.0.0",
|
|
1431
|
+
description: "An experiment has started.",
|
|
1432
|
+
stability: "stable",
|
|
1433
|
+
owners: ["@platform.feature-flags"],
|
|
1434
|
+
tags: ["feature-flags", "experiment", "start"]
|
|
1435
|
+
},
|
|
1436
|
+
payload: ExperimentStartedPayload
|
|
1437
|
+
});
|
|
1438
|
+
var ExperimentStoppedEvent = defineEvent({
|
|
1439
|
+
meta: {
|
|
1440
|
+
key: "experiment.stopped",
|
|
1441
|
+
version: "1.0.0",
|
|
1442
|
+
description: "An experiment has stopped.",
|
|
1443
|
+
stability: "stable",
|
|
1444
|
+
owners: ["@platform.feature-flags"],
|
|
1445
|
+
tags: ["feature-flags", "experiment", "stop"]
|
|
1446
|
+
},
|
|
1447
|
+
payload: ExperimentStoppedPayload
|
|
1448
|
+
});
|
|
1449
|
+
var FlagEvaluatedEvent = defineEvent({
|
|
1450
|
+
meta: {
|
|
1451
|
+
key: "flag.evaluated",
|
|
1452
|
+
version: "1.0.0",
|
|
1453
|
+
description: "A feature flag has been evaluated.",
|
|
1454
|
+
stability: "stable",
|
|
1455
|
+
owners: ["@platform.feature-flags"],
|
|
1456
|
+
tags: ["feature-flags", "evaluate"]
|
|
1457
|
+
},
|
|
1458
|
+
payload: FlagEvaluatedPayload
|
|
1459
|
+
});
|
|
1460
|
+
var VariantAssignedEvent = defineEvent({
|
|
1461
|
+
meta: {
|
|
1462
|
+
key: "experiment.variant_assigned",
|
|
1463
|
+
version: "1.0.0",
|
|
1464
|
+
description: "A subject has been assigned to an experiment variant.",
|
|
1465
|
+
stability: "stable",
|
|
1466
|
+
owners: ["@platform.feature-flags"],
|
|
1467
|
+
tags: ["feature-flags", "experiment", "variant"]
|
|
1468
|
+
},
|
|
1469
|
+
payload: VariantAssignedPayload
|
|
1470
|
+
});
|
|
1471
|
+
var FeatureFlagEvents = {
|
|
1472
|
+
FlagCreatedEvent,
|
|
1473
|
+
FlagUpdatedEvent,
|
|
1474
|
+
FlagDeletedEvent,
|
|
1475
|
+
FlagToggledEvent,
|
|
1476
|
+
RuleCreatedEvent,
|
|
1477
|
+
RuleDeletedEvent,
|
|
1478
|
+
ExperimentCreatedEvent,
|
|
1479
|
+
ExperimentStartedEvent,
|
|
1480
|
+
ExperimentStoppedEvent,
|
|
1481
|
+
FlagEvaluatedEvent,
|
|
1482
|
+
VariantAssignedEvent
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
// src/feature-flags.feature.ts
|
|
1486
|
+
import { defineFeature } from "@contractspec/lib.contracts";
|
|
1487
|
+
var FeatureFlagsFeature = defineFeature({
|
|
1488
|
+
meta: {
|
|
1489
|
+
key: "feature-flags",
|
|
1490
|
+
version: "1.0.0",
|
|
1491
|
+
title: "Feature Flags",
|
|
1492
|
+
description: "Feature flag management with targeting rules and A/B experiments",
|
|
1493
|
+
domain: "platform",
|
|
1494
|
+
owners: ["@platform.feature-flags"],
|
|
1495
|
+
tags: ["feature-flags", "experiments", "targeting"],
|
|
1496
|
+
stability: "stable"
|
|
1497
|
+
},
|
|
1498
|
+
operations: [
|
|
1499
|
+
{ key: "flag.create", version: "1.0.0" },
|
|
1500
|
+
{ key: "flag.update", version: "1.0.0" },
|
|
1501
|
+
{ key: "flag.delete", version: "1.0.0" },
|
|
1502
|
+
{ key: "flag.toggle", version: "1.0.0" },
|
|
1503
|
+
{ key: "flag.get", version: "1.0.0" },
|
|
1504
|
+
{ key: "flag.list", version: "1.0.0" },
|
|
1505
|
+
{ key: "flag.evaluate", version: "1.0.0" },
|
|
1506
|
+
{ key: "flag.rule.create", version: "1.0.0" },
|
|
1507
|
+
{ key: "flag.rule.delete", version: "1.0.0" },
|
|
1508
|
+
{ key: "experiment.create", version: "1.0.0" },
|
|
1509
|
+
{ key: "experiment.start", version: "1.0.0" },
|
|
1510
|
+
{ key: "experiment.stop", version: "1.0.0" },
|
|
1511
|
+
{ key: "experiment.get", version: "1.0.0" }
|
|
1512
|
+
],
|
|
1513
|
+
events: [
|
|
1514
|
+
{ key: "flag.created", version: "1.0.0" },
|
|
1515
|
+
{ key: "flag.updated", version: "1.0.0" },
|
|
1516
|
+
{ key: "flag.deleted", version: "1.0.0" },
|
|
1517
|
+
{ key: "flag.toggled", version: "1.0.0" },
|
|
1518
|
+
{ key: "flag.evaluated", version: "1.0.0" },
|
|
1519
|
+
{ key: "flag.rule_created", version: "1.0.0" },
|
|
1520
|
+
{ key: "flag.rule_deleted", version: "1.0.0" },
|
|
1521
|
+
{ key: "experiment.created", version: "1.0.0" },
|
|
1522
|
+
{ key: "experiment.started", version: "1.0.0" },
|
|
1523
|
+
{ key: "experiment.stopped", version: "1.0.0" },
|
|
1524
|
+
{ key: "experiment.variant_assigned", version: "1.0.0" }
|
|
1525
|
+
],
|
|
1526
|
+
presentations: [],
|
|
1527
|
+
opToPresentation: [],
|
|
1528
|
+
presentationsTargets: [],
|
|
1529
|
+
capabilities: {
|
|
1530
|
+
provides: [
|
|
1531
|
+
{ key: "feature-flag", version: "1.0.0" },
|
|
1532
|
+
{ key: "experiments", version: "1.0.0" }
|
|
1533
|
+
],
|
|
1534
|
+
requires: [{ key: "identity", version: "1.0.0" }]
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
export {
|
|
1538
|
+
hashToBucket,
|
|
1539
|
+
getSubjectId,
|
|
1540
|
+
featureFlagsSchemaContribution,
|
|
1541
|
+
featureFlagEntities,
|
|
1542
|
+
evaluateRuleCondition,
|
|
1543
|
+
VariantAssignedEvent,
|
|
1544
|
+
UpdateFlagContract,
|
|
1545
|
+
ToggleFlagContract,
|
|
1546
|
+
TargetingRuleModel,
|
|
1547
|
+
StopExperimentContract,
|
|
1548
|
+
StartExperimentContract,
|
|
1549
|
+
RuleOperatorEnum,
|
|
1550
|
+
RuleDeletedEvent,
|
|
1551
|
+
RuleCreatedEvent,
|
|
1552
|
+
ListFlagsContract,
|
|
1553
|
+
InMemoryFlagRepository,
|
|
1554
|
+
GetFlagContract,
|
|
1555
|
+
GetExperimentContract,
|
|
1556
|
+
FlagUpdatedEvent,
|
|
1557
|
+
FlagToggledEvent,
|
|
1558
|
+
FlagTargetingRuleEntity,
|
|
1559
|
+
FlagStatusEnum,
|
|
1560
|
+
FlagEvaluator,
|
|
1561
|
+
FlagEvaluationEntity,
|
|
1562
|
+
FlagEvaluatedEvent,
|
|
1563
|
+
FlagDeletedEvent,
|
|
1564
|
+
FlagCreatedEvent,
|
|
1565
|
+
FeatureFlagsFeature,
|
|
1566
|
+
FeatureFlagModel,
|
|
1567
|
+
FeatureFlagEvents,
|
|
1568
|
+
FeatureFlagEntity,
|
|
1569
|
+
ExperimentStoppedEvent,
|
|
1570
|
+
ExperimentStatusEnum,
|
|
1571
|
+
ExperimentStartedEvent,
|
|
1572
|
+
ExperimentModel,
|
|
1573
|
+
ExperimentEntity,
|
|
1574
|
+
ExperimentCreatedEvent,
|
|
1575
|
+
ExperimentAssignmentEntity,
|
|
1576
|
+
EvaluationResultModel,
|
|
1577
|
+
EvaluateFlagContract,
|
|
1578
|
+
DeleteRuleContract,
|
|
1579
|
+
DeleteFlagContract,
|
|
1580
|
+
CreateRuleContract,
|
|
1581
|
+
CreateFlagContract,
|
|
1582
|
+
CreateExperimentContract
|
|
1583
|
+
};
|