inngest
Version:
Official SDK for Inngest.com. Inngest is the reliability layer for modern applications. Inngest combines durable execution, events, and queues into a zero-infra platform with built-in observability.
1 lines • 7.73 kB
Source Map (JSON)
{"version":3,"file":"ExperimentStrategies.cjs","names":["hashjs","getAsyncCtxSync"],"sources":["../../src/components/ExperimentStrategies.ts"],"sourcesContent":["import hashjs from \"hash.js\";\nimport { getAsyncCtxSync } from \"./execution/als.ts\";\nimport type { ExperimentSelectFn } from \"./InngestGroupTools.ts\";\n\nconst { sha256 } = hashjs;\n\n/**\n * Hash a string to a float in [0, 1) using SHA-256.\n */\nconst hashToFloat = (str: string): number => {\n const hex = sha256().update(str).digest(\"hex\").slice(0, 8);\n return Number.parseInt(hex, 16) / 0x100000000;\n};\n\n/**\n * Given a float in [0, 1) and a weights map, select the variant whose bucket\n * the float falls into. Entries are sorted alphabetically for determinism.\n */\nconst selectByWeight = (\n hash01: number,\n weights: Record<string, number>,\n): string => {\n const entries = Object.entries(weights).sort(([a], [b]) =>\n a.localeCompare(b),\n );\n const total = entries.reduce((sum, [, w]) => sum + w, 0);\n\n let cursor = 0;\n for (const [name, weight] of entries) {\n cursor += weight / total;\n if (hash01 < cursor) {\n return name;\n }\n }\n\n // Fallback to last entry (floating-point edge case)\n return entries[entries.length - 1]![0]!;\n};\n\n/**\n * Build equal weights from variant names: `{ a: 1, b: 1, ... }`.\n */\nconst equalWeights = (variantNames: string[]): Record<string, number> => {\n return Object.fromEntries(variantNames.map((name) => [name, 1]));\n};\n\n/**\n * Throw if all weights are zero.\n */\nconst validateWeights = (weights: Record<string, number>): void => {\n for (const [name, w] of Object.entries(weights)) {\n if (!Number.isFinite(w)) {\n throw new Error(\n `experiment.weighted(): weight for \"${name}\" is not a finite number (${w}); weights must be finite numbers >= 0`,\n );\n }\n if (w < 0) {\n throw new Error(\n `experiment.weighted(): weight for \"${name}\" is negative (${w}); weights must be >= 0`,\n );\n }\n }\n\n const total = Object.values(weights).reduce((sum, w) => sum + w, 0);\n if (total <= 0) {\n throw new Error(\n \"experiment.weighted(): all weights are zero; at least one weight must be positive\",\n );\n }\n};\n\n/**\n * Attach `__experimentConfig` to a select function, producing an\n * `ExperimentSelectFn`.\n */\nconst createSelectFn = (\n fn: (variantNames?: string[]) => Promise<string> | string,\n config: ExperimentSelectFn[\"__experimentConfig\"],\n): ExperimentSelectFn => {\n return Object.assign(fn, { __experimentConfig: config });\n};\n\n/**\n * Factory functions for creating experiment selection strategies.\n *\n * Each factory returns an `ExperimentSelectFn` — a callable function with an\n * `__experimentConfig` property carrying strategy metadata.\n *\n * @example\n * ```ts\n * import { experiment, group, step } from \"inngest\";\n *\n * const result = await group.experiment(\"checkout-flow\", {\n * variants: {\n * control: () => step.run(\"old\", () => oldCheckout()),\n * new_flow: () => step.run(\"new\", () => newCheckout()),\n * },\n * select: experiment.weighted({ control: 80, new_flow: 20 }),\n * });\n * ```\n *\n * @public\n */\nexport const experiment = {\n /**\n * Always selects the specified variant.\n *\n * @example\n * ```ts\n * select: experiment.fixed(\"control\")\n * ```\n */\n fixed(variantName: string): ExperimentSelectFn {\n return createSelectFn(() => variantName, { strategy: \"fixed\" });\n },\n\n /**\n * Weighted random selection, seeded with the current run ID for\n * determinism — the same run always gets the same variant.\n *\n * @example\n * ```ts\n * select: experiment.weighted({ gpt4: 50, claude: 50 })\n * ```\n *\n * @throws If all weights are zero (validated at creation time).\n */\n weighted(weights: Record<string, number>): ExperimentSelectFn {\n validateWeights(weights);\n\n // Snapshot so that later mutations to the caller's object can't silently\n // change runtime behaviour after validation has passed.\n const frozen = { ...weights };\n\n return createSelectFn(\n () => {\n const runId =\n getAsyncCtxSync()?.execution?.ctx.runId ?? crypto.randomUUID();\n return selectByWeight(hashToFloat(runId), frozen);\n },\n { strategy: \"weighted\", weights: frozen },\n );\n },\n\n /**\n * Consistent hashing — the same value always maps to the same variant.\n *\n * When `value` is `null` or `undefined`, an empty string is hashed instead.\n *\n * @example\n * ```ts\n * select: experiment.bucket(userId)\n * select: experiment.bucket(userId, { weights: { a: 70, b: 30 } })\n * ```\n */\n bucket(\n value: unknown,\n options?: { weights?: Record<string, number> },\n ): ExperimentSelectFn {\n if (options?.weights) {\n validateWeights(options.weights);\n }\n\n const str = value == null ? \"\" : String(value);\n\n return createSelectFn(\n (variantNames?: string[]) => {\n const weights =\n options?.weights ??\n (variantNames ? equalWeights(variantNames) : undefined);\n\n if (!weights) {\n throw new Error(\n \"experiment.bucket() requires either explicit weights or variant \" +\n \"names from group.experiment()\",\n );\n }\n\n return selectByWeight(hashToFloat(str), weights);\n },\n {\n strategy: \"bucket\",\n weights: options?.weights,\n ...(value == null && { nullishBucket: true }),\n },\n );\n },\n\n /**\n * User-provided selection function. The function is called inside the\n * memoized step, so it only runs once per run.\n *\n * @example\n * ```ts\n * select: experiment.custom(async () => {\n * const flag = await getFeatureFlag(\"checkout-variant\");\n * return flag;\n * })\n * ```\n */\n custom(fn: () => Promise<string> | string): ExperimentSelectFn {\n return createSelectFn(fn, { strategy: \"custom\" });\n },\n};\n"],"mappings":";;;;;;AAIA,MAAM,EAAE,WAAWA;;;;AAKnB,MAAM,eAAe,QAAwB;CAC3C,MAAM,MAAM,QAAQ,CAAC,OAAO,IAAI,CAAC,OAAO,MAAM,CAAC,MAAM,GAAG,EAAE;AAC1D,QAAO,OAAO,SAAS,KAAK,GAAG,GAAG;;;;;;AAOpC,MAAM,kBACJ,QACA,YACW;CACX,MAAM,UAAU,OAAO,QAAQ,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,OAClD,EAAE,cAAc,EAAE,CACnB;CACD,MAAM,QAAQ,QAAQ,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAExD,IAAI,SAAS;AACb,MAAK,MAAM,CAAC,MAAM,WAAW,SAAS;AACpC,YAAU,SAAS;AACnB,MAAI,SAAS,OACX,QAAO;;AAKX,QAAO,QAAQ,QAAQ,SAAS,GAAI;;;;;AAMtC,MAAM,gBAAgB,iBAAmD;AACvE,QAAO,OAAO,YAAY,aAAa,KAAK,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;;;;;AAMlE,MAAM,mBAAmB,YAA0C;AACjE,MAAK,MAAM,CAAC,MAAM,MAAM,OAAO,QAAQ,QAAQ,EAAE;AAC/C,MAAI,CAAC,OAAO,SAAS,EAAE,CACrB,OAAM,IAAI,MACR,sCAAsC,KAAK,4BAA4B,EAAE,wCAC1E;AAEH,MAAI,IAAI,EACN,OAAM,IAAI,MACR,sCAAsC,KAAK,iBAAiB,EAAE,yBAC/D;;AAKL,KADc,OAAO,OAAO,QAAQ,CAAC,QAAQ,KAAK,MAAM,MAAM,GAAG,EAAE,IACtD,EACX,OAAM,IAAI,MACR,oFACD;;;;;;AAQL,MAAM,kBACJ,IACA,WACuB;AACvB,QAAO,OAAO,OAAO,IAAI,EAAE,oBAAoB,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;AAwB1D,MAAa,aAAa;CASxB,MAAM,aAAyC;AAC7C,SAAO,qBAAqB,aAAa,EAAE,UAAU,SAAS,CAAC;;CAcjE,SAAS,SAAqD;AAC5D,kBAAgB,QAAQ;EAIxB,MAAM,SAAS,EAAE,GAAG,SAAS;AAE7B,SAAO,qBACC;AAGJ,UAAO,eAAe,YADpBC,6BAAiB,EAAE,WAAW,IAAI,SAAS,OAAO,YAAY,CACxB,EAAE,OAAO;KAEnD;GAAE,UAAU;GAAY,SAAS;GAAQ,CAC1C;;CAcH,OACE,OACA,SACoB;AACpB,MAAI,SAAS,QACX,iBAAgB,QAAQ,QAAQ;EAGlC,MAAM,MAAM,SAAS,OAAO,KAAK,OAAO,MAAM;AAE9C,SAAO,gBACJ,iBAA4B;GAC3B,MAAM,UACJ,SAAS,YACR,eAAe,aAAa,aAAa,GAAG;AAE/C,OAAI,CAAC,QACH,OAAM,IAAI,MACR,gGAED;AAGH,UAAO,eAAe,YAAY,IAAI,EAAE,QAAQ;KAElD;GACE,UAAU;GACV,SAAS,SAAS;GAClB,GAAI,SAAS,QAAQ,EAAE,eAAe,MAAM;GAC7C,CACF;;CAeH,OAAO,IAAwD;AAC7D,SAAO,eAAe,IAAI,EAAE,UAAU,UAAU,CAAC;;CAEpD"}