{"id":51317885,"url":"https://github.com/vllnt/convex-metering","last_synced_at":"2026-07-01T09:31:14.690Z","repository":{"id":365518867,"uuid":"1272384413","full_name":"vllnt/convex-metering","owner":"vllnt","description":"Metered usage records — idempotent usage events rolled up per billing period, as a Convex component","archived":false,"fork":false,"pushed_at":"2026-06-17T16:47:11.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:24:23.529Z","etag":null,"topics":["convex","convex-component","metering","usage-based-billing","vllnt"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@vllnt/convex-metering","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:29.000Z","updated_at":"2026-06-17T16:47:47.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vllnt/convex-metering","commit_stats":null,"previous_names":["vllnt/convex-metering"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/vllnt/convex-metering","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-metering","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-metering/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-metering/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-metering/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vllnt","download_url":"https://codeload.github.com/vllnt/convex-metering/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-metering/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":["convex","convex-component","metering","usage-based-billing","vllnt"],"created_at":"2026-07-01T09:31:11.846Z","updated_at":"2026-07-01T09:31:14.607Z","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-metering.svg)](https://www.npmjs.com/package/@vllnt/convex-metering)\n[![CI](https://github.com/vllnt/convex-metering/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-metering/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/@vllnt/convex-metering.svg)](./LICENSE)\n\n# @vllnt/convex-metering\n\nMetered usage records — idempotent usage events rolled up per billing period, as a Convex component.\n\n```ts\nconst metering = new Metering(components.metering);\nawait metering.defineMeter(ctx, \"api_calls\", { aggregation: \"sum\", unit: \"requests\" });\nawait metering.record(ctx, \"api_calls\", orgId, 1, {\n  period: \"2026-06\",\n  idempotencyKey: requestId, // a retry won't double-count\n});\nconst used = await metering.usage(ctx, \"api_calls\", orgId, { period: \"2026-06\" });\n```\n\nDefine a meter, record usage for an opaque subject into host-chosen period buckets, and read O(1)\nper-period rollups for billing or limit checks. Recording is idempotent, so at-least-once retries\nnever double-count. Price it in your own tables — this owns accurate usage, not your rates.\n\n## Features\n\n- **Idempotent recording** — `idempotencyKey` makes a retry a no-op; the dedup ledger is **separate from records**, so it survives `pruneRecords` (no double-count after pruning).\n- **Per-meter aggregation** — `sum` (accumulate), `max` (peak), or `last` (gauge), locked once usage exists.\n- **Corrections + freeze** — `adjust` posts signed refunds/credits (sum, never below zero); `closePeriod` freezes a billed period so late events can't restate it.\n- **Atomic limits** — `recordWithLimit` checks a cap and records in one transaction (no read-then-write overage race).\n- **Reconciliation** — `verify` recomputes the rollup from records and flags drift for billing audits.\n- **Lifecycle + GDPR** — `listMeters`/`listSubjectUsage` discovery, batched `reset`, and `eraseSubject` (erase a subject across all meters).\n- **Period rollups** — each `(meter, subject, period)` rolls up independently; reads are O(1).\n- **Host-owned periods** — `period` is an opaque string you choose (`\"2026-06\"`, `\"2026-W24\"`, `\"all\"`); no date parsing, any calendar/timezone.\n- **Audit + retention** — raw records back every rollup; prune old records on your schedule, rollups stay.\n- **Scopes** — global by default, or namespace per tenant.\n- **Fully typed** — quantities, periods, and units are concrete types end to end; no `any`.\n- **Server-sourced time** — record timestamps come from the server, never the caller.\n\n## Installation\n\n```bash\npnpm add @vllnt/convex-metering\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 metering from \"@vllnt/convex-metering/convex.config\";\n\nconst app = defineApp();\napp.use(metering);\nexport default app;\n```\n\n```ts\n// convex/usage.ts — host owns auth; pass an opaque subjectRef + period in.\nimport { components } from \"./_generated/api\";\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { Metering } from \"@vllnt/convex-metering\";\n\nconst metering = new Metering(components.metering);\n\nexport const meterApiCall = mutation({\n  args: { orgId: v.string(), requestId: v.string() },\n  handler: async (ctx, { orgId, requestId }) =\u003e {\n    return metering.record(ctx, \"api_calls\", orgId, 1, {\n      period: \"2026-06\",\n      idempotencyKey: requestId, // safe under retries\n    });\n  },\n});\n```\n\nRead `usage(ctx, \"api_calls\", orgId, { period })` for a limit check, or bill from it at your own rate\nin your own table — see [`example/convex/example.ts`](example/convex/example.ts).\n\n## API Reference\n\n| Method | Kind | Result |\n|--------|------|--------|\n| `defineMeter(ctx, key, { aggregation?, unit?, scope? })` | mutation | `{ created: boolean }` |\n| `record(ctx, meter, subjectRef, quantity, { period?, idempotencyKey?, actorRef?, scope? })` | mutation | `{ recorded: true; value; count } \\| { recorded: false; reason: \"duplicate\" }` |\n| `recordWithLimit(ctx, meter, subjectRef, quantity, limit, opts?)` | mutation | `RecordOutcome \\| { recorded: false; reason: \"limit_exceeded\"; value; limit }` |\n| `adjust(ctx, meter, subjectRef, delta, opts?)` | mutation | `RecordOutcome` (sum-only signed correction) |\n| `closePeriod(ctx, meter, subjectRef, { period?, scope? })` | mutation | `boolean` (freeze) |\n| `reset(ctx, meter, subjectRef, { period?, scope?, batch? })` | mutation | `number` (records removed this pass) |\n| `eraseSubject(ctx, subjectRef, { scope?, batch? })` | mutation | `number` (GDPR erasure) |\n| `pruneRecords(ctx, before, batch?)` · `pruneSeen(ctx, before, batch?)` | mutation | `number` |\n| `getMeter(ctx, key, scope?)` · `listMeters(ctx, scope?)` | query | `MeterDefinition \\| null` · `MeterDefinition[]` |\n| `usage(ctx, meter, subjectRef, { period?, scope? })` | query | `{ value, count, closed } \\| null` |\n| `listUsage(ctx, meter, subjectRef, scope?)` | query | `{ period, value, count, closed }[]` |\n| `listSubjectUsage(ctx, subjectRef, scope?)` | query | `{ meter, period, value, count, closed }[]` |\n| `verify(ctx, meter, subjectRef, { period?, scope? })` | query | `{ rollupValue, recomputedValue, recordsRemaining, consistent, … }` |\n\nFull reference: [docs/API.md](docs/API.md).\n\n## React\n\nOptional, tree-shakeable hooks via `@vllnt/convex-metering/react` (`react` is an optional peer dep).\nEach wraps `useQuery` over a query reference **you re-export** from your app — the component never owns\nyour `api`. `useUsage` derives a progress-bar view from an optional `limit`.\n\n```tsx\n// convex/usage.ts — re-export a host wrapper (auth gated)\n// export const myUsage = query({ args: { orgId: v.string() },\n//   handler: (ctx, { orgId }) =\u003e metering.usage(ctx, \"api_calls\", orgId, { period: \"2026-06\" }) });\n\nimport { useUsage } from \"@vllnt/convex-metering/react\";\nimport { api } from \"@/convex/_generated/api\";\n\nfunction UsageBar({ orgId }: { orgId: string }) {\n  const u = useUsage(api.usage.myUsage, { meter: \"api_calls\", subjectRef: orgId, period: \"2026-06\" }, { limit: 10000 });\n  if (u.isLoading) return \u003cSpinner /\u003e;\n  return \u003cMeter value={u.fraction} label={`${u.value} / ${u.limit}`} warn={u.exceeded} /\u003e;\n}\n```\n\n| Hook | Wraps | Returns |\n|------|-------|---------|\n| `useUsage(usageRef, args, { limit? })` | `usage` | `{ isLoading, value, count, closed, limit?, remaining?, fraction?, exceeded? }` |\n| `useUsageList(listUsageRef, args)` | `listUsage` | `UsageEntry[] \\| undefined` |\n\n## Security\n\n- Auth-agnostic — the host resolves identity and decides who may define meters, record, reset, or read.\n- Tables sandboxed — reached only through the exported functions; `subjectRef`, `scope`, and `period` stay opaque.\n- Server-sourced timestamps — a caller cannot supply record 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-metering","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvllnt%2Fconvex-metering","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-metering/lists"}