{"id":51317893,"url":"https://github.com/vllnt/convex-experiments","last_synced_at":"2026-07-01T09:31:14.445Z","repository":{"id":365515995,"uuid":"1272384038","full_name":"vllnt/convex-experiments","owner":"vllnt","description":"Deterministic A/B experimentation — sticky variant assignment and deduped exposure tracking, as a Convex component","archived":false,"fork":false,"pushed_at":"2026-06-17T16:47:09.000Z","size":117,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-17T18:18:38.679Z","etag":null,"topics":["ab-testing","convex","convex-component","experiments","vllnt"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@vllnt/convex-experiments","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vllnt.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":"ROADMAP.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":"bntvllnt"}},"created_at":"2026-06-17T14:58:06.000Z","updated_at":"2026-06-17T16:47:12.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vllnt/convex-experiments","commit_stats":null,"previous_names":["vllnt/convex-experiments"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/vllnt/convex-experiments","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-experiments","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-experiments/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-experiments/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-experiments/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vllnt","download_url":"https://codeload.github.com/vllnt/convex-experiments/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-experiments/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35001648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ab-testing","convex","convex-component","experiments","vllnt"],"created_at":"2026-07-01T09:31:13.549Z","updated_at":"2026-07-01T09:31:14.439Z","avatar_url":"https://github.com/vllnt.png","language":"TypeScript","funding_links":["https://github.com/sponsors/bntvllnt"],"categories":[],"sub_categories":[],"readme":"\u003c!-- Badges --\u003e\n[![convex-component](https://img.shields.io/badge/convex-component-EE342F.svg)](https://www.convex.dev/components)\n[![npm](https://img.shields.io/npm/v/@vllnt/convex-experiments.svg)](https://www.npmjs.com/package/@vllnt/convex-experiments)\n[![CI](https://github.com/vllnt/convex-experiments/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-experiments/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/@vllnt/convex-experiments.svg)](./LICENSE)\n\n# @vllnt/convex-experiments\n\nDeterministic A/B experimentation — sticky variant assignment and deduped exposure tracking, as a\nConvex component.\n\n```ts\nconst experiments = new Experiments(components.experiments);\nawait experiments.define(ctx, \"checkout_button\", {\n  variants: [\n    { key: \"control\", weight: 1 },\n    { key: \"treatment\", weight: 1 },\n  ],\n});\nconst { variant } = await experiments.logExposure(ctx, \"checkout_button\", userId);\nif (variant === \"treatment\") showNewButton();\n```\n\nDefine an experiment with weighted variants; enroll subjects into a **sticky** variant chosen\ndeterministically from `(salt, subjectRef)`; record exposures and read per-variant tallies. Pair it\nwith a feature-flag system for kill-switches and measure conversions in your own tables — this\ncomponent handles assignment and exposure. Domain-neutral: any subject, any surface.\n\n## Features\n\n- **Deterministic + sticky** — the same subject always lands in the same weighted variant, even before anything is stored.\n- **Weighted variants** — split traffic by relative weights (`1:1`, `90:10`, N-way); equal weights split evenly.\n- **Enrollment lifecycle** — `draft → running → stopped`; only `running` enrolls, and stopping preserves recorded data.\n- **Deduped exposures + O(variants) results** — one tallied row per subject; `results` reads maintained per-variant tallies (`assigned`/`subjects`/`exposures`/`weight`), never scanning the exposure table.\n- **Sample-ratio-ready** — `assigned` + `weight` per variant let you check observed vs expected split (SRM).\n- **`peek`** — a read-only deterministic query returns a subject's sticky variant without writing (SSR / flicker-free first paint).\n- **Immutable once assigned** — `variants`/`salt` are fixed after enrollment (changing them throws); define a new key to re-randomize.\n- **Lifecycle + GDPR** — `listExperiments` to discover, `forgetSubject` to erase one subject, `deleteExperiment` to cascade-delete.\n- **Scopes** — global by default, or namespace per tenant / surface (folded into the hash for independent bucketing).\n- **Fully typed** — variant keys, weights, and outcomes are concrete types end to end; no `any`.\n- **Server-sourced time** — assignment timestamps come from the server, never the caller.\n\n## Installation\n\n```bash\npnpm add @vllnt/convex-experiments\n```\n\nPeer dependency: `convex@^1.41.0`.\n\n## Usage\n\n```ts\n// convex/convex.config.ts\nimport { defineApp } from \"convex/server\";\nimport experiments from \"@vllnt/convex-experiments/convex.config\";\n\nconst app = defineApp();\napp.use(experiments);\nexport default app;\n```\n\n```ts\n// convex/checkout.ts — host owns auth; pass an opaque subjectRef in.\nimport { components } from \"./_generated/api\";\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { Experiments } from \"@vllnt/convex-experiments\";\n\nconst experiments = new Experiments(components.experiments);\n\nexport const view = mutation({\n  args: { userId: v.string() },\n  handler: async (ctx, { userId }) =\u003e {\n    const { variant } = await experiments.logExposure(ctx, \"checkout_button\", userId);\n    return { variant }; // \"control\" | \"treatment\" | null (not enrolled)\n  },\n});\n```\n\nMeasure the outcome in your own table, joined on the assigned variant — then read tallies with\n`results(ctx, \"checkout_button\")`. See [`example/convex/example.ts`](example/convex/example.ts) for a\nhost-side conversion recorder.\n\n## API Reference\n\n| Method | Kind | Result |\n|--------|------|--------|\n| `define(ctx, key, { variants, scope?, salt?, status? })` | mutation | `{ created: boolean }` |\n| `setStatus(ctx, key, status, scope?)` | mutation | `boolean` |\n| `assign(ctx, key, subjectRef, scope?)` | mutation | `{ variant: null } \\| { variant: string; isNew: boolean }` |\n| `logExposure(ctx, key, subjectRef, scope?)` | mutation | `{ variant: string \\| null }` |\n| `forgetSubject(ctx, key, subjectRef, scope?)` | mutation | `boolean` (GDPR erasure) |\n| `deleteExperiment(ctx, key, { scope?, batch? })` | mutation | `number` (cascade delete) |\n| `getExperiment(ctx, key, scope?)` | query | `ExperimentDefinition \\| null` |\n| `listExperiments(ctx, { scope?, status? })` | query | `ExperimentDefinition[]` |\n| `getAssignment(ctx, key, subjectRef, scope?)` | query | `{ variant, assignedAt } \\| null` |\n| `peek(ctx, key, subjectRef, scope?)` | query | `{ variant: string \\| null }` (no write) |\n| `results(ctx, key, scope?)` | query | `{ variant, assigned, subjects, exposures, weight }[]` |\n\nFull reference: [docs/API.md](docs/API.md).\n\n## React\n\nOptional, tree-shakeable hooks via `@vllnt/convex-experiments/react` (`react` is an optional peer\ndep — a backend-only consumer pulls none of it). Each hook wraps `useQuery` over a query reference\n**you re-export** from your app, so the component never owns your `api`.\n\n```tsx\n// convex/experiments.ts — re-export the host-side wrappers (auth gated)\n// export const myVariant = query({ args: { userId: v.string() },\n//   handler: (ctx, { userId }) =\u003e experiments.peek(ctx, \"checkout_button\", userId) });\n\nimport { useVariant } from \"@vllnt/convex-experiments/react\";\nimport { api } from \"@/convex/_generated/api\";\n\nfunction CheckoutButton({ userId }: { userId: string }) {\n  // deterministic first paint via peek → no flash-of-control\n  const variant = useVariant(api.experiments.myVariant, { key: \"checkout_button\", subjectRef: userId });\n  return variant === \"treatment\" ? \u003cOneClick /\u003e : \u003cClassic /\u003e;\n}\n```\n\n| Hook | Wraps | Returns |\n|------|-------|---------|\n| `useVariant(peekRef, args)` | `peek` | `string \\| null \\| undefined` (variant / not-enrolled / loading) |\n| `useAssignment(getAssignmentRef, args)` | `getAssignment` | `Assignment \\| null \\| undefined` |\n| `useExperimentResults(resultsRef, args)` | `results` | `VariantResult[] \\| undefined` |\n\n## Security\n\n- Auth-agnostic — the host resolves identity and decides who may define, start, stop, or enroll.\n- Tables sandboxed — reached only through the exported functions; `subjectRef`, variant keys, and `scope` stay opaque.\n- Server-sourced timestamps — a caller cannot supply assignment time.\n\nSee [docs/API.md](docs/API.md).\n\n## Testing\n\n```bash\npnpm test           # single run\npnpm test:coverage  # enforced 100% on covered files\n```\n\nTests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Author\n\nBuilt by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt)\n\nPart of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com)\n\nIf this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt).\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-experiments","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvllnt%2Fconvex-experiments","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-experiments/lists"}