@vllnt/convex-metering
v0.1.0
Published
Metered usage records — idempotent usage events rolled up per billing period, as a Convex component
Maintainers
Readme
@vllnt/convex-metering
Metered usage records — idempotent usage events rolled up per billing period, as a Convex component.
const metering = new Metering(components.metering);
await metering.defineMeter(ctx, "api_calls", { aggregation: "sum", unit: "requests" });
await metering.record(ctx, "api_calls", orgId, 1, {
period: "2026-06",
idempotencyKey: requestId, // a retry won't double-count
});
const used = await metering.usage(ctx, "api_calls", orgId, { period: "2026-06" });Define a meter, record usage for an opaque subject into host-chosen period buckets, and read O(1) per-period rollups for billing or limit checks. Recording is idempotent, so at-least-once retries never double-count. Price it in your own tables — this owns accurate usage, not your rates.
Features
- Idempotent recording —
idempotencyKeymakes a retry a no-op; the dedup ledger is separate from records, so it survivespruneRecords(no double-count after pruning). - Per-meter aggregation —
sum(accumulate),max(peak), orlast(gauge), locked once usage exists. - Corrections + freeze —
adjustposts signed refunds/credits (sum, never below zero);closePeriodfreezes a billed period so late events can't restate it. - Atomic limits —
recordWithLimitchecks a cap and records in one transaction (no read-then-write overage race). - Reconciliation —
verifyrecomputes the rollup from records and flags drift for billing audits. - Lifecycle + GDPR —
listMeters/listSubjectUsagediscovery, batchedreset, anderaseSubject(erase a subject across all meters). - Period rollups — each
(meter, subject, period)rolls up independently; reads are O(1). - Host-owned periods —
periodis an opaque string you choose ("2026-06","2026-W24","all"); no date parsing, any calendar/timezone. - Audit + retention — raw records back every rollup; prune old records on your schedule, rollups stay.
- Scopes — global by default, or namespace per tenant.
- Fully typed — quantities, periods, and units are concrete types end to end; no
any. - Server-sourced time — record timestamps come from the server, never the caller.
Installation
pnpm add @vllnt/convex-meteringPeer dependency: convex@^1.41.0.
Usage
// convex/convex.config.ts
import { defineApp } from "convex/server";
import metering from "@vllnt/convex-metering/convex.config";
const app = defineApp();
app.use(metering);
export default app;// convex/usage.ts — host owns auth; pass an opaque subjectRef + period in.
import { components } from "./_generated/api";
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { Metering } from "@vllnt/convex-metering";
const metering = new Metering(components.metering);
export const meterApiCall = mutation({
args: { orgId: v.string(), requestId: v.string() },
handler: async (ctx, { orgId, requestId }) => {
return metering.record(ctx, "api_calls", orgId, 1, {
period: "2026-06",
idempotencyKey: requestId, // safe under retries
});
},
});Read usage(ctx, "api_calls", orgId, { period }) for a limit check, or bill from it at your own rate
in your own table — see example/convex/example.ts.
API Reference
| Method | Kind | Result |
|--------|------|--------|
| defineMeter(ctx, key, { aggregation?, unit?, scope? }) | mutation | { created: boolean } |
| record(ctx, meter, subjectRef, quantity, { period?, idempotencyKey?, actorRef?, scope? }) | mutation | { recorded: true; value; count } \| { recorded: false; reason: "duplicate" } |
| recordWithLimit(ctx, meter, subjectRef, quantity, limit, opts?) | mutation | RecordOutcome \| { recorded: false; reason: "limit_exceeded"; value; limit } |
| adjust(ctx, meter, subjectRef, delta, opts?) | mutation | RecordOutcome (sum-only signed correction) |
| closePeriod(ctx, meter, subjectRef, { period?, scope? }) | mutation | boolean (freeze) |
| reset(ctx, meter, subjectRef, { period?, scope?, batch? }) | mutation | number (records removed this pass) |
| eraseSubject(ctx, subjectRef, { scope?, batch? }) | mutation | number (GDPR erasure) |
| pruneRecords(ctx, before, batch?) · pruneSeen(ctx, before, batch?) | mutation | number |
| getMeter(ctx, key, scope?) · listMeters(ctx, scope?) | query | MeterDefinition \| null · MeterDefinition[] |
| usage(ctx, meter, subjectRef, { period?, scope? }) | query | { value, count, closed } \| null |
| listUsage(ctx, meter, subjectRef, scope?) | query | { period, value, count, closed }[] |
| listSubjectUsage(ctx, subjectRef, scope?) | query | { meter, period, value, count, closed }[] |
| verify(ctx, meter, subjectRef, { period?, scope? }) | query | { rollupValue, recomputedValue, recordsRemaining, consistent, … } |
Full reference: docs/API.md.
React
Optional, tree-shakeable hooks via @vllnt/convex-metering/react (react is an optional peer dep).
Each wraps useQuery over a query reference you re-export from your app — the component never owns
your api. useUsage derives a progress-bar view from an optional limit.
// convex/usage.ts — re-export a host wrapper (auth gated)
// export const myUsage = query({ args: { orgId: v.string() },
// handler: (ctx, { orgId }) => metering.usage(ctx, "api_calls", orgId, { period: "2026-06" }) });
import { useUsage } from "@vllnt/convex-metering/react";
import { api } from "@/convex/_generated/api";
function UsageBar({ orgId }: { orgId: string }) {
const u = useUsage(api.usage.myUsage, { meter: "api_calls", subjectRef: orgId, period: "2026-06" }, { limit: 10000 });
if (u.isLoading) return <Spinner />;
return <Meter value={u.fraction} label={`${u.value} / ${u.limit}`} warn={u.exceeded} />;
}| Hook | Wraps | Returns |
|------|-------|---------|
| useUsage(usageRef, args, { limit? }) | usage | { isLoading, value, count, closed, limit?, remaining?, fraction?, exceeded? } |
| useUsageList(listUsageRef, args) | listUsage | UsageEntry[] \| undefined |
Security
- Auth-agnostic — the host resolves identity and decides who may define meters, record, reset, or read.
- Tables sandboxed — reached only through the exported functions;
subjectRef,scope, andperiodstay opaque. - Server-sourced timestamps — a caller cannot supply record time.
See docs/API.md.
Testing
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
Contributing
See CONTRIBUTING.md.
Author
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
License
MIT — see LICENSE.
