167 行
5.1 KiB
JavaScript
167 行
5.1 KiB
JavaScript
export const FEATURE_FLAG_APPLY_DECISION = Object.freeze({
|
|
CONTINUE_POST: "CONTINUE_POST",
|
|
STOP_NOT_APPLIED: "STOP_NOT_APPLIED",
|
|
STOP_UNKNOWN: "STOP_UNKNOWN",
|
|
STOP_FOR_MANUAL_DECISION: "STOP_FOR_MANUAL_DECISION",
|
|
});
|
|
|
|
function normalizeTargets(targets) {
|
|
if (!Array.isArray(targets) || targets.length === 0) {
|
|
throw new TypeError("targets must be a non-empty array");
|
|
}
|
|
|
|
const keys = targets.map((target) => String(target?.flagKey ?? "").trim());
|
|
if (keys.some((key) => !key) || new Set(keys).size !== keys.length) {
|
|
throw new TypeError("targets must contain unique non-empty flagKey values");
|
|
}
|
|
|
|
return targets.map((target, index) => {
|
|
const preVersion = Number(target.preVersion);
|
|
if (!Number.isInteger(preVersion) || preVersion < 1) {
|
|
throw new TypeError(`invalid preVersion for ${keys[index]}`);
|
|
}
|
|
|
|
const preOverrideMode = String(target.preOverrideMode ?? "").trim();
|
|
const postOverrideMode = String(target.postOverrideMode ?? "").trim();
|
|
if (!preOverrideMode || !postOverrideMode) {
|
|
throw new TypeError(`missing override mode for ${keys[index]}`);
|
|
}
|
|
|
|
return Object.freeze({
|
|
flagKey: keys[index],
|
|
preVersion,
|
|
preOverrideMode,
|
|
postVersion: preVersion + 1,
|
|
postOverrideMode,
|
|
});
|
|
});
|
|
}
|
|
|
|
function normalizeReadback(readback) {
|
|
if (!Array.isArray(readback)) {
|
|
throw new TypeError("readback must be an array");
|
|
}
|
|
|
|
return new Map(
|
|
readback.map((item) => [
|
|
String(item?.flagKey ?? "").trim(),
|
|
{
|
|
version: Number(item?.version),
|
|
overrideMode: String(item?.overrideMode ?? "").trim(),
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
function matchesState(actual, version, overrideMode) {
|
|
return actual?.version === version && actual?.overrideMode === overrideMode;
|
|
}
|
|
|
|
export function classifyFeatureFlagApplyReadback({ targets, readback }) {
|
|
const normalizedTargets = normalizeTargets(targets);
|
|
const actualByKey = normalizeReadback(readback);
|
|
const observations = normalizedTargets.map((target) => {
|
|
const actual = actualByKey.get(target.flagKey);
|
|
if (matchesState(actual, target.postVersion, target.postOverrideMode)) {
|
|
return { flagKey: target.flagKey, state: "POST" };
|
|
}
|
|
if (matchesState(actual, target.preVersion, target.preOverrideMode)) {
|
|
return { flagKey: target.flagKey, state: "PRE" };
|
|
}
|
|
return { flagKey: target.flagKey, state: "OTHER" };
|
|
});
|
|
|
|
if (observations.every((item) => item.state === "POST")) {
|
|
return Object.freeze({
|
|
decision: FEATURE_FLAG_APPLY_DECISION.CONTINUE_POST,
|
|
classification: "APPLIED",
|
|
observations: Object.freeze(observations),
|
|
});
|
|
}
|
|
|
|
if (observations.every((item) => item.state === "PRE")) {
|
|
return Object.freeze({
|
|
decision: FEATURE_FLAG_APPLY_DECISION.STOP_NOT_APPLIED,
|
|
classification: "NOT_APPLIED",
|
|
observations: Object.freeze(observations),
|
|
});
|
|
}
|
|
|
|
return Object.freeze({
|
|
decision: FEATURE_FLAG_APPLY_DECISION.STOP_FOR_MANUAL_DECISION,
|
|
classification: "PARTIAL_OR_UNEXPECTED",
|
|
observations: Object.freeze(observations),
|
|
});
|
|
}
|
|
|
|
export async function resolveFeatureFlagApplyUncertainty({ targets, readCurrent }) {
|
|
if (typeof readCurrent !== "function") {
|
|
throw new TypeError("readCurrent must be a function");
|
|
}
|
|
|
|
try {
|
|
const readback = await readCurrent();
|
|
return classifyFeatureFlagApplyReadback({ targets, readback });
|
|
} catch (error) {
|
|
return Object.freeze({
|
|
decision: FEATURE_FLAG_APPLY_DECISION.STOP_UNKNOWN,
|
|
classification: "READBACK_FAILED",
|
|
errorCode: error instanceof Error && error.message ? error.message : "readback_failed",
|
|
observations: Object.freeze([]),
|
|
});
|
|
}
|
|
}
|
|
|
|
export function createSingleAttemptMutation(sendMutation) {
|
|
if (typeof sendMutation !== "function") {
|
|
throw new TypeError("sendMutation must be a function");
|
|
}
|
|
|
|
let attemptCount = 0;
|
|
return Object.freeze({
|
|
get attemptCount() {
|
|
return attemptCount;
|
|
},
|
|
async execute(payload) {
|
|
if (attemptCount !== 0) {
|
|
throw new Error("mutation_attempt_already_consumed");
|
|
}
|
|
attemptCount += 1;
|
|
return sendMutation(payload);
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function executeFeatureFlagApplyAttempt({
|
|
targets,
|
|
payload,
|
|
sendMutation,
|
|
readCurrent,
|
|
observedRequestCount = null,
|
|
}) {
|
|
const mutation = createSingleAttemptMutation(sendMutation);
|
|
let response = null;
|
|
let responseError = null;
|
|
|
|
try {
|
|
response = await mutation.execute(payload);
|
|
} catch (error) {
|
|
responseError = error instanceof Error && error.message ? error.message : "mutation_result_unknown";
|
|
}
|
|
|
|
const resolution = await resolveFeatureFlagApplyUncertainty({ targets, readCurrent });
|
|
const normalizedObservedCount = Number.isInteger(observedRequestCount) && observedRequestCount >= 0
|
|
? observedRequestCount
|
|
: null;
|
|
|
|
return Object.freeze({
|
|
...resolution,
|
|
sendAttemptCount: mutation.attemptCount,
|
|
responseKnown: responseError === null,
|
|
response,
|
|
responseErrorCode: responseError,
|
|
observedRequestCount: normalizedObservedCount,
|
|
observerMismatch: normalizedObservedCount !== null && normalizedObservedCount !== mutation.attemptCount,
|
|
});
|
|
}
|