diff --git a/.changeset/local-flagship-runtime.md b/.changeset/local-flagship-runtime.md new file mode 100644 index 00000000000..365d25cad81 --- /dev/null +++ b/.changeset/local-flagship-runtime.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +--- + +Simulate Flagship bindings locally + +Flagship bindings can now evaluate flags against a persisted local store instead of requiring a remote app. Miniflare also exposes an admin API for populating and managing that store in development tools and tests, while bindings configured for remote access continue to proxy to Flagship. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index c7699efb2b4..6215537ca2d 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -133,6 +133,7 @@ import { SharedHeaders, SiteBindings, } from "./workers"; +import { ADMIN_API as FLAGSHIP_ADMIN_API } from "./workers/flagship/constants"; import { ADMIN_API } from "./workers/secrets-store/constants"; import type { MiniflareOptions, @@ -170,6 +171,9 @@ import type { } from "./shared/dev-control"; import type { WorkerDefinition } from "./shared/dev-registry-types"; import type { Awaitable } from "./workers"; +import type { FlagshipAdmin } from "./workers/flagship/admin"; +import type { EvaluationDetails, FlagValue } from "./workers/flagship/evaluate"; +import type { Flag, FlagInput } from "./workers/flagship/flags"; import type { CacheStorage, D1Database, @@ -3465,6 +3469,17 @@ export class Miniflare { ): Promise { return this.#getProxy(FLAGSHIP_PLUGIN_NAME, bindingName, workerName); } + getFlagshipBindingAPI( + bindingName: string, + workerName?: string + ): Promise<() => FlagshipAdmin> { + return this.#getProxy(FLAGSHIP_PLUGIN_NAME, bindingName, workerName).then( + (binding) => { + // @ts-expect-error We exposed an admin API on this key + return binding[FLAGSHIP_ADMIN_API]; + } + ); + } getStreamBinding( bindingName: string, workerName?: string @@ -3625,6 +3640,28 @@ export class Miniflare { export type { WorkerdStructuredLog } from "./plugins/core"; +export type { FlagshipAdmin } from "./workers/flagship/admin"; + +export type { + BaseCondition, + Condition, + ErrorCode, + LogicalCondition, + EvaluationContext, + EvaluationDetails, + EvaluationReason, + FlagValue, + Operator, + Rollout, +} from "./workers/flagship/evaluate"; +export type { + Flag, + FlagChanges, + FlagInput, + FlagType, + Rule, +} from "./workers/flagship/flags"; + export interface SecretsStoreSecretAdmin { create(value: string): Promise; update(value: string, id: string): Promise; diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 6ba12ffa818..b1b26790a2c 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -1,30 +1,56 @@ +import fs from "node:fs/promises"; +import BINDING_SCRIPT from "worker:flagship/binding"; +import OBJECT_SCRIPT from "worker:flagship/object"; import { buildRemoteProxyProps, getEnvBindingsOfType, + getPersistPath, getRemoteProxyConnectionString, + getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; -import type { Worker_Binding } from "../../runtime"; +import type { Service, Worker_Binding } from "../../runtime"; import type { Plugin } from "../shared"; export const FLAGSHIP_PLUGIN_NAME = "flagship"; -const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:remote`; +const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:remote`; +const FLAGSHIP_OBJECT_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:object`; +const FLAGSHIP_STORAGE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:storage`; +const FLAGSHIP_OBJECT_CLASS_NAME = "FlagshipObject"; + +// Rollout bucketing is seeded with the account tag. Local flag definitions are +// their own source of truth and an account tag is not reliably available +// offline, so a constant keeps bucketing deterministic across machines; it does +// not reproduce production buckets. +const LOCAL_ACCOUNT_TAG = "local"; export const FLAGSHIP_PLUGIN: Plugin = { bindingTypeDescription: "Flagship", async getBindings(options) { return getEnvBindingsOfType(options.config, "flagship").map( - ([name, binding]) => ({ - name, - service: { - name: FLAGSHIP_REMOTE_SERVICE_NAME, - props: buildRemoteProxyProps( - getRemoteProxyConnectionString(binding, options.dev), - name - ), - }, - }) + ([name, binding]) => { + const remoteProxyConnectionString = getRemoteProxyConnectionString( + binding, + options.dev + ); + if (remoteProxyConnectionString) { + return { + name, + service: { + name: FLAGSHIP_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + }, + }; + } + return { + name, + service: { + name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, binding.id), + entrypoint: "FlagshipBinding", + }, + }; + } ); }, getNodeBindings(options) { @@ -35,16 +61,90 @@ export const FLAGSHIP_PLUGIN: Plugin = { ]) ); }, - async getServices({ options }) { - if (getEnvBindingsOfType(options.config, "flagship").length === 0) { + async getServices({ options, tmpPath, sharedOptions }) { + const bindings = getEnvBindingsOfType(options.config, "flagship"); + if (bindings.length === 0) { return []; } - return [ - { + const services: Service[] = []; + const hasRemote = bindings.some(([, binding]) => + getRemoteProxyConnectionString(binding, options.dev) + ); + if (hasRemote) { + services.push({ name: FLAGSHIP_REMOTE_SERVICE_NAME, worker: remoteProxyClientWorker(), + }); + } + + const localAppIds = new Set( + bindings + .filter( + ([, binding]) => + getRemoteProxyConnectionString(binding, options.dev) === undefined + ) + .map(([, binding]) => binding.id) + ); + if (localAppIds.size === 0) { + return services; + } + + const persistPath = getPersistPath( + FLAGSHIP_PLUGIN_NAME, + tmpPath, + sharedOptions.resourcePersistencePath + ); + await fs.mkdir(persistPath, { recursive: true }); + + services.push( + { + name: FLAGSHIP_STORAGE_SERVICE_NAME, + disk: { path: persistPath, writable: true }, }, - ]; + { + name: FLAGSHIP_OBJECT_SERVICE_NAME, + worker: { + compatibilityDate: "2025-01-01", + modules: [{ name: "object.worker.js", esModule: OBJECT_SCRIPT() }], + durableObjectNamespaces: [ + { + className: FLAGSHIP_OBJECT_CLASS_NAME, + uniqueKey: `miniflare-flagship-${FLAGSHIP_OBJECT_CLASS_NAME}`, + enableSql: true, + }, + ], + durableObjectStorage: { localDisk: FLAGSHIP_STORAGE_SERVICE_NAME }, + }, + } + ); + + for (const appId of localAppIds) { + services.push({ + name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, appId), + worker: { + compatibilityDate: "2025-01-01", + modules: [{ name: "binding.worker.js", esModule: BINDING_SCRIPT() }], + bindings: [ + { + name: "config", + json: JSON.stringify({ + appId, + accountTag: LOCAL_ACCOUNT_TAG, + }), + }, + { + name: "store", + durableObjectNamespace: { + className: FLAGSHIP_OBJECT_CLASS_NAME, + serviceName: FLAGSHIP_OBJECT_SERVICE_NAME, + }, + }, + ], + }, + }); + } + + return services; }, }; diff --git a/packages/miniflare/src/workers/flagship/admin.ts b/packages/miniflare/src/workers/flagship/admin.ts new file mode 100644 index 00000000000..71284adda53 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/admin.ts @@ -0,0 +1,23 @@ +import type { + EvaluationContext, + EvaluationDetails, + FlagValue, +} from "./evaluate"; +import type { Flag, FlagChanges, FlagInput } from "./flags"; + +export interface FlagshipAdmin { + listFlags(): Promise; + getFlag(flagKey: string): Promise; + getAccountTag(): Promise; + setAccountTag(accountTag: string): Promise; + createFlag(input: FlagInput): Promise; + updateFlag(flagKey: string, input: FlagInput): Promise; + patchFlag(flagKey: string, changes: FlagChanges): Promise; + putFlag(input: FlagInput): Promise; + putFlags(inputs: FlagInput[], accountTag: string): Promise; + deleteFlag(flagKey: string): Promise; + evaluateFlag( + flagKey: string, + context?: EvaluationContext + ): Promise>; +} diff --git a/packages/miniflare/src/workers/flagship/binding.worker.ts b/packages/miniflare/src/workers/flagship/binding.worker.ts new file mode 100644 index 00000000000..14edbd98346 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/binding.worker.ts @@ -0,0 +1,293 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { ADMIN_API } from "./constants"; +import { + evaluateFlag, + FlagConfigError, + matchesType, + TypeCastError, +} from "./evaluate"; +import { flagNotFoundMessage } from "./flags"; +import type { FlagshipAdmin } from "./admin"; +import type { + ErrorCode, + EvaluationContext, + EvaluationDetails, + FlagType, + FlagValue, +} from "./evaluate"; +import type { Flag, FlagInput } from "./flags"; +import type { FlagshipObject, WriteResult } from "./object.worker"; + +interface Env { + config: { appId: string; accountTag: string }; + store: DurableObjectNamespace; +} + +// Keep the default name: workerd prefixes custom error names during RPC serialization. +class FlagNotFoundError extends Error { + constructor(flagKey: string) { + super(flagNotFoundMessage(flagKey)); + } +} + +class FlagConflictError extends Error { + constructor(flagKey: string) { + super(`Flag '${flagKey}' already exists`); + } +} + +// Module scope, so the warning fires at most once per isolate rather than once +// per evaluation. +let warnedAboutUnseededRollout = false; + +function validateAccountTag(accountTag: string): void { + if (typeof accountTag !== "string" || accountTag === "") { + throw new Error("accountTag must be a non-empty string"); + } +} + +function hasPartialRollout(flag: Flag): boolean { + return flag.rules.some( + (rule) => rule.rollout !== undefined && rule.rollout.percentage < 100 + ); +} + +function warnIfBucketingUnseeded(flag: Flag): void { + if (warnedAboutUnseededRollout || !hasPartialRollout(flag)) { + return; + } + warnedAboutUnseededRollout = true; + console.warn( + `Flagship: flag '${flag.key}' has a percentage rollout, but the local flag store has no account tag, so its buckets will not match your remote app. Run \`wrangler flagship flags pull\` to seed the store.` + ); +} + +function errorCodeFor(error: unknown): ErrorCode | undefined { + if (error instanceof FlagNotFoundError) { + return "FLAG_NOT_FOUND"; + } + if (error instanceof FlagConfigError) { + return "PARSE_ERROR"; + } + return undefined; +} + +export class FlagshipBinding extends WorkerEntrypoint { + get #stub() { + const namespace = this.env.store; + return namespace.get(namespace.idFromName(this.env.config.appId)); + } + + async #evaluate( + flagKey: string, + context: EvaluationContext + ): Promise> { + if (typeof flagKey !== "string" || flagKey === "") { + throw new Error("flagKey must be a non-empty string"); + } + const { flag, accountTag } = await this.#stub.getForEvaluation(flagKey); + if (flag === null) { + throw new FlagNotFoundError(flagKey); + } + if (accountTag === null) { + warnIfBucketingUnseeded(flag); + } + const { value, variant, reason } = evaluateFlag( + flag, + context, + accountTag ?? this.env.config.accountTag + ); + return { flagKey, value, variant, reason }; + } + + async #typedDetails( + flagKey: string, + defaultValue: T, + expectedType: FlagType, + context?: EvaluationContext + ): Promise> { + const failure = ( + errorCode: ErrorCode, + errorMessage: string + ): EvaluationDetails => ({ + flagKey, + value: defaultValue, + variant: "default", + reason: "ERROR", + errorCode, + errorMessage, + }); + let result: EvaluationDetails; + try { + result = await this.#evaluate(flagKey, context ?? {}); + } catch (error) { + const errorCode = errorCodeFor(error); + if (errorCode === undefined) { + throw error; + } + return failure(errorCode, (error as Error).message); + } + + if (!matchesType(result.value, expectedType)) { + return failure( + "TYPE_MISMATCH", + new TypeCastError(flagKey, expectedType, result.value).message + ); + } + + return { + flagKey, + value: result.value as T, + variant: result.variant, + reason: result.reason, + }; + } + + async get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext + ): Promise { + try { + return (await this.#evaluate(flagKey, context ?? {})).value; + } catch (error) { + if (errorCodeFor(error) !== undefined && defaultValue !== undefined) { + return defaultValue; + } + throw error; + } + } + + async getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext + ): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "boolean", context)) + .value; + } + + async getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext + ): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "string", context)) + .value; + } + + async getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext + ): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "number", context)) + .value; + } + + async getObjectValue< + T extends Record | unknown[] = Record, + >(flagKey: string, defaultValue: T, context?: EvaluationContext): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "object", context)) + .value; + } + + async getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "boolean", context); + } + + async getStringDetails( + flagKey: string, + defaultValue: string, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "string", context); + } + + async getNumberDetails( + flagKey: string, + defaultValue: number, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "number", context); + } + + async getObjectDetails< + T extends Record | unknown[] = Record, + >( + flagKey: string, + defaultValue: T, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "object", context); + } + + [ADMIN_API](): FlagshipAdmin { + const stub = this.#stub; + function unwrap(result: WriteResult, flagKey: string): Flag { + switch (result.status) { + case "written": + return result.flag; + case "missing": + throw new FlagNotFoundError(flagKey); + case "exists": + throw new FlagConflictError(flagKey); + case "invalid": + throw new Error(result.message); + } + } + async function getFlag(flagKey: string): Promise { + const flag = await stub.get(flagKey); + if (flag === null) { + throw new FlagNotFoundError(flagKey); + } + return flag; + } + async function write( + operation: Promise, + flagKey: string + ): Promise { + return unwrap(await operation, flagKey); + } + + return { + listFlags: (): Promise => stub.list(), + getFlag, + getAccountTag: (): Promise => stub.getAccountTag(), + setAccountTag: (accountTag: string): Promise => { + validateAccountTag(accountTag); + return stub.setAccountTag(accountTag); + }, + createFlag: (input) => write(stub.create(input), input.key), + updateFlag: (flagKey, input) => + write(stub.update(flagKey, input), flagKey), + patchFlag: (flagKey, changes) => + write(stub.patch(flagKey, changes), flagKey), + putFlag: (input) => write(stub.put(input), input.key), + putFlags: async ( + inputs: FlagInput[], + accountTag: string + ): Promise => { + validateAccountTag(accountTag); + const result = await stub.putAll(inputs, accountTag); + if (result.status === "invalid") { + throw new Error(result.message); + } + }, + deleteFlag: async (flagKey: string): Promise => { + if (!(await stub.delete(flagKey))) { + throw new FlagNotFoundError(flagKey); + } + }, + evaluateFlag: ( + flagKey: string, + context?: EvaluationContext + ): Promise> => + this.#evaluate(flagKey, context ?? {}), + }; + } +} diff --git a/packages/miniflare/src/workers/flagship/constants.ts b/packages/miniflare/src/workers/flagship/constants.ts new file mode 100644 index 00000000000..adadc0b5b1f --- /dev/null +++ b/packages/miniflare/src/workers/flagship/constants.ts @@ -0,0 +1 @@ +export const ADMIN_API = "FlagshipBinding::admin_api"; diff --git a/packages/miniflare/src/workers/flagship/evaluate.ts b/packages/miniflare/src/workers/flagship/evaluate.ts new file mode 100644 index 00000000000..9033a5d1e92 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/evaluate.ts @@ -0,0 +1,270 @@ +import type { Condition, FlagInput, FlagValue, Rule } from "./flags"; + +// Vendored from Flagship data-plane commit f32a8bf1607a7493175ea3a919f56dcd6b8a4fca. +// Hashing and matching must remain byte-compatible with production. + +export type EvaluationReason = + | "TARGETING_MATCH" + | "DEFAULT" + | "DISABLED" + | "SPLIT" + | "ERROR"; + +export type ErrorCode = + | "FLAG_NOT_FOUND" + | "PARSE_ERROR" + | "TYPE_MISMATCH" + | "GENERAL"; + +export type EvaluationContext = Record; + +export type FlagType = "boolean" | "string" | "number" | "object"; + +export type EvalRule = Omit; +export type EvalFlag = Omit & { rules: EvalRule[] }; + +export interface EvaluationDetails { + flagKey: string; + value: T; + variant: string; + reason: EvaluationReason; + errorCode?: ErrorCode; + errorMessage?: string; +} + +export class TypeCastError extends Error { + constructor(flagKey: string, expectedType: string, actualValue: unknown) { + super( + `Flag '${flagKey}' has type '${typeof actualValue}', expected '${expectedType}'` + ); + this.name = "TypeCastError"; + } +} + +export class FlagConfigError extends Error { + constructor(flagKey: string, message: string) { + super(`Flag '${flagKey}' ${message}`); + this.name = "FlagConfigError"; + } +} + +const ISO_8601_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + +const encoder = new TextEncoder(); +const randomBuf = new Uint32Array(1); +let hashBuf = new Uint8Array(512); + +function murmurhash3(str: string, seed: number): number { + if (hashBuf.byteLength < str.length * 3) { + hashBuf = new Uint8Array(str.length * 3); + } + const { written: n } = encoder.encodeInto(str, hashBuf); + const b = hashBuf; + let h = seed >>> 0; + let i = 0; + while (i + 4 <= n) { + let k = b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); + k = Math.imul(k, 0xcc9e2d51) >>> 0; + k = ((k << 15) | (k >>> 17)) >>> 0; + k = Math.imul(k, 0x1b873593) >>> 0; + h ^= k; + h = ((h << 13) | (h >>> 19)) >>> 0; + h = (Math.imul(h, 5) + 0xe6546b64) >>> 0; + i += 4; + } + let k = 0; + if (n - i >= 3) { + k ^= b[i + 2] << 16; + } + if (n - i >= 2) { + k ^= b[i + 1] << 8; + } + if (n > i) { + k ^= b[i]; + k = Math.imul(k, 0xcc9e2d51) >>> 0; + k = ((k << 15) | (k >>> 17)) >>> 0; + k = Math.imul(k, 0x1b873593) >>> 0; + h ^= k; + } + h ^= n; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b) >>> 0; + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35) >>> 0; + h ^= h >>> 16; + return (h >>> 0) % 100; +} + +function compareTemporalOrNumeric( + attrValue: unknown, + target: unknown, + compare: (a: number, b: number) => boolean +): boolean { + if ( + typeof target === "string" && + ISO_8601_REGEX.test(target) && + typeof attrValue === "string" + ) { + const ts = Date.parse(attrValue); + if (!isNaN(ts)) { + return compare(ts, Date.parse(target)); + } + } + return compare(Number(attrValue), Number(target)); +} + +function evaluateCondition( + condition: Condition, + context: EvaluationContext +): boolean { + if ("logical_operator" in condition) { + const { logical_operator, clauses } = condition; + if (logical_operator === "AND") { + for (const clause of clauses) { + if (!evaluateCondition(clause, context)) { + return false; + } + } + return true; + } + for (const clause of clauses) { + if (evaluateCondition(clause, context)) { + return true; + } + } + return false; + } + + const { attribute, operator, value: target } = condition; + const attrValue = context[attribute]; + if (attrValue === undefined) { + return false; + } + + switch (operator) { + case "equals": + return String(attrValue) === String(target); + case "not_equals": + return String(attrValue) !== String(target); + case "contains": + return String(attrValue).includes(String(target)); + case "starts_with": + return String(attrValue).startsWith(String(target)); + case "ends_with": + return String(attrValue).endsWith(String(target)); + case "greater_than": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a > b); + case "less_than": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a < b); + case "greater_than_or_equals": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a >= b); + case "less_than_or_equals": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a <= b); + case "in": + return ( + Array.isArray(target) && + target.some((value) => String(value) === String(attrValue)) + ); + case "not_in": + return ( + Array.isArray(target) && + !target.some((value) => String(value) === String(attrValue)) + ); + default: + return false; + } +} + +export function evaluateFlag( + flagDef: EvalFlag | FlagInput, + context: EvaluationContext, + accountId: string +): { value: FlagValue; variant: string; reason: EvaluationReason } { + const serve = (variant: string, reason: EvaluationReason) => { + if (!Object.hasOwn(flagDef.variations, variant)) { + throw new FlagConfigError( + flagDef.key, + `variation '${variant}' is not defined` + ); + } + return { + value: flagDef.variations[variant] as FlagValue, + variant, + reason, + }; + }; + + if (!flagDef.enabled) { + return serve(flagDef.default_variation, "DISABLED"); + } + + // Seeded per account+flag so the same targetingKey lands in different + // buckets across flags, preventing correlated rollouts. + let seed: number | undefined; + + const rules = [...flagDef.rules].sort((a, b) => { + const aPriority = "priority" in a ? a.priority : 0; + const bPriority = "priority" in b ? b.priority : 0; + return aPriority - bPriority; + }); + for (const rule of rules) { + let ruleMatches = true; + + for (const condition of rule.conditions) { + if (!evaluateCondition(condition, context)) { + ruleMatches = false; + break; + } + } + + if ( + ruleMatches && + rule.rollout !== undefined && + rule.rollout.percentage < 100 + ) { + seed ??= murmurhash3(`${accountId}:${flagDef.key}`, 0); + const attr = context[rule.rollout.attribute || "targetingKey"]; + const bucket = + attr !== null && attr !== undefined + ? murmurhash3(String(attr), seed) + : (crypto.getRandomValues(randomBuf)[0] / 0x100000000) * 100; + if (bucket >= rule.rollout.percentage) { + ruleMatches = false; + } + } + + if (ruleMatches) { + return serve( + rule.serve_variation, + rule.rollout !== undefined ? "SPLIT" : "TARGETING_MATCH" + ); + } + } + + return serve(flagDef.default_variation, "DEFAULT"); +} + +export type { + BaseCondition, + Condition, + FlagValue, + LogicalCondition, + Operator, + Rollout, +} from "./flags"; + +export function matchesType(value: FlagValue, expectedType: FlagType): boolean { + switch (expectedType) { + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "object": + return typeof value === "object" && value !== null; + default: + return false; + } +} diff --git a/packages/miniflare/src/workers/flagship/flags.ts b/packages/miniflare/src/workers/flagship/flags.ts new file mode 100644 index 00000000000..a6b78fe05f7 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/flags.ts @@ -0,0 +1,316 @@ +export type FlagType = "boolean" | "string" | "number" | "json"; + +export type FlagValue = + | boolean + | string + | number + | Record + | unknown[]; + +export type Operator = + | "equals" + | "not_equals" + | "greater_than" + | "less_than" + | "greater_than_or_equals" + | "less_than_or_equals" + | "contains" + | "starts_with" + | "ends_with" + | "in" + | "not_in"; + +export interface BaseCondition { + attribute: string; + operator: Operator; + value: unknown; +} + +export interface LogicalCondition { + logical_operator: "AND" | "OR"; + clauses: Condition[]; +} + +export type Condition = BaseCondition | LogicalCondition; + +export interface Rollout { + percentage: number; + attribute?: string; +} + +export interface Rule { + priority: number; + conditions: Condition[]; + serve_variation: string; + rollout?: Rollout; +} + +export interface FlagInput { + key: string; + description?: string | null; + enabled: boolean; + default_variation: string; + variations: Record; + rules: Rule[]; +} + +export interface Flag extends FlagInput { + type: FlagType; + updated_at: string; +} + +export interface FlagChanges { + description?: string | null; + enabled?: boolean; + default_variation?: string; + variations?: Record; + rules?: Rule[]; +} + +const FLAG_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; + +export function flagNotFoundMessage(flagKey: string): string { + return `Flag '${flagKey}' not found`; +} + +const OPERATORS = new Set([ + "equals", + "not_equals", + "greater_than", + "less_than", + "greater_than_or_equals", + "less_than_or_equals", + "contains", + "starts_with", + "ends_with", + "in", + "not_in", +]); + +const LIST_OPERATORS = new Set(["in", "not_in"]); + +const MAX_CONDITION_DEPTH = 5; + +function isJsonValue(value: unknown, seen = new Set()): boolean { + if ( + value === null || + typeof value === "boolean" || + typeof value === "string" + ) { + return true; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (typeof value !== "object" || seen.has(value)) { + return false; + } + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) { + return false; + } + + seen.add(value); + const values = Array.isArray(value) ? value : Object.values(value); + const valid = values.every((entry) => isJsonValue(entry, seen)); + seen.delete(value); + return valid; +} + +export function getFlagType(variations: Record): FlagType { + const [first] = Object.values(variations); + switch (typeof first) { + case "boolean": + return "boolean"; + case "string": + return "string"; + case "number": + return "number"; + default: + return "json"; + } +} + +function validateCondition( + key: string, + condition: unknown, + depth: number +): void { + if ( + typeof condition !== "object" || + condition === null || + Array.isArray(condition) + ) { + throw new Error(`Flag '${key}' has a condition that is not an object`); + } + + if ("logical_operator" in condition) { + const { logical_operator: operator, clauses } = condition as { + logical_operator: unknown; + clauses?: unknown; + }; + if (operator !== "AND" && operator !== "OR") { + throw new Error( + `Flag '${key}' has a condition with an unknown logical operator '${String(operator)}'` + ); + } + if (!Array.isArray(clauses)) { + throw new Error( + `Flag '${key}' has a '${operator}' condition without a list of clauses` + ); + } + if (depth === 0) { + throw new Error(`Flag '${key}' has conditions nested too deeply`); + } + for (const clause of clauses) { + validateCondition(key, clause, depth - 1); + } + return; + } + + const { attribute, operator, value } = condition as { + attribute?: unknown; + operator?: unknown; + value?: unknown; + }; + if (typeof attribute !== "string" || attribute === "") { + throw new Error( + `Flag '${key}' has a condition without an attribute to match on` + ); + } + if (typeof operator !== "string" || !OPERATORS.has(operator as Operator)) { + throw new Error( + `Flag '${key}' has a condition with an unknown operator '${String(operator)}'` + ); + } + if (LIST_OPERATORS.has(operator as Operator) && !Array.isArray(value)) { + throw new Error( + `Flag '${key}' has a '${operator}' condition whose value is not a list` + ); + } + if (value === undefined) { + throw new Error(`Flag '${key}' has a condition without a value`); + } + if (!isJsonValue(value)) { + throw new Error( + `Flag '${key}' has a condition with a value that cannot be stored as JSON` + ); + } +} + +export function validateFlagInput(input: FlagInput): void { + if (!FLAG_KEY_REGEX.test(input.key)) { + throw new Error( + `Flag key '${input.key}' must be 1-64 alphanumeric, hyphen or underscore characters` + ); + } + + const variationNames = Object.keys(input.variations); + if (variationNames.length === 0) { + throw new Error(`Flag '${input.key}' must define at least one variation`); + } + + const types = new Set( + Object.values(input.variations).map((value) => + typeof value === "boolean" || + typeof value === "string" || + typeof value === "number" + ? typeof value + : "object" + ) + ); + if (types.size > 1) { + throw new Error( + `Flag '${input.key}' variations must all share the same type` + ); + } + if (Object.values(input.variations).some((value) => value === null)) { + throw new Error(`Flag '${input.key}' variations cannot be null`); + } + if (Object.values(input.variations).some((value) => !isJsonValue(value))) { + throw new Error( + `Flag '${input.key}' variations must contain values that can be stored as JSON` + ); + } + + if (!variationNames.includes(input.default_variation)) { + throw new Error( + `Flag '${input.key}' default variation '${input.default_variation}' is not defined` + ); + } + + if (!Array.isArray(input.rules)) { + throw new Error(`Flag '${input.key}' rules must be a list`); + } + + const priorities = new Set(); + for (const rule of input.rules) { + if (!Array.isArray(rule.conditions)) { + throw new Error(`Flag '${input.key}' rule conditions must be a list`); + } + for (const condition of rule.conditions) { + validateCondition(input.key, condition, MAX_CONDITION_DEPTH); + } + if (!variationNames.includes(rule.serve_variation)) { + throw new Error( + `Flag '${input.key}' rule serves undefined variation '${rule.serve_variation}'` + ); + } + if (!Number.isInteger(rule.priority) || rule.priority < 1) { + throw new Error( + `Flag '${input.key}' rule priorities must be integers greater than or equal to 1` + ); + } + if (priorities.has(rule.priority)) { + throw new Error( + `Flag '${input.key}' has duplicate rule priority ${rule.priority}` + ); + } + priorities.add(rule.priority); + + if (rule.rollout !== undefined) { + const { percentage, attribute } = rule.rollout; + if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) { + throw new Error( + `Flag '${input.key}' rollout percentage must be a number between 0 and 100` + ); + } + if (attribute !== undefined && typeof attribute !== "string") { + throw new Error( + `Flag '${input.key}' rollout attribute must be a string` + ); + } + } + } + + let seenCatchAll = false; + for (const rule of [...input.rules].sort((a, b) => a.priority - b.priority)) { + if ( + rule.conditions.length === 0 && + (rule.rollout === undefined || rule.rollout.percentage === 100) + ) { + seenCatchAll = true; + } else if (seenCatchAll) { + throw new Error( + `Flag '${input.key}' has targeting rules after a rule with no conditions` + ); + } + } +} + +export function toStoredFlag(input: FlagInput): Flag { + return { + key: input.key, + description: input.description ?? null, + enabled: input.enabled, + default_variation: input.default_variation, + variations: input.variations, + // Evaluation order is defined by priority, not input array order. + rules: [...input.rules].sort((a, b) => a.priority - b.priority), + type: getFlagType(input.variations), + updated_at: new Date().toISOString(), + }; +} diff --git a/packages/miniflare/src/workers/flagship/object.worker.ts b/packages/miniflare/src/workers/flagship/object.worker.ts new file mode 100644 index 00000000000..eb6ead8cef2 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/object.worker.ts @@ -0,0 +1,183 @@ +import { DurableObject } from "cloudflare:workers"; +import { toStoredFlag, validateFlagInput } from "./flags"; +import type { Flag, FlagChanges, FlagInput } from "./flags"; + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS flags ( + key TEXT PRIMARY KEY, + definition TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, +]; + +const ACCOUNT_TAG_KEY = "accountTag"; + +export type WriteResult = + | { status: "written"; flag: Flag } + | { status: "invalid"; message: string } + | { status: "missing" } + | { status: "exists" }; + +type InvalidResult = Extract; + +function invalidResult(input: FlagInput): InvalidResult | undefined { + try { + validateFlagInput(input); + } catch (error) { + return { + status: "invalid", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export class FlagshipObject extends DurableObject { + private sql = this.ctx.storage.sql; + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env as never); + this.ctx.blockConcurrencyWhile(async () => { + for (const statement of SCHEMA) { + this.sql.exec(statement); + } + }); + } + + list(): Flag[] { + return [ + ...this.sql.exec<{ definition: string }>( + "SELECT definition FROM flags ORDER BY key" + ), + ].map((row) => JSON.parse(row.definition) as Flag); + } + + get(key: string): Flag | null { + const [row] = [ + ...this.sql.exec<{ definition: string }>( + "SELECT definition FROM flags WHERE key = ?", + key + ), + ]; + return row === undefined ? null : (JSON.parse(row.definition) as Flag); + } + + getAccountTag(): string | null { + const [row] = [ + ...this.sql.exec<{ value: string }>( + "SELECT value FROM metadata WHERE key = ?", + ACCOUNT_TAG_KEY + ), + ]; + return row?.value ?? null; + } + + setAccountTag(accountTag: string): void { + this.#writeAccountTag(accountTag); + } + + getForEvaluation(key: string): { + flag: Flag | null; + accountTag: string | null; + } { + return { flag: this.get(key), accountTag: this.getAccountTag() }; + } + + create(input: FlagInput): WriteResult { + const invalid = invalidResult(input); + if (invalid !== undefined) { + return invalid; + } + if (this.get(input.key) !== null) { + return { status: "exists" }; + } + return this.#writeResult(input); + } + + update(key: string, input: FlagInput): WriteResult { + if (this.get(key) === null) { + return { status: "missing" }; + } + return this.#validateAndWrite({ ...input, key }); + } + + patch(key: string, changes: FlagChanges): WriteResult { + const current = this.get(key); + if (current === null) { + return { status: "missing" }; + } + const next: FlagInput = { + key, + description: + changes.description === undefined + ? current.description + : changes.description, + enabled: changes.enabled ?? current.enabled, + default_variation: changes.default_variation ?? current.default_variation, + variations: changes.variations ?? current.variations, + rules: changes.rules ?? current.rules, + }; + return this.#validateAndWrite(next); + } + + put(input: FlagInput): WriteResult { + return this.#validateAndWrite(input); + } + + putAll( + inputs: FlagInput[], + accountTag: string + ): { status: "written" } | { status: "invalid"; message: string } { + for (const input of inputs) { + const invalid = invalidResult(input); + if (invalid !== undefined) { + return invalid; + } + } + const stored = inputs.map(toStoredFlag); + this.ctx.storage.transactionSync(() => { + this.#writeAccountTag(accountTag); + for (const flag of stored) { + this.#write(flag); + } + }); + return { status: "written" }; + } + + delete(key: string): boolean { + if (this.get(key) === null) { + return false; + } + this.sql.exec("DELETE FROM flags WHERE key = ?", key); + return true; + } + + #validateAndWrite(input: FlagInput): WriteResult { + return invalidResult(input) ?? this.#writeResult(input); + } + + #writeResult(input: FlagInput): WriteResult { + return { status: "written", flag: this.#write(toStoredFlag(input)) }; + } + + #write(flag: Flag): Flag { + this.sql.exec( + `INSERT INTO flags (key, definition) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET definition = excluded.definition`, + flag.key, + JSON.stringify(flag) + ); + return flag; + } + + #writeAccountTag(accountTag: string): void { + this.sql.exec( + `INSERT INTO metadata (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value`, + ACCOUNT_TAG_KEY, + accountTag + ); + } +} diff --git a/packages/miniflare/test/plugins/flagship/evaluate.spec.ts b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts new file mode 100644 index 00000000000..cba74f7ecd1 --- /dev/null +++ b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts @@ -0,0 +1,210 @@ +import { describe, test } from "vitest"; +import { evaluateFlag } from "../../../src/workers/flagship/evaluate"; +import type { EvaluationContext } from "../../../src/workers/flagship/evaluate"; +import type { FlagInput, Rule } from "../../../src/workers/flagship/flags"; + +const ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; + +function flag(overrides: Partial = {}): FlagInput { + return { + key: "test", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [], + ...overrides, + }; +} + +function rolloutFlag(percentage: number, attribute?: string): FlagInput { + return flag({ + key: "rollout_test", + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage, attribute }, + }, + ], + }); +} + +function matches( + conditions: Rule["conditions"], + context: EvaluationContext +): boolean { + return ( + evaluateFlag( + flag({ rules: [{ priority: 1, conditions, serve_variation: "on" }] }), + context, + "local" + ).reason === "TARGETING_MATCH" + ); +} + +function bucketFor(targetingKey: unknown): number { + for (let percentage = 1; percentage <= 100; percentage++) { + if ( + evaluateFlag(rolloutFlag(percentage), { targetingKey }, ACCOUNT_TAG) + .reason === "SPLIT" + ) { + return percentage - 1; + } + } + return 100; +} + +describe("flagship evaluation", () => { + test("serves defaults, disabled flags, and the first matching rule", ({ + expect, + }) => { + expect(evaluateFlag(flag(), {}, "local")).toEqual({ + value: false, + variant: "off", + reason: "DEFAULT", + }); + expect( + evaluateFlag( + flag({ + enabled: false, + rules: [{ priority: 1, conditions: [], serve_variation: "on" }], + }), + {}, + "local" + ) + ).toMatchObject({ variant: "off", reason: "DISABLED" }); + + const ordered = flag({ + variations: { first: "a", second: "b", off: "off" }, + rules: [ + { + priority: 1, + conditions: [{ attribute: "id", operator: "equals", value: 1 }], + serve_variation: "first", + }, + { priority: 2, conditions: [], serve_variation: "second" }, + ], + }); + expect(evaluateFlag(ordered, { id: "1" }, "local").variant).toBe("first"); + expect(evaluateFlag(ordered, { id: "2" }, "local").variant).toBe("second"); + ordered.rules.reverse(); + expect(evaluateFlag(ordered, { id: "1" }, "local").variant).toBe("first"); + }); + + test("rejects an undefined served variation", ({ expect }) => { + expect(() => + evaluateFlag(flag({ default_variation: "missing" }), {}, "local") + ).toThrow("Flag 'test' variation 'missing' is not defined"); + }); + + test("matches comparison and logical conditions with production coercions", ({ + expect, + }) => { + expect( + matches([{ attribute: "id", operator: "equals", value: 42 }], { + id: "42", + }) + ).toBe(true); + expect( + matches([{ attribute: "country", operator: "in", value: ["US"] }], { + country: "US", + }) + ).toBe(true); + expect( + matches([{ attribute: "country", operator: "not_in", value: [] }], { + country: "US", + }) + ).toBe(true); + expect( + matches( + [ + { + attribute: "now", + operator: "greater_than", + value: "2025-05-01T15:00:00Z", + }, + ], + { now: "2025-06-01T15:00:00Z" } + ) + ).toBe(true); + expect(matches([{ logical_operator: "AND", clauses: [] }], {})).toBe(true); + expect(matches([{ logical_operator: "OR", clauses: [] }], {})).toBe(false); + }); + + test("does not match missing attributes or malformed operators", ({ + expect, + }) => { + expect( + matches([{ attribute: "plan", operator: "equals", value: "pro" }], {}) + ).toBe(false); + expect( + matches([{ attribute: "country", operator: "in", value: "US" }], { + country: "US", + }) + ).toBe(false); + expect( + matches([{ attribute: "id", operator: "invalid" as never, value: 1 }], { + id: 1, + }) + ).toBe(false); + }); + + describe("rollouts", () => { + test("matches upstream hash vectors and stringifies targeting keys", ({ + expect, + }) => { + expect( + Object.fromEntries( + ["0", "1", "2", "", "日本語", "héllo", "false"].map((key) => [ + key, + bucketFor(key), + ]) + ) + ).toEqual({ + "0": 15, + "1": 8, + "2": 91, + "": 50, + 日本語: 9, + héllo: 73, + false: 33, + }); + expect(bucketFor(0)).toBe(bucketFor("0")); + expect(bucketFor(false)).toBe(bucketFor("false")); + }); + + test("honors rollout boundaries and custom attributes", ({ expect }) => { + expect(evaluateFlag(rolloutFlag(100), {}, ACCOUNT_TAG).reason).toBe( + "SPLIT" + ); + expect( + evaluateFlag(rolloutFlag(0), { targetingKey: "1" }, ACCOUNT_TAG).reason + ).toBe("DEFAULT"); + const custom = rolloutFlag(50, "userId"); + expect(evaluateFlag(custom, { userId: "1" }, ACCOUNT_TAG).reason).toBe( + "SPLIT" + ); + expect(evaluateFlag(custom, { userId: "2" }, ACCOUNT_TAG).reason).toBe( + "DEFAULT" + ); + expect(["SPLIT", "DEFAULT"]).toContain( + evaluateFlag(rolloutFlag(50), {}, ACCOUNT_TAG).reason + ); + }); + + test("seeds buckets by account and flag", ({ expect }) => { + const reasons = (flagKey: string, accountTag: string) => + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].map( + (targetingKey) => { + const rollout = rolloutFlag(50); + rollout.key = flagKey; + return evaluateFlag(rollout, { targetingKey }, accountTag).reason; + } + ); + const baseline = reasons("rollout_test", ACCOUNT_TAG); + expect(reasons("rollout_test", "local")).not.toEqual(baseline); + expect(reasons("other", ACCOUNT_TAG)).not.toEqual(baseline); + }); + }); +}); diff --git a/packages/miniflare/test/plugins/flagship/index.spec.ts b/packages/miniflare/test/plugins/flagship/index.spec.ts index 48d49416c51..d8a7d00713f 100644 --- a/packages/miniflare/test/plugins/flagship/index.spec.ts +++ b/packages/miniflare/test/plugins/flagship/index.spec.ts @@ -1,5 +1,7 @@ -import { WorkerOptionsSchema } from "miniflare"; -import { test } from "vitest"; +import { Miniflare, WorkerOptionsSchema } from "miniflare"; +import { describe, test } from "vitest"; +import { singleModuleManifest, useDispose, useTmp } from "../../test-shared"; +import type { FlagInput, MiniflareOptions } from "miniflare"; function workerConfigBase( overrides?: Record @@ -18,6 +20,49 @@ function workerConfigBase( }; } +const WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const { method, args } = await request.json(); + return Response.json({ result: await env.FLAGS[method](...args) }); + }, + }; +`; + +function options( + env: Record = { + FLAGS: { type: "flagship", id: "app" }, + } +): MiniflareOptions { + return { + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-05-01", + env, + manifest: singleModuleManifest(WORKER_SCRIPT), + }, + }, + ], + }; +} + +const BOOL_FLAG: FlagInput = { + key: "new_checkout", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [ + { + priority: 1, + conditions: [{ attribute: "plan", operator: "equals", value: "pro" }], + serve_variation: "on", + }, + ], +}; + test("flagship: accepts valid flagship binding", ({ expect }) => { const result = WorkerOptionsSchema.safeParse({ config: workerConfigBase({ @@ -53,3 +98,401 @@ test("flagship: accepts config with no flagship binding", ({ expect }) => { }); expect(result.success).toBe(true); }); + +const ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; +const BUCKETS = { "0": 15, "1": 8, "2": 91, "": 50, 日本語: 9, héllo: 73 }; +const ROLLOUT_FLAG: FlagInput = { + ...BOOL_FLAG, + key: "rollout_test", + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 50 }, + }, + ], +}; + +async function getAdmin(mf: Miniflare, binding = "FLAGS") { + return (await mf.getFlagshipBindingAPI(binding))(); +} + +async function call(mf: Miniflare, method: string, ...args: unknown[]) { + const response = await mf.dispatchFetch("http://placeholder", { + method: "POST", + body: JSON.stringify({ method, args }), + }); + if (!response.ok) throw new Error(await response.text()); + return ((await response.json()) as { result: unknown }).result; +} + +async function rejection(call: () => Promise): Promise { + try { + await call(); + } catch (error) { + return (error as Error).message; + } + throw new Error("expected rejection"); +} + +describe("flagship plugin", () => { + test("keeps app service names separate from internal services", async ({ + expect, + }) => { + const mf = new Miniflare( + options({ + OBJECT: { type: "flagship", id: "internal:object" }, + REMOTE: { type: "flagship", id: "internal:remote" }, + STORAGE: { type: "flagship", id: "internal:storage" }, + }) + ); + useDispose(mf); + + await expect(getAdmin(mf, "OBJECT")).resolves.toBeDefined(); + await expect(getAdmin(mf, "REMOTE")).resolves.toBeDefined(); + await expect(getAdmin(mf, "STORAGE")).resolves.toBeDefined(); + }); + + test("implements binding values, details, defaults, and errors", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + + expect( + await call(mf, "getBooleanValue", BOOL_FLAG.key, false, { plan: "pro" }) + ).toBe(true); + expect( + await call(mf, "getBooleanDetails", BOOL_FLAG.key, false, { plan: "pro" }) + ).toEqual({ + flagKey: BOOL_FLAG.key, + value: true, + variant: "on", + reason: "TARGETING_MATCH", + }); + expect(await call(mf, "getStringValue", "missing", "fallback")).toBe( + "fallback" + ); + expect(await call(mf, "getStringDetails", "missing", "fallback")).toEqual({ + flagKey: "missing", + value: "fallback", + variant: "default", + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + errorMessage: "Flag 'missing' not found", + }); + expect( + await call(mf, "getStringDetails", BOOL_FLAG.key, "fallback") + ).toEqual( + expect.objectContaining({ + value: "fallback", + errorCode: "TYPE_MISMATCH", + errorMessage: + "Flag 'new_checkout' has type 'boolean', expected 'string'", + }) + ); + await expect(call(mf, "get", "missing")).rejects.toThrow( + "Flag 'missing' not found" + ); + expect(await call(mf, "get", "missing", false)).toBe(false); + }); + + test("supports every admin mutation", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + + expect(await admin.listFlags()).toEqual([]); + const created = await admin.createFlag(BOOL_FLAG); + expect(await admin.getFlag(BOOL_FLAG.key)).toEqual(created); + await admin.updateFlag(BOOL_FLAG.key, { ...BOOL_FLAG, enabled: false }); + expect( + await admin.evaluateFlag(BOOL_FLAG.key, { plan: "pro" }) + ).toMatchObject({ + value: false, + reason: "DISABLED", + }); + await admin.patchFlag(BOOL_FLAG.key, { description: "description" }); + expect(await admin.getFlag(BOOL_FLAG.key)).toMatchObject({ + description: "description", + enabled: false, + }); + await admin.putFlag({ ...BOOL_FLAG, enabled: true }); + await admin.putFlag({ ...BOOL_FLAG, enabled: false }); + expect(await admin.listFlags()).toHaveLength(1); + await admin.putFlags([{ ...BOOL_FLAG, enabled: true }], ACCOUNT_TAG); + expect(await admin.getAccountTag()).toBe(ACCOUNT_TAG); + await admin.deleteFlag(BOOL_FLAG.key); + expect(await admin.listFlags()).toEqual([]); + }); + + test("reports missing and conflicting admin operations", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + expect(await rejection(() => admin.createFlag(BOOL_FLAG))).toBe( + "Flag 'new_checkout' already exists" + ); + for (const operation of [ + () => admin.getFlag("missing"), + () => admin.updateFlag("missing", BOOL_FLAG), + () => admin.patchFlag("missing", {}), + () => admin.deleteFlag("missing"), + ]) { + expect(await rejection(operation)).toBe("Flag 'missing' not found"); + } + }); + + test("enforces flag validation", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + const cases: [Partial, string][] = [ + [ + { key: "not valid!" }, + "Flag key 'not valid!' must be 1-64 alphanumeric, hyphen or underscore characters", + ], + [{ variations: {} }, "must define at least one variation"], + [{ variations: { on: true, off: "no" } }, "must all share the same type"], + [{ variations: { on: null, off: null } }, "variations cannot be null"], + [ + { variations: { on: Number.POSITIVE_INFINITY, off: 0 } }, + "variations must contain values that can be stored as JSON", + ], + [ + { variations: { on: new Date(), off: {} } }, + "variations must contain values that can be stored as JSON", + ], + [ + { default_variation: "missing" }, + "default variation 'missing' is not defined", + ], + [ + { rules: [{ ...BOOL_FLAG.rules[0], serve_variation: "missing" }] }, + "rule serves undefined variation 'missing'", + ], + [ + { rules: [{ ...BOOL_FLAG.rules[0], priority: 0 }] }, + "rule priorities must be integers greater than or equal to 1", + ], + [ + { rules: [BOOL_FLAG.rules[0], { ...BOOL_FLAG.rules[0] }] }, + "duplicate rule priority 1", + ], + [ + { + rules: [ + { priority: 1, conditions: [], serve_variation: "on" }, + { ...BOOL_FLAG.rules[0], priority: 2 }, + ], + }, + "targeting rules after a rule with no conditions", + ], + [ + { rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 101 } }] }, + "rollout percentage must be a number between 0 and 100", + ], + [ + { + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [{ logical_operator: "AND" } as never], + }, + ], + }, + "'AND' condition without a list of clauses", + ], + [ + { + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [ + { attribute: "plan", operator: "invalid", value: "pro" }, + ] as never, + }, + ], + }, + "condition with an unknown operator 'invalid'", + ], + [ + { + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [ + { attribute: "plan", operator: "in", value: "pro" }, + ] as never, + }, + ], + }, + "'in' condition whose value is not a list", + ], + ]; + for (const [changes, message] of cases) { + expect( + await rejection(() => + admin.putFlag({ ...BOOL_FLAG, ...changes } as FlagInput) + ) + ).toContain(message); + } + const fractional = await admin.putFlag({ + ...BOOL_FLAG, + rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 33.333333 } }], + }); + expect(fractional.rules[0].rollout?.percentage).toBe(33.333333); + const partialRollout = await admin.putFlag({ + ...BOOL_FLAG, + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 50 }, + }, + { ...BOOL_FLAG.rules[0], priority: 2 }, + ], + }); + expect(partialRollout.rules).toHaveLength(2); + }); + + test("validates account tags and makes batch writes atomic", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + for (const operation of [ + () => admin.setAccountTag(""), + () => admin.putFlags([BOOL_FLAG], ""), + ]) { + expect(await rejection(operation)).toBe( + "accountTag must be a non-empty string" + ); + } + expect( + await rejection(() => + admin.putFlags( + [BOOL_FLAG, { ...BOOL_FLAG, key: "invalid", variations: {} }], + ACCOUNT_TAG + ) + ) + ).toBe("Flag 'invalid' must define at least one variation"); + expect(await admin.listFlags()).toEqual([]); + expect(await admin.getAccountTag()).toBeNull(); + }); + + test("isolates apps and shares aliases", async ({ expect }) => { + const mf = new Miniflare( + options({ + FLAGS: { type: "flagship", id: "app-a" }, + ALIAS: { type: "flagship", id: "app-a" }, + OTHER: { type: "flagship", id: "app-b" }, + }) + ); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + await admin.setAccountTag(ACCOUNT_TAG); + expect(await (await getAdmin(mf, "ALIAS")).listFlags()).toHaveLength(1); + expect(await (await getAdmin(mf, "OTHER")).listFlags()).toEqual([]); + expect(await (await getAdmin(mf, "OTHER")).getAccountTag()).toBeNull(); + }); + + test("persists flags and account tags", async ({ expect }) => { + const persistence = await useTmp(); + const opts = { ...options(), resourcePersistencePath: persistence }; + const first = new Miniflare(opts); + const admin = await getAdmin(first); + await admin.createFlag(BOOL_FLAG); + await admin.setAccountTag(ACCOUNT_TAG); + await first.dispose(); + + const second = new Miniflare(opts); + useDispose(second); + expect(await (await getAdmin(second)).getAccountTag()).toBe(ACCOUNT_TAG); + expect( + await call(second, "getBooleanValue", BOOL_FLAG.key, false, { + plan: "pro", + }) + ).toBe(true); + }); + + test("shares live persistent storage across instances", async ({ + expect, + }) => { + const persistence = await useTmp(); + const opts = { ...options(), resourcePersistencePath: persistence }; + const first = new Miniflare(opts); + useDispose(first); + const firstAdmin = await getAdmin(first); + expect(await firstAdmin.listFlags()).toEqual([]); + const second = new Miniflare(opts); + useDispose(second); + await (await getAdmin(second)).createFlag(BOOL_FLAG); + expect(await firstAdmin.listFlags()).toEqual([ + expect.objectContaining({ key: BOOL_FLAG.key }), + ]); + }); + + test("reproduces seeded rollout buckets", async ({ expect }) => { + const warnings: string[] = []; + const mf = new Miniflare({ + ...options(), + handleStructuredLogs(log) { + if (log.level === "warn") warnings.push(log.message); + }, + }); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.setAccountTag(ACCOUNT_TAG); + await admin.createFlag(ROLLOUT_FLAG); + for (const [targetingKey, bucket] of Object.entries(BUCKETS)) { + expect( + await call(mf, "getBooleanValue", ROLLOUT_FLAG.key, false, { + targetingKey, + }) + ).toBe(bucket < 50); + } + expect(warnings).toEqual([]); + }); + + test("warns once for unseeded partial rollouts only", async ({ expect }) => { + const warnings: string[] = []; + const mf = new Miniflare({ + ...options(), + handleStructuredLogs(log) { + if (log.level === "warn") warnings.push(log.message); + }, + }); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + await call(mf, "getBooleanValue", BOOL_FLAG.key, false, { plan: "pro" }); + await admin.createFlag({ + ...ROLLOUT_FLAG, + key: "full_rollout", + rules: [{ ...ROLLOUT_FLAG.rules[0], rollout: { percentage: 100 } }], + }); + await call(mf, "getBooleanValue", "full_rollout", false, { + targetingKey: "0", + }); + expect(warnings).toEqual([]); + await admin.createFlag(ROLLOUT_FLAG); + for (const targetingKey of ["0", "1"]) { + await call(mf, "getBooleanValue", ROLLOUT_FLAG.key, false, { + targetingKey, + }); + } + expect(warnings).toEqual([ + "Flagship: flag 'rollout_test' has a percentage rollout, but the local flag store has no account tag, so its buckets will not match your remote app. Run `wrangler flagship flags pull` to seed the store.", + ]); + }); +});