From fa27be1a0032f98447cc6eda78896e646fa73c1a Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Mon, 24 Aug 2026 12:04:25 +0530 Subject: [PATCH 1/7] feat: simulate Flagship bindings locally --- .changeset/local-flagship-runtime.md | 7 + packages/miniflare/src/index.ts | 37 + .../miniflare/src/plugins/flagship/index.ts | 132 +++- .../miniflare/src/workers/flagship/admin.ts | 23 + .../src/workers/flagship/binding.worker.ts | 290 ++++++++ .../src/workers/flagship/constants.ts | 1 + .../src/workers/flagship/evaluate.ts | 308 ++++++++ .../miniflare/src/workers/flagship/flags.ts | 265 +++++++ .../src/workers/flagship/object.worker.ts | 184 +++++ .../test/plugins/flagship/evaluate.spec.ts | 269 +++++++ .../test/plugins/flagship/index.spec.ts | 669 +++++++++++++++++- 11 files changed, 2167 insertions(+), 18 deletions(-) create mode 100644 .changeset/local-flagship-runtime.md create mode 100644 packages/miniflare/src/workers/flagship/admin.ts create mode 100644 packages/miniflare/src/workers/flagship/binding.worker.ts create mode 100644 packages/miniflare/src/workers/flagship/constants.ts create mode 100644 packages/miniflare/src/workers/flagship/evaluate.ts create mode 100644 packages/miniflare/src/workers/flagship/flags.ts create mode 100644 packages/miniflare/src/workers/flagship/object.worker.ts create mode 100644 packages/miniflare/test/plugins/flagship/evaluate.spec.ts 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..6a13bba6915 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_OBJECT_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:object`; +const FLAGSHIP_STORAGE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}: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..5d44cc5f567 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/binding.worker.ts @@ -0,0 +1,290 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { ADMIN_API } from "./constants"; +import { + evaluateFlag, + FlagConfigError, + matchesType, + TypeCastError, +} from "./evaluate"; +import { flagNotFoundMessage, toEvalFlag } from "./flags"; +import type { FlagshipAdmin } from "./admin"; +import type { + ErrorCode, + EvaluationContext, + EvaluationDetails, + FlagType, + FlagValue, +} from "./evaluate"; +import type { Flag, FlagChanges, FlagInput } from "./flags"; +import type { FlagshipObject, WriteResult } from "./object.worker"; + +interface Env { + config: { appId: string; accountTag: string }; + store: DurableObjectNamespace; +} + +// `name` is deliberately left as "Error": workerd's RPC serialisation prefixes +// unrecognised error names onto the message, which leaks into CLI output. +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( + toEvalFlag(flag), + context, + accountTag ?? this.env.config.accountTag + ); + return { flagKey, value, variant, reason }; + } + + async #typedDetails( + flagKey: string, + defaultValue: T, + expectedType: FlagType, + context?: EvaluationContext + ): Promise> { + let result: EvaluationDetails; + try { + result = await this.#evaluate(flagKey, context ?? {}); + } catch (error) { + const errorCode = errorCodeFor(error); + if (errorCode === undefined) { + throw error; + } + return { + flagKey, + value: defaultValue, + variant: "default", + reason: "ERROR", + errorCode, + errorMessage: (error as Error).message, + }; + } + + if (!matchesType(result.value, expectedType)) { + return { + flagKey, + value: defaultValue, + variant: "default", + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: new TypeCastError(flagKey, expectedType, result.value) + .message, + }; + } + + return { + flagKey: result.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; + const 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); + } + }; + + return { + listFlags: (): Promise => stub.list(), + getFlag: async (flagKey: string): Promise => { + const flag = await stub.get(flagKey); + if (flag === null) { + throw new FlagNotFoundError(flagKey); + } + return flag; + }, + getAccountTag: (): Promise => stub.getAccountTag(), + setAccountTag: (accountTag: string): Promise => { + validateAccountTag(accountTag); + return stub.setAccountTag(accountTag); + }, + createFlag: async (input: FlagInput): Promise => + unwrap(await stub.create(input), input.key), + updateFlag: async (flagKey: string, input: FlagInput): Promise => + unwrap(await stub.update(flagKey, input), flagKey), + patchFlag: async (flagKey: string, changes: FlagChanges): Promise => + unwrap(await stub.patch(flagKey, changes), flagKey), + putFlag: async (input: FlagInput): Promise => + unwrap(await 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..7c5578a5e54 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/evaluate.ts @@ -0,0 +1,308 @@ +// Vendored from the Flagship data plane (`packages/data-plane/src/evaluate.ts`) +// at commit f32a8bf1607a7493175ea3a919f56dcd6b8a4fca. +// +// The hashing and rule-matching behaviour must stay byte-for-byte compatible +// with production, otherwise local percentage rollouts bucket differently to +// deployed Workers. `test/plugins/flagship/evaluate.spec.ts` pins the upstream +// hash vectors — update this file and those vectors together. + +export type FlagValue = + | boolean + | string + | number + | Record + | unknown[]; + +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 interface EvaluationDetails { + flagKey: string; + value: T; + variant: string; + reason: EvaluationReason; + errorCode?: ErrorCode; + errorMessage?: string; +} + +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 EvalRule { + conditions: Condition[]; + serve_variation: string; + rollout?: Rollout; +} + +export interface EvalFlag { + key: string; + enabled: boolean; + default_variation: string; + variations: Record; + rules: EvalRule[]; +} + +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, + 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; + + for (const rule of flagDef.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 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..028902c6c49 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/flags.ts @@ -0,0 +1,265 @@ +import type { + Condition, + EvalFlag, + FlagValue, + Operator, + Rollout, +} from "./evaluate"; + +export type FlagType = "boolean" | "string" | "number" | "json"; + +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; + +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"; + } +} + +export function toEvalFlag(flag: Flag): EvalFlag { + return { + key: flag.key, + enabled: flag.enabled, + default_variation: flag.default_variation, + variations: flag.variations, + rules: [...flag.rules] + .sort((a, b) => a.priority - b.priority) + .map(({ conditions, serve_variation, rollout }) => ({ + conditions, + serve_variation, + rollout, + })), + }; +} + +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`); + } +} + +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 (!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, + // The evaluator walks this array in order, so `priority` must decide it. + rules: [...input.rules].sort((a, b) => a.priority - b.priority), + type: getFlagType(input.variations), + updated_at: new Date().toISOString(), + }; +} + +export type { Condition, FlagValue, Rollout }; 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..47ab6d3e7b8 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/object.worker.ts @@ -0,0 +1,184 @@ +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" }; + +function validate(input: FlagInput): { message: string } | null { + try { + validateFlagInput(input); + return null; + } catch (error) { + return { 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 = validate(input); + if (invalid !== null) { + return { status: "invalid", message: invalid.message }; + } + if (this.get(input.key) !== null) { + return { status: "exists" }; + } + return { status: "written", flag: this.#write(toStoredFlag(input)) }; + } + + update(key: string, input: FlagInput): WriteResult { + if (this.get(key) === null) { + return { status: "missing" }; + } + const next = { ...input, key }; + const invalid = validate(next); + if (invalid !== null) { + return { status: "invalid", message: invalid.message }; + } + return { status: "written", flag: this.#write(toStoredFlag(next)) }; + } + + 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, + }; + const invalid = validate(next); + if (invalid !== null) { + return { status: "invalid", message: invalid.message }; + } + return { status: "written", flag: this.#write(toStoredFlag(next)) }; + } + + put(input: FlagInput): WriteResult { + const invalid = validate(input); + if (invalid !== null) { + return { status: "invalid", message: invalid.message }; + } + return { status: "written", flag: this.#write(toStoredFlag(input)) }; + } + + putAll( + inputs: FlagInput[], + accountTag: string + ): { status: "written" } | { status: "invalid"; message: string } { + for (const input of inputs) { + const invalid = validate(input); + if (invalid !== null) { + return { status: "invalid", message: invalid.message }; + } + } + 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; + } + + #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..b3761aaf061 --- /dev/null +++ b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts @@ -0,0 +1,269 @@ +import { describe, test } from "vitest"; +import { evaluateFlag } from "../../../src/workers/flagship/evaluate"; +import type { + EvalFlag, + EvalRule, + EvaluationContext, +} from "../../../src/workers/flagship/evaluate"; + +// Account tag used by the upstream Flagship data-plane test suite. The bucket +// expectations below are copied from there and pin the vendored MurmurHash3 +// implementation. +const UPSTREAM_ACCOUNT_ID = "aaaabbbbccccdddd1111222233334444"; + +function flag(overrides: Partial = {}): EvalFlag { + return { + key: "test", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [], + ...overrides, + }; +} + +function rolloutFlag(percentage: number, attribute?: string): EvalFlag { + return flag({ + key: "rollout_test", + rules: [ + { + conditions: [], + serve_variation: "on", + rollout: { percentage, attribute }, + }, + ], + }); +} + +/** + * Recover the exact rollout bucket for a targeting key. A rule is included + * when `bucket < percentage`, so the smallest including percentage is + * `bucket + 1`. + */ +function bucketFor(targetingKey: unknown): number { + for (let percentage = 1; percentage <= 100; percentage++) { + const { reason } = evaluateFlag( + rolloutFlag(percentage), + { targetingKey } as EvaluationContext, + UPSTREAM_ACCOUNT_ID + ); + if (reason === "SPLIT") { + return percentage - 1; + } + } + return 100; +} + +describe("flagship evaluation", () => { + test("serves the default variation when no rule matches", ({ expect }) => { + expect(evaluateFlag(flag(), {}, "local")).toEqual({ + value: false, + variant: "off", + reason: "DEFAULT", + }); + }); + + test("serves the default variation and skips rules when disabled", ({ + expect, + }) => { + const disabled = flag({ + enabled: false, + rules: [{ conditions: [], serve_variation: "on" }], + }); + expect(evaluateFlag(disabled, {}, "local")).toEqual({ + value: false, + variant: "off", + reason: "DISABLED", + }); + }); + + test("serves the first matching rule in array order", ({ expect }) => { + const multi = flag({ + variations: { a: "a", b: "b", off: "off" }, + rules: [ + { + conditions: [{ attribute: "userId", operator: "equals", value: "1" }], + serve_variation: "a", + }, + { conditions: [], serve_variation: "b" }, + ], + }); + expect(evaluateFlag(multi, { userId: "1" }, "local")).toMatchObject({ + variant: "a", + reason: "TARGETING_MATCH", + }); + expect(evaluateFlag(multi, { userId: "2" }, "local")).toMatchObject({ + variant: "b", + reason: "TARGETING_MATCH", + }); + }); + + test("throws when a served variation is not defined", ({ expect }) => { + const broken = flag({ default_variation: "missing" }); + expect(() => evaluateFlag(broken, {}, "local")).toThrow( + "Flag 'test' variation 'missing' is not defined" + ); + }); + + describe("conditions", () => { + function matches( + conditions: EvalRule["conditions"], + context: EvaluationContext + ): boolean { + const result = evaluateFlag( + flag({ rules: [{ conditions, serve_variation: "on" }] }), + context, + "local" + ); + return result.reason === "TARGETING_MATCH"; + } + + test("missing context attributes never match", ({ expect }) => { + expect( + matches([{ attribute: "plan", operator: "equals", value: "pro" }], {}) + ).toBe(false); + }); + + test("comparisons coerce through String()", ({ expect }) => { + expect( + matches([{ attribute: "id", operator: "equals", value: 42 }], { + id: "42", + }) + ).toBe(true); + }); + + test("in and not_in require arrays", ({ expect }) => { + expect( + matches([{ attribute: "country", operator: "in", value: ["US"] }], { + country: "US", + }) + ).toBe(true); + expect( + matches([{ attribute: "country", operator: "in", value: "US" }], { + country: "US", + }) + ).toBe(false); + expect( + matches([{ attribute: "country", operator: "not_in", value: [] }], { + country: "US", + }) + ).toBe(true); + }); + + test("ISO-8601 targets compare as timestamps", ({ expect }) => { + expect( + matches( + [ + { + attribute: "now", + operator: "greater_than", + value: "2025-05-01T15:00:00Z", + }, + ], + { now: "2025-06-01T15:00:00Z" } + ) + ).toBe(true); + }); + + test("empty AND clauses match, empty OR clauses do not", ({ expect }) => { + expect(matches([{ logical_operator: "AND", clauses: [] }], {})).toBe( + true + ); + expect(matches([{ logical_operator: "OR", clauses: [] }], {})).toBe( + false + ); + }); + + test("unknown operators do not match", ({ expect }) => { + expect( + matches( + [ + { + attribute: "id", + operator: "spaceship" as never, + value: "1", + }, + ], + { id: "1" } + ) + ).toBe(false); + }); + }); + + describe("rollouts", () => { + // Buckets pinned against the upstream Flagship suite for flag + // `rollout_test` under UPSTREAM_ACCOUNT_ID (hash seed 45). + test("buckets match the upstream hash vectors", ({ expect }) => { + const buckets = Object.fromEntries( + ["0", "1", "2", "", "日本語", "héllo", "false"].map((key) => [ + key, + bucketFor(key), + ]) + ); + expect(buckets).toEqual({ + "0": 15, + "1": 8, + "2": 91, + "": 50, + 日本語: 9, + héllo: 73, + false: 33, + }); + }); + + test("non-string targeting keys hash as their string form", ({ + expect, + }) => { + expect(bucketFor(0)).toBe(bucketFor("0")); + expect(bucketFor(false)).toBe(bucketFor("false")); + }); + + test("100 percent always matches and reports SPLIT", ({ expect }) => { + for (const targetingKey of ["0", "1", "50", "99", "999"]) { + expect( + evaluateFlag(rolloutFlag(100), { targetingKey }, UPSTREAM_ACCOUNT_ID) + ).toMatchObject({ reason: "SPLIT" }); + } + }); + + test("0 percent never matches", ({ expect }) => { + for (const targetingKey of ["0", "1", "50", "99", "999"]) { + expect( + evaluateFlag(rolloutFlag(0), { targetingKey }, UPSTREAM_ACCOUNT_ID) + ).toMatchObject({ reason: "DEFAULT" }); + } + }); + + test("a missing targeting key buckets randomly", ({ expect }) => { + const { reason } = evaluateFlag(rolloutFlag(50), {}, UPSTREAM_ACCOUNT_ID); + expect(["SPLIT", "DEFAULT"]).toContain(reason); + }); + + test("the seed varies by account and by flag", ({ expect }) => { + const keys = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + const bucketsFor = (flagKey: string, accountId: string) => + keys.map((targetingKey) => { + const rollout = rolloutFlag(50); + rollout.key = flagKey; + const { reason } = evaluateFlag(rollout, { targetingKey }, accountId); + return reason; + }); + + const baseline = bucketsFor("rollout_test", UPSTREAM_ACCOUNT_ID); + expect(bucketsFor("rollout_test", "local")).not.toEqual(baseline); + expect(bucketsFor("other_flag", UPSTREAM_ACCOUNT_ID)).not.toEqual( + baseline + ); + }); + + test("a custom rollout attribute replaces targetingKey", ({ expect }) => { + const byUserId = rolloutFlag(50, "userId"); + expect( + evaluateFlag(byUserId, { userId: "1" }, UPSTREAM_ACCOUNT_ID) + ).toMatchObject({ reason: "SPLIT" }); + expect( + evaluateFlag(byUserId, { userId: "2" }, UPSTREAM_ACCOUNT_ID) + ).toMatchObject({ reason: "DEFAULT" }); + }); + }); +}); diff --git a/packages/miniflare/test/plugins/flagship/index.spec.ts b/packages/miniflare/test/plugins/flagship/index.spec.ts index 48d49416c51..6f8f0e6ac7b 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,623 @@ test("flagship: accepts config with no flagship binding", ({ expect }) => { }); expect(result.success).toBe(true); }); + +// Account tag and bucket expectations copied from the upstream Flagship +// data-plane suite, so seeding the local store must reproduce them exactly. +const UPSTREAM_ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; +const UPSTREAM_BUCKETS: Record = { + "0": 15, + "1": 8, + "2": 91, + "": 50, + 日本語: 9, + héllo: 73, + false: 33, +}; + +const ROLLOUT_PERCENTAGE = 50; + +const ROLLOUT_FLAG: FlagInput = { + key: "rollout_test", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: ROLLOUT_PERCENTAGE }, + }, + ], +}; + +/** + * Await an admin API call and return the message it rejects with. Handing the + * RPC promise to Vitest's `.rejects` matcher instead leaves it unhandled. + */ +async function rejection(call: () => Promise): Promise { + try { + await call(); + } catch (error) { + return (error as Error).message; + } + throw new Error("expected the call to reject"); +} + +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()); + } + const { result } = (await response.json()) as { result: unknown }; + return result; +} + +describe("flagship plugin", () => { + test("evaluates seeded flags through the binding", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.createFlag(BOOL_FLAG); + + expect( + await call(mf, "getBooleanValue", "new_checkout", false, { plan: "pro" }) + ).toBe(true); + expect( + await call(mf, "getBooleanValue", "new_checkout", false, { + plan: "free", + }) + ).toBe(false); + expect( + await call(mf, "getBooleanDetails", "new_checkout", false, { + plan: "pro", + }) + ).toEqual({ + flagKey: "new_checkout", + value: true, + variant: "on", + reason: "TARGETING_MATCH", + }); + }); + + test("returns the default value for unknown flags", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + + expect(await call(mf, "getStringValue", "nope", "fallback")).toBe( + "fallback" + ); + expect(await call(mf, "getStringDetails", "nope", "fallback")).toEqual({ + flagKey: "nope", + value: "fallback", + variant: "default", + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + errorMessage: "Flag 'nope' not found", + }); + }); + + test("returns the default value when the type does not match", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.createFlag(BOOL_FLAG); + + expect(await call(mf, "getStringDetails", "new_checkout", "x")).toEqual({ + flagKey: "new_checkout", + value: "x", + variant: "default", + reason: "ERROR", + errorCode: "TYPE_MISMATCH", + errorMessage: "Flag 'new_checkout' has type 'boolean', expected 'string'", + }); + }); + + test("get throws for unknown flags without a default value", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + + await expect(call(mf, "get", "nope")).rejects.toThrow( + "Flag 'nope' not found" + ); + expect(await call(mf, "get", "nope", "fallback")).toBe("fallback"); + }); + + test("isolates flags by app id and shares them within one", async ({ + expect, + }) => { + const mf = new Miniflare( + options({ + FLAGS: { type: "flagship", id: "app-a" }, + OTHER: { type: "flagship", id: "app-b" }, + ALIAS: { type: "flagship", id: "app-a" }, + }) + ); + useDispose(mf); + + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.createFlag(BOOL_FLAG); + + expect( + await (await mf.getFlagshipBindingAPI("ALIAS"))().listFlags() + ).toEqual([expect.objectContaining({ key: "new_checkout" })]); + expect( + await (await mf.getFlagshipBindingAPI("OTHER"))().listFlags() + ).toEqual([]); + }); + + test("persists flags on the file system", async ({ expect }) => { + const tmp = await useTmp(); + const opts = { ...options(), resourcePersistencePath: tmp }; + + const mf1 = new Miniflare(opts); + const admin1 = (await mf1.getFlagshipBindingAPI("FLAGS"))(); + await admin1.createFlag(BOOL_FLAG); + await mf1.dispose(); + + const mf2 = new Miniflare(opts); + useDispose(mf2); + expect( + await call(mf2, "getBooleanValue", "new_checkout", false, { + plan: "pro", + }) + ).toBe(true); + }); + + describe("admin API", () => { + test("supports the full flag lifecycle", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + expect(await admin.listFlags()).toEqual([]); + + const created = await admin.createFlag(BOOL_FLAG); + expect(created).toMatchObject({ key: "new_checkout", enabled: true }); + expect(await admin.getFlag("new_checkout")).toEqual(created); + + await admin.updateFlag("new_checkout", { ...BOOL_FLAG, enabled: false }); + expect(await admin.getFlag("new_checkout")).toMatchObject({ + enabled: false, + }); + expect(await admin.evaluateFlag("new_checkout", { plan: "pro" })).toEqual( + { + flagKey: "new_checkout", + value: false, + variant: "off", + reason: "DISABLED", + } + ); + + await admin.deleteFlag("new_checkout"); + expect(await admin.listFlags()).toEqual([]); + }); + + test("rejects invalid operations", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + await admin.createFlag(BOOL_FLAG); + expect(await rejection(() => admin.createFlag(BOOL_FLAG))).toBe( + "Flag 'new_checkout' already exists" + ); + expect( + await rejection(() => admin.updateFlag("missing", BOOL_FLAG)) + ).toBe("Flag 'missing' not found"); + expect(await rejection(() => admin.deleteFlag("missing"))).toBe( + "Flag 'missing' not found" + ); + }); + + test("enforces the control-plane write invariants", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + const put = (input: Partial) => + rejection(() => admin.putFlag({ ...BOOL_FLAG, ...input } as FlagInput)); + + expect(await put({ key: "not valid!" })).toBe( + "Flag key 'not valid!' must be 1-64 alphanumeric, hyphen or underscore characters" + ); + expect(await put({ variations: {} })).toBe( + "Flag 'new_checkout' must define at least one variation" + ); + expect(await put({ variations: { on: true, off: "no" } })).toBe( + "Flag 'new_checkout' variations must all share the same type" + ); + expect(await put({ variations: { on: null, off: null } })).toBe( + "Flag 'new_checkout' variations cannot be null" + ); + expect(await put({ default_variation: "nope" })).toBe( + "Flag 'new_checkout' default variation 'nope' is not defined" + ); + expect( + await put({ + rules: [{ ...BOOL_FLAG.rules[0], serve_variation: "nope" }], + }) + ).toBe("Flag 'new_checkout' rule serves undefined variation 'nope'"); + expect( + await put({ rules: [{ ...BOOL_FLAG.rules[0], priority: 0 }] }) + ).toBe( + "Flag 'new_checkout' rule priorities must be integers greater than or equal to 1" + ); + expect( + await put({ rules: [BOOL_FLAG.rules[0], { ...BOOL_FLAG.rules[0] }] }) + ).toBe("Flag 'new_checkout' has duplicate rule priority 1"); + expect( + await put({ + rules: [ + { priority: 1, conditions: [], serve_variation: "on" }, + { ...BOOL_FLAG.rules[0], priority: 2 }, + ], + }) + ).toBe( + "Flag 'new_checkout' has targeting rules after a rule with no conditions" + ); + 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); + expect( + await put({ + rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 101 } }], + }) + ).toBe( + "Flag 'new_checkout' rollout percentage must be a number between 0 and 100" + ); + expect( + await put({ + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [{ logical_operator: "AND" } as never], + }, + ], + }) + ).toBe( + "Flag 'new_checkout' has a 'AND' condition without a list of clauses" + ); + expect( + await put({ + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [ + { attribute: "plan", operator: "sorta_equals", value: "pro" }, + ] as never, + }, + ], + }) + ).toBe( + "Flag 'new_checkout' has a condition with an unknown operator 'sorta_equals'" + ); + expect( + await put({ + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [ + { attribute: "plan", operator: "in", value: "pro" }, + ] as never, + }, + ], + }) + ).toBe( + "Flag 'new_checkout' has a 'in' condition whose value is not a list" + ); + }); + + test("putFlag upserts", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + await admin.putFlag(BOOL_FLAG); + await admin.putFlag({ ...BOOL_FLAG, enabled: false }); + expect(await admin.listFlags()).toEqual([ + expect.objectContaining({ key: "new_checkout", enabled: false }), + ]); + }); + }); + + describe("rollout bucketing", () => { + test("reproduces the remote app's buckets once the store is seeded", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.setAccountTag(UPSTREAM_ACCOUNT_TAG); + await admin.createFlag(ROLLOUT_FLAG); + + // `included` is the ground truth from the upstream hash vectors: a + // rollout includes a targeting key when its bucket is below the + // percentage. Evaluating through the binding must agree. + for (const [targetingKey, bucket] of Object.entries(UPSTREAM_BUCKETS)) { + const included = bucket < ROLLOUT_PERCENTAGE; + expect({ + targetingKey, + value: await call(mf, "getBooleanValue", "rollout_test", false, { + targetingKey, + }), + }).toEqual({ targetingKey, value: included }); + } + }); + + test("seeding changes which keys land in the rollout", async ({ + expect, + }) => { + const keys = Object.keys(UPSTREAM_BUCKETS); + const evaluateAll = async (mf: Miniflare) => { + const results: boolean[] = []; + for (const targetingKey of keys) { + results.push( + (await call(mf, "getBooleanValue", "rollout_test", false, { + targetingKey, + })) as boolean + ); + } + return results; + }; + + const unseeded = new Miniflare(options()); + useDispose(unseeded); + await ( + await unseeded.getFlagshipBindingAPI("FLAGS") + )().createFlag(ROLLOUT_FLAG); + + const seeded = new Miniflare(options()); + useDispose(seeded); + const seededAdmin = (await seeded.getFlagshipBindingAPI("FLAGS"))(); + await seededAdmin.setAccountTag(UPSTREAM_ACCOUNT_TAG); + await seededAdmin.createFlag(ROLLOUT_FLAG); + + expect(await evaluateAll(unseeded)).not.toEqual( + await evaluateAll(seeded) + ); + }); + + test("exposes and persists the account tag", async ({ expect }) => { + const tmp = await useTmp(); + const opts = { ...options(), resourcePersistencePath: tmp }; + + const mf1 = new Miniflare(opts); + const admin1 = (await mf1.getFlagshipBindingAPI("FLAGS"))(); + expect(await admin1.getAccountTag()).toBe(null); + await admin1.setAccountTag(UPSTREAM_ACCOUNT_TAG); + expect(await admin1.getAccountTag()).toBe(UPSTREAM_ACCOUNT_TAG); + await mf1.dispose(); + + const mf2 = new Miniflare(opts); + useDispose(mf2); + expect( + await (await mf2.getFlagshipBindingAPI("FLAGS"))().getAccountTag() + ).toBe(UPSTREAM_ACCOUNT_TAG); + }); + + test("keeps the account tag out of the flag listing", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + await admin.setAccountTag(UPSTREAM_ACCOUNT_TAG); + expect(await admin.listFlags()).toEqual([]); + }); + + test("isolates the account tag by app id", async ({ expect }) => { + const mf = new Miniflare( + options({ + FLAGS: { type: "flagship", id: "app-a" }, + OTHER: { type: "flagship", id: "app-b" }, + }) + ); + useDispose(mf); + + await ( + await mf.getFlagshipBindingAPI("FLAGS") + )().setAccountTag(UPSTREAM_ACCOUNT_TAG); + expect( + await (await mf.getFlagshipBindingAPI("OTHER"))().getAccountTag() + ).toBe(null); + }); + + test("rejects an empty account tag", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + expect(await rejection(() => admin.setAccountTag(""))).toBe( + "accountTag must be a non-empty string" + ); + }); + }); + + describe("unseeded bucketing warning", () => { + /** + * Build an instance capturing the worker's structured logs, which is where + * `console.warn` from inside the binding surfaces. + */ + function withCapturedLogs() { + const warnings: string[] = []; + const mf = new Miniflare({ + ...options(), + handleStructuredLogs: ({ level, message }) => { + if (level === "warn") { + warnings.push(message); + } + }, + }); + return { mf, warnings }; + } + + async function evaluateRollout(mf: Miniflare, targetingKey: string) { + return call(mf, "getBooleanValue", "rollout_test", false, { + targetingKey, + }); + } + + test("warns once per session when a rollout is evaluated unseeded", async ({ + expect, + }) => { + const { mf, warnings } = withCapturedLogs(); + useDispose(mf); + await ( + await mf.getFlagshipBindingAPI("FLAGS") + )().createFlag(ROLLOUT_FLAG); + + for (const targetingKey of ["0", "1", "2"]) { + await evaluateRollout(mf, 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.", + ]); + }); + + test("stays quiet once the store is seeded", async ({ expect }) => { + const { mf, warnings } = withCapturedLogs(); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.setAccountTag(UPSTREAM_ACCOUNT_TAG); + await admin.createFlag(ROLLOUT_FLAG); + + await evaluateRollout(mf, "0"); + + expect(warnings).toEqual([]); + }); + + test("stays quiet for flags without a partial rollout", async ({ + expect, + }) => { + const { mf, warnings } = withCapturedLogs(); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.createFlag(BOOL_FLAG); + await admin.createFlag({ + ...ROLLOUT_FLAG, + key: "full_rollout", + rules: [{ ...ROLLOUT_FLAG.rules[0], rollout: { percentage: 100 } }], + }); + + await call(mf, "getBooleanValue", "new_checkout", false, { plan: "pro" }); + await call(mf, "getBooleanValue", "full_rollout", false, { + targetingKey: "0", + }); + + expect(warnings).toEqual([]); + }); + }); + describe("persistence", () => { + test("sees writes made by another instance sharing the store", async ({ + expect, + }) => { + const tmp = await useTmp(); + const first = new Miniflare({ + ...options(), + resourcePersistencePath: tmp, + }); + useDispose(first); + const firstAdmin = (await first.getFlagshipBindingAPI("FLAGS"))(); + expect(await firstAdmin.listFlags()).toEqual([]); + + const second = new Miniflare({ + ...options(), + resourcePersistencePath: tmp, + }); + const secondAdmin = (await second.getFlagshipBindingAPI("FLAGS"))(); + await secondAdmin.createFlag(BOOL_FLAG); + await second.dispose(); + + expect( + (await firstAdmin.listFlags()).map((flag) => flag.key) + ).toStrictEqual(["new_checkout"]); + }); + + test("accepts a fractional rollout percentage", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + const flag = await admin.putFlag({ + ...BOOL_FLAG, + rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 33.333333 } }], + }); + + expect(flag.rules[0].rollout).toEqual({ percentage: 33.333333 }); + }); + + test("patchFlag only changes the fields it is given", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.createFlag(BOOL_FLAG); + + await admin.patchFlag("new_checkout", { description: "now described" }); + + expect(await admin.getFlag("new_checkout")).toEqual( + expect.objectContaining({ + description: "now described", + enabled: true, + rules: BOOL_FLAG.rules, + }) + ); + }); + + test("putFlags rejects the whole batch when one flag is invalid", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + expect( + await rejection(() => + admin.putFlags( + [BOOL_FLAG, { ...BOOL_FLAG, key: "second", variations: {} }], + "tag" + ) + ) + ).toBe("Flag 'second' must define at least one variation"); + + expect(await admin.listFlags()).toEqual([]); + expect(await admin.getAccountTag()).toBeNull(); + }); + + test("putFlags rejects an empty account tag", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + + expect(await rejection(() => admin.putFlags([BOOL_FLAG], ""))).toBe( + "accountTag must be a non-empty string" + ); + expect(await admin.listFlags()).toEqual([]); + expect(await admin.getAccountTag()).toBeNull(); + }); + }); +}); From f40265ecfd43c7d0da2731d4c12dc64984a9205b Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Tue, 25 Aug 2026 12:28:04 +0530 Subject: [PATCH 2/7] refactor: simplify local Flagship runtime --- .../src/workers/flagship/binding.worker.ts | 81 +- .../src/workers/flagship/evaluate.ts | 82 +- .../miniflare/src/workers/flagship/flags.ts | 66 +- .../src/workers/flagship/object.worker.ts | 51 +- .../test/plugins/flagship/evaluate.spec.ts | 335 +++----- .../test/plugins/flagship/index.spec.ts | 804 +++++++----------- 6 files changed, 554 insertions(+), 865 deletions(-) diff --git a/packages/miniflare/src/workers/flagship/binding.worker.ts b/packages/miniflare/src/workers/flagship/binding.worker.ts index 5d44cc5f567..780b761512b 100644 --- a/packages/miniflare/src/workers/flagship/binding.worker.ts +++ b/packages/miniflare/src/workers/flagship/binding.worker.ts @@ -6,7 +6,7 @@ import { matchesType, TypeCastError, } from "./evaluate"; -import { flagNotFoundMessage, toEvalFlag } from "./flags"; +import { flagNotFoundMessage } from "./flags"; import type { FlagshipAdmin } from "./admin"; import type { ErrorCode, @@ -23,8 +23,7 @@ interface Env { store: DurableObjectNamespace; } -// `name` is deliberately left as "Error": workerd's RPC serialisation prefixes -// unrecognised error names onto the message, which leaks into CLI output. +// Keep the default name: workerd prefixes custom error names during RPC serialization. class FlagNotFoundError extends Error { constructor(flagKey: string) { super(flagNotFoundMessage(flagKey)); @@ -94,7 +93,7 @@ export class FlagshipBinding extends WorkerEntrypoint { warnIfBucketingUnseeded(flag); } const { value, variant, reason } = evaluateFlag( - toEvalFlag(flag), + flag, context, accountTag ?? this.env.config.accountTag ); @@ -107,6 +106,17 @@ export class FlagshipBinding extends WorkerEntrypoint { 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 ?? {}); @@ -115,30 +125,18 @@ export class FlagshipBinding extends WorkerEntrypoint { if (errorCode === undefined) { throw error; } - return { - flagKey, - value: defaultValue, - variant: "default", - reason: "ERROR", - errorCode, - errorMessage: (error as Error).message, - }; + return failure(errorCode, (error as Error).message); } if (!matchesType(result.value, expectedType)) { - return { - flagKey, - value: defaultValue, - variant: "default", - reason: "ERROR", - errorCode: "TYPE_MISMATCH", - errorMessage: new TypeCastError(flagKey, expectedType, result.value) - .message, - }; + return failure( + "TYPE_MISMATCH", + new TypeCastError(flagKey, expectedType, result.value).message + ); } return { - flagKey: result.flagKey, + flagKey, value: result.value as T, variant: result.variant, reason: result.reason, @@ -230,7 +228,7 @@ export class FlagshipBinding extends WorkerEntrypoint { [ADMIN_API](): FlagshipAdmin { const stub = this.#stub; - const unwrap = (result: WriteResult, flagKey: string): Flag => { + function unwrap(result: WriteResult, flagKey: string): Flag { switch (result.status) { case "written": return result.flag; @@ -241,30 +239,35 @@ export class FlagshipBinding extends WorkerEntrypoint { 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: async (flagKey: string): Promise => { - const flag = await stub.get(flagKey); - if (flag === null) { - throw new FlagNotFoundError(flagKey); - } - return flag; - }, + getFlag, getAccountTag: (): Promise => stub.getAccountTag(), setAccountTag: (accountTag: string): Promise => { validateAccountTag(accountTag); return stub.setAccountTag(accountTag); }, - createFlag: async (input: FlagInput): Promise => - unwrap(await stub.create(input), input.key), - updateFlag: async (flagKey: string, input: FlagInput): Promise => - unwrap(await stub.update(flagKey, input), flagKey), - patchFlag: async (flagKey: string, changes: FlagChanges): Promise => - unwrap(await stub.patch(flagKey, changes), flagKey), - putFlag: async (input: FlagInput): Promise => - unwrap(await stub.put(input), input.key), + 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 diff --git a/packages/miniflare/src/workers/flagship/evaluate.ts b/packages/miniflare/src/workers/flagship/evaluate.ts index 7c5578a5e54..9033a5d1e92 100644 --- a/packages/miniflare/src/workers/flagship/evaluate.ts +++ b/packages/miniflare/src/workers/flagship/evaluate.ts @@ -1,17 +1,7 @@ -// Vendored from the Flagship data plane (`packages/data-plane/src/evaluate.ts`) -// at commit f32a8bf1607a7493175ea3a919f56dcd6b8a4fca. -// -// The hashing and rule-matching behaviour must stay byte-for-byte compatible -// with production, otherwise local percentage rollouts bucket differently to -// deployed Workers. `test/plugins/flagship/evaluate.spec.ts` pins the upstream -// hash vectors — update this file and those vectors together. +import type { Condition, FlagInput, FlagValue, Rule } from "./flags"; -export type FlagValue = - | boolean - | string - | number - | Record - | unknown[]; +// Vendored from Flagship data-plane commit f32a8bf1607a7493175ea3a919f56dcd6b8a4fca. +// Hashing and matching must remain byte-compatible with production. export type EvaluationReason = | "TARGETING_MATCH" @@ -30,6 +20,9 @@ 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; @@ -39,51 +32,6 @@ export interface EvaluationDetails { errorMessage?: string; } -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 EvalRule { - conditions: Condition[]; - serve_variation: string; - rollout?: Rollout; -} - -export interface EvalFlag { - key: string; - enabled: boolean; - default_variation: string; - variations: Record; - rules: EvalRule[]; -} - export class TypeCastError extends Error { constructor(flagKey: string, expectedType: string, actualValue: unknown) { super( @@ -229,7 +177,7 @@ function evaluateCondition( } export function evaluateFlag( - flagDef: EvalFlag, + flagDef: EvalFlag | FlagInput, context: EvaluationContext, accountId: string ): { value: FlagValue; variant: string; reason: EvaluationReason } { @@ -255,7 +203,12 @@ export function evaluateFlag( // buckets across flags, preventing correlated rollouts. let seed: number | undefined; - for (const rule of flagDef.rules) { + 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) { @@ -292,6 +245,15 @@ export function evaluateFlag( 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": diff --git a/packages/miniflare/src/workers/flagship/flags.ts b/packages/miniflare/src/workers/flagship/flags.ts index 028902c6c49..7767cc128db 100644 --- a/packages/miniflare/src/workers/flagship/flags.ts +++ b/packages/miniflare/src/workers/flagship/flags.ts @@ -1,13 +1,43 @@ -import type { - Condition, - EvalFlag, - FlagValue, - Operator, - Rollout, -} from "./evaluate"; - 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[]; @@ -75,22 +105,6 @@ export function getFlagType(variations: Record): FlagType { } } -export function toEvalFlag(flag: Flag): EvalFlag { - return { - key: flag.key, - enabled: flag.enabled, - default_variation: flag.default_variation, - variations: flag.variations, - rules: [...flag.rules] - .sort((a, b) => a.priority - b.priority) - .map(({ conditions, serve_variation, rollout }) => ({ - conditions, - serve_variation, - rollout, - })), - }; -} - function validateCondition( key: string, condition: unknown, @@ -255,11 +269,9 @@ export function toStoredFlag(input: FlagInput): Flag { enabled: input.enabled, default_variation: input.default_variation, variations: input.variations, - // The evaluator walks this array in order, so `priority` must decide it. + // 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(), }; } - -export type { Condition, FlagValue, Rollout }; diff --git a/packages/miniflare/src/workers/flagship/object.worker.ts b/packages/miniflare/src/workers/flagship/object.worker.ts index 47ab6d3e7b8..eb6ead8cef2 100644 --- a/packages/miniflare/src/workers/flagship/object.worker.ts +++ b/packages/miniflare/src/workers/flagship/object.worker.ts @@ -21,12 +21,16 @@ export type WriteResult = | { status: "missing" } | { status: "exists" }; -function validate(input: FlagInput): { message: string } | null { +type InvalidResult = Extract; + +function invalidResult(input: FlagInput): InvalidResult | undefined { try { validateFlagInput(input); - return null; } catch (error) { - return { message: error instanceof Error ? error.message : String(error) }; + return { + status: "invalid", + message: error instanceof Error ? error.message : String(error), + }; } } @@ -82,26 +86,21 @@ export class FlagshipObject extends DurableObject { } create(input: FlagInput): WriteResult { - const invalid = validate(input); - if (invalid !== null) { - return { status: "invalid", message: invalid.message }; + const invalid = invalidResult(input); + if (invalid !== undefined) { + return invalid; } if (this.get(input.key) !== null) { return { status: "exists" }; } - return { status: "written", flag: this.#write(toStoredFlag(input)) }; + return this.#writeResult(input); } update(key: string, input: FlagInput): WriteResult { if (this.get(key) === null) { return { status: "missing" }; } - const next = { ...input, key }; - const invalid = validate(next); - if (invalid !== null) { - return { status: "invalid", message: invalid.message }; - } - return { status: "written", flag: this.#write(toStoredFlag(next)) }; + return this.#validateAndWrite({ ...input, key }); } patch(key: string, changes: FlagChanges): WriteResult { @@ -120,19 +119,11 @@ export class FlagshipObject extends DurableObject { variations: changes.variations ?? current.variations, rules: changes.rules ?? current.rules, }; - const invalid = validate(next); - if (invalid !== null) { - return { status: "invalid", message: invalid.message }; - } - return { status: "written", flag: this.#write(toStoredFlag(next)) }; + return this.#validateAndWrite(next); } put(input: FlagInput): WriteResult { - const invalid = validate(input); - if (invalid !== null) { - return { status: "invalid", message: invalid.message }; - } - return { status: "written", flag: this.#write(toStoredFlag(input)) }; + return this.#validateAndWrite(input); } putAll( @@ -140,9 +131,9 @@ export class FlagshipObject extends DurableObject { accountTag: string ): { status: "written" } | { status: "invalid"; message: string } { for (const input of inputs) { - const invalid = validate(input); - if (invalid !== null) { - return { status: "invalid", message: invalid.message }; + const invalid = invalidResult(input); + if (invalid !== undefined) { + return invalid; } } const stored = inputs.map(toStoredFlag); @@ -163,6 +154,14 @@ export class FlagshipObject extends DurableObject { 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 (?, ?) diff --git a/packages/miniflare/test/plugins/flagship/evaluate.spec.ts b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts index b3761aaf061..cba74f7ecd1 100644 --- a/packages/miniflare/test/plugins/flagship/evaluate.spec.ts +++ b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts @@ -1,17 +1,11 @@ import { describe, test } from "vitest"; import { evaluateFlag } from "../../../src/workers/flagship/evaluate"; -import type { - EvalFlag, - EvalRule, - EvaluationContext, -} from "../../../src/workers/flagship/evaluate"; +import type { EvaluationContext } from "../../../src/workers/flagship/evaluate"; +import type { FlagInput, Rule } from "../../../src/workers/flagship/flags"; -// Account tag used by the upstream Flagship data-plane test suite. The bucket -// expectations below are copied from there and pin the vendored MurmurHash3 -// implementation. -const UPSTREAM_ACCOUNT_ID = "aaaabbbbccccdddd1111222233334444"; +const ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; -function flag(overrides: Partial = {}): EvalFlag { +function flag(overrides: Partial = {}): FlagInput { return { key: "test", enabled: true, @@ -22,11 +16,12 @@ function flag(overrides: Partial = {}): EvalFlag { }; } -function rolloutFlag(percentage: number, attribute?: string): EvalFlag { +function rolloutFlag(percentage: number, attribute?: string): FlagInput { return flag({ key: "rollout_test", rules: [ { + priority: 1, conditions: [], serve_variation: "on", rollout: { percentage, attribute }, @@ -35,19 +30,25 @@ function rolloutFlag(percentage: number, attribute?: string): EvalFlag { }); } -/** - * Recover the exact rollout bucket for a targeting key. A rule is included - * when `bucket < percentage`, so the smallest including percentage is - * `bucket + 1`. - */ +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++) { - const { reason } = evaluateFlag( - rolloutFlag(percentage), - { targetingKey } as EvaluationContext, - UPSTREAM_ACCOUNT_ID - ); - if (reason === "SPLIT") { + if ( + evaluateFlag(rolloutFlag(percentage), { targetingKey }, ACCOUNT_TAG) + .reason === "SPLIT" + ) { return percentage - 1; } } @@ -55,152 +56,112 @@ function bucketFor(targetingKey: unknown): number { } describe("flagship evaluation", () => { - test("serves the default variation when no rule matches", ({ expect }) => { - expect(evaluateFlag(flag(), {}, "local")).toEqual({ - value: false, - variant: "off", - reason: "DEFAULT", - }); - }); - - test("serves the default variation and skips rules when disabled", ({ + test("serves defaults, disabled flags, and the first matching rule", ({ expect, }) => { - const disabled = flag({ - enabled: false, - rules: [{ conditions: [], serve_variation: "on" }], - }); - expect(evaluateFlag(disabled, {}, "local")).toEqual({ + expect(evaluateFlag(flag(), {}, "local")).toEqual({ value: false, variant: "off", - reason: "DISABLED", + reason: "DEFAULT", }); - }); + expect( + evaluateFlag( + flag({ + enabled: false, + rules: [{ priority: 1, conditions: [], serve_variation: "on" }], + }), + {}, + "local" + ) + ).toMatchObject({ variant: "off", reason: "DISABLED" }); - test("serves the first matching rule in array order", ({ expect }) => { - const multi = flag({ - variations: { a: "a", b: "b", off: "off" }, + const ordered = flag({ + variations: { first: "a", second: "b", off: "off" }, rules: [ { - conditions: [{ attribute: "userId", operator: "equals", value: "1" }], - serve_variation: "a", + priority: 1, + conditions: [{ attribute: "id", operator: "equals", value: 1 }], + serve_variation: "first", }, - { conditions: [], serve_variation: "b" }, + { priority: 2, conditions: [], serve_variation: "second" }, ], }); - expect(evaluateFlag(multi, { userId: "1" }, "local")).toMatchObject({ - variant: "a", - reason: "TARGETING_MATCH", - }); - expect(evaluateFlag(multi, { userId: "2" }, "local")).toMatchObject({ - variant: "b", - reason: "TARGETING_MATCH", - }); + 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("throws when a served variation is not defined", ({ expect }) => { - const broken = flag({ default_variation: "missing" }); - expect(() => evaluateFlag(broken, {}, "local")).toThrow( - "Flag 'test' variation 'missing' is not defined" - ); + test("rejects an undefined served variation", ({ expect }) => { + expect(() => + evaluateFlag(flag({ default_variation: "missing" }), {}, "local") + ).toThrow("Flag 'test' variation 'missing' is not defined"); }); - describe("conditions", () => { - function matches( - conditions: EvalRule["conditions"], - context: EvaluationContext - ): boolean { - const result = evaluateFlag( - flag({ rules: [{ conditions, serve_variation: "on" }] }), - context, - "local" - ); - return result.reason === "TARGETING_MATCH"; - } - - test("missing context attributes never match", ({ expect }) => { - expect( - matches([{ attribute: "plan", operator: "equals", value: "pro" }], {}) - ).toBe(false); - }); - - test("comparisons coerce through String()", ({ expect }) => { - expect( - matches([{ attribute: "id", operator: "equals", value: 42 }], { - id: "42", - }) - ).toBe(true); - }); - - test("in and not_in require arrays", ({ expect }) => { - expect( - matches([{ attribute: "country", operator: "in", value: ["US"] }], { - country: "US", - }) - ).toBe(true); - expect( - matches([{ attribute: "country", operator: "in", value: "US" }], { - country: "US", - }) - ).toBe(false); - expect( - matches([{ attribute: "country", operator: "not_in", value: [] }], { - country: "US", - }) - ).toBe(true); - }); - - test("ISO-8601 targets compare as timestamps", ({ expect }) => { - expect( - matches( - [ - { - attribute: "now", - operator: "greater_than", - value: "2025-05-01T15:00:00Z", - }, - ], - { now: "2025-06-01T15:00:00Z" } - ) - ).toBe(true); - }); - - test("empty AND clauses match, empty OR clauses do not", ({ expect }) => { - expect(matches([{ logical_operator: "AND", clauses: [] }], {})).toBe( - true - ); - expect(matches([{ logical_operator: "OR", clauses: [] }], {})).toBe( - false - ); - }); + 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("unknown operators do not match", ({ expect }) => { - expect( - matches( - [ - { - attribute: "id", - operator: "spaceship" as never, - value: "1", - }, - ], - { id: "1" } - ) - ).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", () => { - // Buckets pinned against the upstream Flagship suite for flag - // `rollout_test` under UPSTREAM_ACCOUNT_ID (hash seed 45). - test("buckets match the upstream hash vectors", ({ expect }) => { - const buckets = Object.fromEntries( - ["0", "1", "2", "", "日本語", "héllo", "false"].map((key) => [ - key, - bucketFor(key), - ]) - ); - expect(buckets).toEqual({ + 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, @@ -209,61 +170,41 @@ describe("flagship evaluation", () => { héllo: 73, false: 33, }); - }); - - test("non-string targeting keys hash as their string form", ({ - expect, - }) => { expect(bucketFor(0)).toBe(bucketFor("0")); expect(bucketFor(false)).toBe(bucketFor("false")); }); - test("100 percent always matches and reports SPLIT", ({ expect }) => { - for (const targetingKey of ["0", "1", "50", "99", "999"]) { - expect( - evaluateFlag(rolloutFlag(100), { targetingKey }, UPSTREAM_ACCOUNT_ID) - ).toMatchObject({ reason: "SPLIT" }); - } - }); - - test("0 percent never matches", ({ expect }) => { - for (const targetingKey of ["0", "1", "50", "99", "999"]) { - expect( - evaluateFlag(rolloutFlag(0), { targetingKey }, UPSTREAM_ACCOUNT_ID) - ).toMatchObject({ reason: "DEFAULT" }); - } - }); - - test("a missing targeting key buckets randomly", ({ expect }) => { - const { reason } = evaluateFlag(rolloutFlag(50), {}, UPSTREAM_ACCOUNT_ID); - expect(["SPLIT", "DEFAULT"]).toContain(reason); - }); - - test("the seed varies by account and by flag", ({ expect }) => { - const keys = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; - const bucketsFor = (flagKey: string, accountId: string) => - keys.map((targetingKey) => { - const rollout = rolloutFlag(50); - rollout.key = flagKey; - const { reason } = evaluateFlag(rollout, { targetingKey }, accountId); - return reason; - }); - - const baseline = bucketsFor("rollout_test", UPSTREAM_ACCOUNT_ID); - expect(bucketsFor("rollout_test", "local")).not.toEqual(baseline); - expect(bucketsFor("other_flag", UPSTREAM_ACCOUNT_ID)).not.toEqual( - baseline + 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("a custom rollout attribute replaces targetingKey", ({ expect }) => { - const byUserId = rolloutFlag(50, "userId"); - expect( - evaluateFlag(byUserId, { userId: "1" }, UPSTREAM_ACCOUNT_ID) - ).toMatchObject({ reason: "SPLIT" }); - expect( - evaluateFlag(byUserId, { userId: "2" }, UPSTREAM_ACCOUNT_ID) - ).toMatchObject({ reason: "DEFAULT" }); + 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 6f8f0e6ac7b..2dfca4f1335 100644 --- a/packages/miniflare/test/plugins/flagship/index.spec.ts +++ b/packages/miniflare/test/plugins/flagship/index.spec.ts @@ -99,47 +99,23 @@ test("flagship: accepts config with no flagship binding", ({ expect }) => { expect(result.success).toBe(true); }); -// Account tag and bucket expectations copied from the upstream Flagship -// data-plane suite, so seeding the local store must reproduce them exactly. -const UPSTREAM_ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; -const UPSTREAM_BUCKETS: Record = { - "0": 15, - "1": 8, - "2": 91, - "": 50, - 日本語: 9, - héllo: 73, - false: 33, -}; - -const ROLLOUT_PERCENTAGE = 50; - +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", - enabled: true, - default_variation: "off", - variations: { on: true, off: false }, rules: [ { priority: 1, conditions: [], serve_variation: "on", - rollout: { percentage: ROLLOUT_PERCENTAGE }, + rollout: { percentage: 50 }, }, ], }; -/** - * Await an admin API call and return the message it rejects with. Handing the - * RPC promise to Vitest's `.rejects` matcher instead leaves it unhandled. - */ -async function rejection(call: () => Promise): Promise { - try { - await call(); - } catch (error) { - return (error as Error).message; - } - throw new Error("expected the call to reject"); +async function getAdmin(mf: Miniflare, binding = "FLAGS") { + return (await mf.getFlagshipBindingAPI(binding))(); } async function call(mf: Miniflare, method: string, ...args: unknown[]) { @@ -147,269 +123,201 @@ async function call(mf: Miniflare, method: string, ...args: unknown[]) { method: "POST", body: JSON.stringify({ method, args }), }); - if (!response.ok) { - throw new Error(await response.text()); + 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; } - const { result } = (await response.json()) as { result: unknown }; - return result; + throw new Error("expected rejection"); } describe("flagship plugin", () => { - test("evaluates seeded flags through the binding", async ({ expect }) => { + test("accepts local, remote, and absent bindings", ({ expect }) => { + const config = (env?: Record) => ({ + config: { + type: "worker", + name: "test", + compatibilityDate: "2025-01-01", + env, + manifest: singleModuleManifest("export default {}"), + }, + }); + for (const env of [ + { FLAGS: { type: "flagship", id: "app" } }, + { FLAGS: { type: "flagship", id: "app", remote: true } }, + undefined, + ]) { + expect(WorkerOptionsSchema.safeParse(config(env)).success).toBe(true); + } + }); + + test("implements binding values, details, defaults, and errors", async ({ + expect, + }) => { const mf = new Miniflare(options()); useDispose(mf); - - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + const admin = await getAdmin(mf); await admin.createFlag(BOOL_FLAG); expect( - await call(mf, "getBooleanValue", "new_checkout", false, { plan: "pro" }) + await call(mf, "getBooleanValue", BOOL_FLAG.key, false, { plan: "pro" }) ).toBe(true); expect( - await call(mf, "getBooleanValue", "new_checkout", false, { - plan: "free", - }) - ).toBe(false); - expect( - await call(mf, "getBooleanDetails", "new_checkout", false, { - plan: "pro", - }) + await call(mf, "getBooleanDetails", BOOL_FLAG.key, false, { plan: "pro" }) ).toEqual({ - flagKey: "new_checkout", + flagKey: BOOL_FLAG.key, value: true, variant: "on", reason: "TARGETING_MATCH", }); - }); - - test("returns the default value for unknown flags", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - - expect(await call(mf, "getStringValue", "nope", "fallback")).toBe( + expect(await call(mf, "getStringValue", "missing", "fallback")).toBe( "fallback" ); - expect(await call(mf, "getStringDetails", "nope", "fallback")).toEqual({ - flagKey: "nope", + expect(await call(mf, "getStringDetails", "missing", "fallback")).toEqual({ + flagKey: "missing", value: "fallback", variant: "default", reason: "ERROR", errorCode: "FLAG_NOT_FOUND", - errorMessage: "Flag 'nope' 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("returns the default value when the type does not match", async ({ - expect, - }) => { + test("supports every admin mutation", async ({ expect }) => { const mf = new Miniflare(options()); useDispose(mf); + const admin = await getAdmin(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - await admin.createFlag(BOOL_FLAG); - - expect(await call(mf, "getStringDetails", "new_checkout", "x")).toEqual({ - flagKey: "new_checkout", - value: "x", - variant: "default", - reason: "ERROR", - errorCode: "TYPE_MISMATCH", - errorMessage: "Flag 'new_checkout' has type 'boolean', expected 'string'", + 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("get throws for unknown flags without a default value", async ({ + test("reports missing and conflicting admin operations", async ({ expect, }) => { const mf = new Miniflare(options()); useDispose(mf); - - await expect(call(mf, "get", "nope")).rejects.toThrow( - "Flag 'nope' not found" + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + expect(await rejection(() => admin.createFlag(BOOL_FLAG))).toBe( + "Flag 'new_checkout' already exists" ); - expect(await call(mf, "get", "nope", "fallback")).toBe("fallback"); + 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("isolates flags by app id and shares them within one", async ({ - expect, - }) => { - const mf = new Miniflare( - options({ - FLAGS: { type: "flagship", id: "app-a" }, - OTHER: { type: "flagship", id: "app-b" }, - ALIAS: { type: "flagship", id: "app-a" }, - }) - ); + test("enforces flag validation", async ({ expect }) => { + const mf = new Miniflare(options()); useDispose(mf); - - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - await admin.createFlag(BOOL_FLAG); - - expect( - await (await mf.getFlagshipBindingAPI("ALIAS"))().listFlags() - ).toEqual([expect.objectContaining({ key: "new_checkout" })]); - expect( - await (await mf.getFlagshipBindingAPI("OTHER"))().listFlags() - ).toEqual([]); - }); - - test("persists flags on the file system", async ({ expect }) => { - const tmp = await useTmp(); - const opts = { ...options(), resourcePersistencePath: tmp }; - - const mf1 = new Miniflare(opts); - const admin1 = (await mf1.getFlagshipBindingAPI("FLAGS"))(); - await admin1.createFlag(BOOL_FLAG); - await mf1.dispose(); - - const mf2 = new Miniflare(opts); - useDispose(mf2); - expect( - await call(mf2, "getBooleanValue", "new_checkout", false, { - plan: "pro", - }) - ).toBe(true); - }); - - describe("admin API", () => { - test("supports the full flag lifecycle", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - - expect(await admin.listFlags()).toEqual([]); - - const created = await admin.createFlag(BOOL_FLAG); - expect(created).toMatchObject({ key: "new_checkout", enabled: true }); - expect(await admin.getFlag("new_checkout")).toEqual(created); - - await admin.updateFlag("new_checkout", { ...BOOL_FLAG, enabled: false }); - expect(await admin.getFlag("new_checkout")).toMatchObject({ - enabled: false, - }); - expect(await admin.evaluateFlag("new_checkout", { plan: "pro" })).toEqual( + 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"], + [ + { 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", + ], + [ { - flagKey: "new_checkout", - value: false, - variant: "off", - reason: "DISABLED", - } - ); - - await admin.deleteFlag("new_checkout"); - expect(await admin.listFlags()).toEqual([]); - }); - - test("rejects invalid operations", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - - await admin.createFlag(BOOL_FLAG); - expect(await rejection(() => admin.createFlag(BOOL_FLAG))).toBe( - "Flag 'new_checkout' already exists" - ); - expect( - await rejection(() => admin.updateFlag("missing", BOOL_FLAG)) - ).toBe("Flag 'missing' not found"); - expect(await rejection(() => admin.deleteFlag("missing"))).toBe( - "Flag 'missing' not found" - ); - }); - - test("enforces the control-plane write invariants", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - const put = (input: Partial) => - rejection(() => admin.putFlag({ ...BOOL_FLAG, ...input } as FlagInput)); - - expect(await put({ key: "not valid!" })).toBe( - "Flag key 'not valid!' must be 1-64 alphanumeric, hyphen or underscore characters" - ); - expect(await put({ variations: {} })).toBe( - "Flag 'new_checkout' must define at least one variation" - ); - expect(await put({ variations: { on: true, off: "no" } })).toBe( - "Flag 'new_checkout' variations must all share the same type" - ); - expect(await put({ variations: { on: null, off: null } })).toBe( - "Flag 'new_checkout' variations cannot be null" - ); - expect(await put({ default_variation: "nope" })).toBe( - "Flag 'new_checkout' default variation 'nope' is not defined" - ); - expect( - await put({ - rules: [{ ...BOOL_FLAG.rules[0], serve_variation: "nope" }], - }) - ).toBe("Flag 'new_checkout' rule serves undefined variation 'nope'"); - expect( - await put({ rules: [{ ...BOOL_FLAG.rules[0], priority: 0 }] }) - ).toBe( - "Flag 'new_checkout' rule priorities must be integers greater than or equal to 1" - ); - expect( - await put({ rules: [BOOL_FLAG.rules[0], { ...BOOL_FLAG.rules[0] }] }) - ).toBe("Flag 'new_checkout' has duplicate rule priority 1"); - expect( - await put({ rules: [ { priority: 1, conditions: [], serve_variation: "on" }, { ...BOOL_FLAG.rules[0], priority: 2 }, ], - }) - ).toBe( - "Flag 'new_checkout' has targeting rules after a rule with no conditions" - ); - 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); - expect( - await put({ - rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 101 } }], - }) - ).toBe( - "Flag 'new_checkout' rollout percentage must be a number between 0 and 100" - ); - expect( - await put({ + }, + "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], }, ], - }) - ).toBe( - "Flag 'new_checkout' has a 'AND' condition without a list of clauses" - ); - expect( - await put({ + }, + "'AND' condition without a list of clauses", + ], + [ + { rules: [ { ...BOOL_FLAG.rules[0], conditions: [ - { attribute: "plan", operator: "sorta_equals", value: "pro" }, + { attribute: "plan", operator: "invalid", value: "pro" }, ] as never, }, ], - }) - ).toBe( - "Flag 'new_checkout' has a condition with an unknown operator 'sorta_equals'" - ); - expect( - await put({ + }, + "condition with an unknown operator 'invalid'", + ], + [ + { rules: [ { ...BOOL_FLAG.rules[0], @@ -418,303 +326,167 @@ describe("flagship plugin", () => { ] as never, }, ], - }) - ).toBe( - "Flag 'new_checkout' has a 'in' condition whose value is not a list" - ); - }); - - test("putFlag upserts", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - - await admin.putFlag(BOOL_FLAG); - await admin.putFlag({ ...BOOL_FLAG, enabled: false }); - expect(await admin.listFlags()).toEqual([ - expect.objectContaining({ key: "new_checkout", enabled: false }), - ]); - }); - }); - - describe("rollout bucketing", () => { - test("reproduces the remote app's buckets once the store is seeded", async ({ - expect, - }) => { - const mf = new Miniflare(options()); - useDispose(mf); - - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - await admin.setAccountTag(UPSTREAM_ACCOUNT_TAG); - await admin.createFlag(ROLLOUT_FLAG); - - // `included` is the ground truth from the upstream hash vectors: a - // rollout includes a targeting key when its bucket is below the - // percentage. Evaluating through the binding must agree. - for (const [targetingKey, bucket] of Object.entries(UPSTREAM_BUCKETS)) { - const included = bucket < ROLLOUT_PERCENTAGE; - expect({ - targetingKey, - value: await call(mf, "getBooleanValue", "rollout_test", false, { - targetingKey, - }), - }).toEqual({ targetingKey, value: included }); - } - }); - - test("seeding changes which keys land in the rollout", async ({ - expect, - }) => { - const keys = Object.keys(UPSTREAM_BUCKETS); - const evaluateAll = async (mf: Miniflare) => { - const results: boolean[] = []; - for (const targetingKey of keys) { - results.push( - (await call(mf, "getBooleanValue", "rollout_test", false, { - targetingKey, - })) as boolean - ); - } - return results; - }; - - const unseeded = new Miniflare(options()); - useDispose(unseeded); - await ( - await unseeded.getFlagshipBindingAPI("FLAGS") - )().createFlag(ROLLOUT_FLAG); - - const seeded = new Miniflare(options()); - useDispose(seeded); - const seededAdmin = (await seeded.getFlagshipBindingAPI("FLAGS"))(); - await seededAdmin.setAccountTag(UPSTREAM_ACCOUNT_TAG); - await seededAdmin.createFlag(ROLLOUT_FLAG); - - expect(await evaluateAll(unseeded)).not.toEqual( - await evaluateAll(seeded) - ); - }); - - test("exposes and persists the account tag", async ({ expect }) => { - const tmp = await useTmp(); - const opts = { ...options(), resourcePersistencePath: tmp }; - - const mf1 = new Miniflare(opts); - const admin1 = (await mf1.getFlagshipBindingAPI("FLAGS"))(); - expect(await admin1.getAccountTag()).toBe(null); - await admin1.setAccountTag(UPSTREAM_ACCOUNT_TAG); - expect(await admin1.getAccountTag()).toBe(UPSTREAM_ACCOUNT_TAG); - await mf1.dispose(); - - const mf2 = new Miniflare(opts); - useDispose(mf2); + }, + "'in' condition whose value is not a list", + ], + ]; + for (const [changes, message] of cases) { expect( - await (await mf2.getFlagshipBindingAPI("FLAGS"))().getAccountTag() - ).toBe(UPSTREAM_ACCOUNT_TAG); - }); - - test("keeps the account tag out of the flag listing", async ({ - expect, - }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - - await admin.setAccountTag(UPSTREAM_ACCOUNT_TAG); - expect(await admin.listFlags()).toEqual([]); + 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 } }], }); - - test("isolates the account tag by app id", async ({ expect }) => { - const mf = new Miniflare( - options({ - FLAGS: { type: "flagship", id: "app-a" }, - OTHER: { type: "flagship", id: "app-b" }, - }) - ); - useDispose(mf); - - await ( - await mf.getFlagshipBindingAPI("FLAGS") - )().setAccountTag(UPSTREAM_ACCOUNT_TAG); - expect( - await (await mf.getFlagshipBindingAPI("OTHER"))().getAccountTag() - ).toBe(null); + 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("rejects an empty account tag", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - - expect(await rejection(() => admin.setAccountTag(""))).toBe( + 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" ); - }); - }); - - describe("unseeded bucketing warning", () => { - /** - * Build an instance capturing the worker's structured logs, which is where - * `console.warn` from inside the binding surfaces. - */ - function withCapturedLogs() { - const warnings: string[] = []; - const mf = new Miniflare({ - ...options(), - handleStructuredLogs: ({ level, message }) => { - if (level === "warn") { - warnings.push(message); - } - }, - }); - return { mf, warnings }; - } - - async function evaluateRollout(mf: Miniflare, targetingKey: string) { - return call(mf, "getBooleanValue", "rollout_test", false, { - targetingKey, - }); } - - test("warns once per session when a rollout is evaluated unseeded", async ({ - expect, - }) => { - const { mf, warnings } = withCapturedLogs(); - useDispose(mf); - await ( - await mf.getFlagshipBindingAPI("FLAGS") - )().createFlag(ROLLOUT_FLAG); - - for (const targetingKey of ["0", "1", "2"]) { - await evaluateRollout(mf, 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.", - ]); - }); - - test("stays quiet once the store is seeded", async ({ expect }) => { - const { mf, warnings } = withCapturedLogs(); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - await admin.setAccountTag(UPSTREAM_ACCOUNT_TAG); - await admin.createFlag(ROLLOUT_FLAG); - - await evaluateRollout(mf, "0"); - - expect(warnings).toEqual([]); - }); - - test("stays quiet for flags without a partial rollout", async ({ - expect, - }) => { - const { mf, warnings } = withCapturedLogs(); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - await admin.createFlag(BOOL_FLAG); - await admin.createFlag({ - ...ROLLOUT_FLAG, - key: "full_rollout", - rules: [{ ...ROLLOUT_FLAG.rules[0], rollout: { percentage: 100 } }], - }); - - await call(mf, "getBooleanValue", "new_checkout", false, { plan: "pro" }); - await call(mf, "getBooleanValue", "full_rollout", false, { - targetingKey: "0", - }); - - expect(warnings).toEqual([]); - }); + 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(); }); - describe("persistence", () => { - test("sees writes made by another instance sharing the store", async ({ - expect, - }) => { - const tmp = await useTmp(); - const first = new Miniflare({ - ...options(), - resourcePersistencePath: tmp, - }); - useDispose(first); - const firstAdmin = (await first.getFlagshipBindingAPI("FLAGS"))(); - expect(await firstAdmin.listFlags()).toEqual([]); - - const second = new Miniflare({ - ...options(), - resourcePersistencePath: tmp, - }); - const secondAdmin = (await second.getFlagshipBindingAPI("FLAGS"))(); - await secondAdmin.createFlag(BOOL_FLAG); - await second.dispose(); - - expect( - (await firstAdmin.listFlags()).map((flag) => flag.key) - ).toStrictEqual(["new_checkout"]); - }); - - test("accepts a fractional rollout percentage", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - const flag = await admin.putFlag({ - ...BOOL_FLAG, - rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 33.333333 } }], - }); + 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(); + }); - expect(flag.rules[0].rollout).toEqual({ percentage: 33.333333 }); - }); + 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(); - test("patchFlag only changes the fields it is given", async ({ - expect, - }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - await admin.createFlag(BOOL_FLAG); + 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); + }); - await admin.patchFlag("new_checkout", { description: "now described" }); + 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 }), + ]); + }); - expect(await admin.getFlag("new_checkout")).toEqual( - expect.objectContaining({ - description: "now described", - enabled: true, - rules: BOOL_FLAG.rules, - }) - ); + 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); + }, }); - - test("putFlags rejects the whole batch when one flag is invalid", async ({ - expect, - }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - + 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 rejection(() => - admin.putFlags( - [BOOL_FLAG, { ...BOOL_FLAG, key: "second", variations: {} }], - "tag" - ) - ) - ).toBe("Flag 'second' must define at least one variation"); + await call(mf, "getBooleanValue", ROLLOUT_FLAG.key, false, { + targetingKey, + }) + ).toBe(bucket < 50); + } + expect(warnings).toEqual([]); + }); - expect(await admin.listFlags()).toEqual([]); - expect(await admin.getAccountTag()).toBeNull(); + 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); + }, }); - - test("putFlags rejects an empty account tag", async ({ expect }) => { - const mf = new Miniflare(options()); - useDispose(mf); - const admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); - - expect(await rejection(() => admin.putFlags([BOOL_FLAG], ""))).toBe( - "accountTag must be a non-empty string" - ); - expect(await admin.listFlags()).toEqual([]); - expect(await admin.getAccountTag()).toBeNull(); + 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.", + ]); }); }); From fc68217ec9310c5be54f110d31bd4c2592be7df2 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Tue, 25 Aug 2026 12:44:04 +0530 Subject: [PATCH 3/7] test: align Flagship schema coverage --- .../test/plugins/flagship/index.spec.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/miniflare/test/plugins/flagship/index.spec.ts b/packages/miniflare/test/plugins/flagship/index.spec.ts index 2dfca4f1335..53bada3eab5 100644 --- a/packages/miniflare/test/plugins/flagship/index.spec.ts +++ b/packages/miniflare/test/plugins/flagship/index.spec.ts @@ -137,25 +137,6 @@ async function rejection(call: () => Promise): Promise { } describe("flagship plugin", () => { - test("accepts local, remote, and absent bindings", ({ expect }) => { - const config = (env?: Record) => ({ - config: { - type: "worker", - name: "test", - compatibilityDate: "2025-01-01", - env, - manifest: singleModuleManifest("export default {}"), - }, - }); - for (const env of [ - { FLAGS: { type: "flagship", id: "app" } }, - { FLAGS: { type: "flagship", id: "app", remote: true } }, - undefined, - ]) { - expect(WorkerOptionsSchema.safeParse(config(env)).success).toBe(true); - } - }); - test("implements binding values, details, defaults, and errors", async ({ expect, }) => { From 34b62accac793f292235af018356de8aebab8b7f Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Tue, 25 Aug 2026 12:54:58 +0530 Subject: [PATCH 4/7] fix: validate local Flagship storage inputs --- .../miniflare/src/plugins/flagship/index.ts | 15 +++++---- .../miniflare/src/workers/flagship/flags.ts | 32 +++++++++++++++++++ .../test/plugins/flagship/index.spec.ts | 21 ++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 6a13bba6915..3eea77445ef 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -6,7 +6,6 @@ import { getEnvBindingsOfType, getPersistPath, getRemoteProxyConnectionString, - getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -14,11 +13,15 @@ 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_OBJECT_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:object`; -const FLAGSHIP_STORAGE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:storage`; +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"; +function getFlagshipAppServiceName(appId: string): string { + return `${FLAGSHIP_PLUGIN_NAME}:app:${encodeURIComponent(appId)}`; +} + // 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 @@ -46,7 +49,7 @@ export const FLAGSHIP_PLUGIN: Plugin = { return { name, service: { - name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, binding.id), + name: getFlagshipAppServiceName(binding.id), entrypoint: "FlagshipBinding", }, }; @@ -121,7 +124,7 @@ export const FLAGSHIP_PLUGIN: Plugin = { for (const appId of localAppIds) { services.push({ - name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, appId), + name: getFlagshipAppServiceName(appId), worker: { compatibilityDate: "2025-01-01", modules: [{ name: "binding.worker.js", esModule: BINDING_SCRIPT() }], diff --git a/packages/miniflare/src/workers/flagship/flags.ts b/packages/miniflare/src/workers/flagship/flags.ts index 7767cc128db..c681140779e 100644 --- a/packages/miniflare/src/workers/flagship/flags.ts +++ b/packages/miniflare/src/workers/flagship/flags.ts @@ -91,6 +91,28 @@ 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; + } + + 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) { @@ -165,6 +187,11 @@ function validateCondition( 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 { @@ -196,6 +223,11 @@ export function validateFlagInput(input: FlagInput): void { 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( diff --git a/packages/miniflare/test/plugins/flagship/index.spec.ts b/packages/miniflare/test/plugins/flagship/index.spec.ts index 53bada3eab5..a7d10f25099 100644 --- a/packages/miniflare/test/plugins/flagship/index.spec.ts +++ b/packages/miniflare/test/plugins/flagship/index.spec.ts @@ -137,6 +137,23 @@ async function rejection(call: () => Promise): Promise { } describe("flagship plugin", () => { + test("keeps app service names separate from internal services", async ({ + expect, + }) => { + const mf = new Miniflare( + options({ + OBJECT: { type: "flagship", id: "object" }, + REMOTE: { type: "flagship", id: "remote" }, + STORAGE: { type: "flagship", id: "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, }) => { @@ -244,6 +261,10 @@ describe("flagship plugin", () => { [{ 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", + ], [ { default_variation: "missing" }, "default variation 'missing' is not defined", From f69338698a0893cb15830e83b74e5793c820aea6 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Tue, 25 Aug 2026 13:01:35 +0530 Subject: [PATCH 5/7] refactor: preserve Flagship service naming --- packages/miniflare/src/plugins/flagship/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 3eea77445ef..8f5ec0d87c6 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -6,6 +6,7 @@ import { getEnvBindingsOfType, getPersistPath, getRemoteProxyConnectionString, + getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,10 +19,6 @@ 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"; -function getFlagshipAppServiceName(appId: string): string { - return `${FLAGSHIP_PLUGIN_NAME}:app:${encodeURIComponent(appId)}`; -} - // 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 @@ -49,7 +46,7 @@ export const FLAGSHIP_PLUGIN: Plugin = { return { name, service: { - name: getFlagshipAppServiceName(binding.id), + name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, binding.id), entrypoint: "FlagshipBinding", }, }; @@ -124,7 +121,7 @@ export const FLAGSHIP_PLUGIN: Plugin = { for (const appId of localAppIds) { services.push({ - name: getFlagshipAppServiceName(appId), + name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, appId), worker: { compatibilityDate: "2025-01-01", modules: [{ name: "binding.worker.js", esModule: BINDING_SCRIPT() }], From b8d3308044c3f250d3ae684af28d5c935ef8588c Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Tue, 25 Aug 2026 13:08:52 +0530 Subject: [PATCH 6/7] fix: isolate Flagship internal services --- packages/miniflare/src/plugins/flagship/index.ts | 6 +++--- packages/miniflare/src/workers/flagship/flags.ts | 7 +++++++ packages/miniflare/test/plugins/flagship/index.spec.ts | 10 +++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 8f5ec0d87c6..b1b26790a2c 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -14,9 +14,9 @@ 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}:internal:remote`; -const FLAGSHIP_OBJECT_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:internal:object`; -const FLAGSHIP_STORAGE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:internal:storage`; +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 diff --git a/packages/miniflare/src/workers/flagship/flags.ts b/packages/miniflare/src/workers/flagship/flags.ts index c681140779e..a6b78fe05f7 100644 --- a/packages/miniflare/src/workers/flagship/flags.ts +++ b/packages/miniflare/src/workers/flagship/flags.ts @@ -105,6 +105,13 @@ function isJsonValue(value: unknown, seen = new Set()): boolean { 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); diff --git a/packages/miniflare/test/plugins/flagship/index.spec.ts b/packages/miniflare/test/plugins/flagship/index.spec.ts index a7d10f25099..d8a7d00713f 100644 --- a/packages/miniflare/test/plugins/flagship/index.spec.ts +++ b/packages/miniflare/test/plugins/flagship/index.spec.ts @@ -142,9 +142,9 @@ describe("flagship plugin", () => { }) => { const mf = new Miniflare( options({ - OBJECT: { type: "flagship", id: "object" }, - REMOTE: { type: "flagship", id: "remote" }, - STORAGE: { type: "flagship", id: "storage" }, + OBJECT: { type: "flagship", id: "internal:object" }, + REMOTE: { type: "flagship", id: "internal:remote" }, + STORAGE: { type: "flagship", id: "internal:storage" }, }) ); useDispose(mf); @@ -265,6 +265,10 @@ describe("flagship plugin", () => { { 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", From 212628cb5de3ddaab1e3d268931540822eb500eb Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Tue, 25 Aug 2026 13:22:43 +0530 Subject: [PATCH 7/7] chore: remove unused Flagship type import --- packages/miniflare/src/workers/flagship/binding.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/miniflare/src/workers/flagship/binding.worker.ts b/packages/miniflare/src/workers/flagship/binding.worker.ts index 780b761512b..14edbd98346 100644 --- a/packages/miniflare/src/workers/flagship/binding.worker.ts +++ b/packages/miniflare/src/workers/flagship/binding.worker.ts @@ -15,7 +15,7 @@ import type { FlagType, FlagValue, } from "./evaluate"; -import type { Flag, FlagChanges, FlagInput } from "./flags"; +import type { Flag, FlagInput } from "./flags"; import type { FlagshipObject, WriteResult } from "./object.worker"; interface Env {