This project uses Convex as its backend.
When working on Convex code, always read example/convex/_generated/ai/guidelines.md first for
important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that
override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running npx convex ai-files install.
Reactions, votes, and likes on any resource, as a Convex component. A reaction is the opaque edge
(authorRef, resourceRef, kind); react toggles it (add if absent, remove if present), counts
tallies a resource per kind, reactors pages who reacted. It follows the vllnt Component Standard
(see the convex-components hub .claude/rules/component-standard.md).
src/
├── shared.ts # the component id / default mount name
├── test.ts # convex-test register() helper
├── client/
│ ├── index.ts # Reactions class (consumer-facing API)
│ └── types.ts # public TypeScript interfaces
└── component/
├── schema.ts # sandboxed table: reactions {authorRef, resourceRef, kind, createdAt}
├── convex.config.ts # defineComponent("reactions")
├── mutations.ts # react (toggle), unreact (idempotent remove)
├── queries.ts # counts, hasReacted, myReactions, reactors
└── validators.ts # shared validators (reactionView, kindCount, reactResult)
Sandboxed table: reactions — indexed by_author_resource_kind (uniqueness / toggle / dedup key),
by_resource_kind (per-resource counts + reactor listing), and by_author_resource (a subject's own
reactions). No host tables are touched. The refs and kind are opaque to the component.
Component owns:
- The reaction edges (
reactionstable) — toggle, remove, count, list - The uniqueness invariant: at most one
(authorRef, resourceRef, kind)edge per subject, enforced inside the mutation transaction - Server-sourced time —
Date.now()insidereactstampscreatedAt; no caller clock - The per-kind tally and the paginated reactor listing
Host owns:
- The subject and the resource being reacted to (and their domain meaning) — passed as opaque
authorRef/resourceRefstrings - The reaction vocabulary (
kind) — freeform, or pinned via the client'sallowedKinds - Auth and authorization — whether a caller may react to a given resource
- Namespacing the opaque refs (a user id, a row id, a tenant-prefixed key)
Auth: the component is completely auth-agnostic. The host resolves identity, decides access, and
passes opaque refs. There is no built-in scope dimension — the host namespaces refs itself, or mounts a
second instance (app.use(component, { name })) for a static partition (e.g. emoji vs votes).
-
Transactional uniqueness (the core invariant):
reactreads the(authorRef, resourceRef, kind)edge via theby_author_resource_kindunique index and inserts-or-deletes in the same mutation transaction. Two concurrent toggles run in serializable transactions, so a duplicate edge can never exist and counts stay exact — this is what makes the toggle safe. -
reactis a toggle,unreactis an explicit idempotent remove:reactflips the edge and returns{ reacted, action }so the host knows which side ran;unreactis the unconditional remove (returnsfalseon a no-op) for a host that wants "ensure absent" semantics rather than a flip. -
Server-sourced time:
reactstampscreatedAtfromDate.now()internally; no API surface accepts a caller-supplied timestamp, so reactor ordering cannot be skewed by a client clock. -
Opaque refs, no
v.any():authorRef,resourceRef, andkindare plain typedv.string()args — the component never de-references them and never reads host tables. There is no arbitrary-data payload, so nojsonValueescape hatch is needed. -
Configurable vocabulary at the client boundary:
allowedKindslets the host pin its reaction set (votes["up","down"], a fixed emoji list) and is enforced in theReactionsclient before the component is called — the component itself stays vocabulary-agnostic (anykindis a valid edge). -
Counts by index scan, no aggregate child (yet):
countsreads a resource's edges via theby_resource_kindindex and tallies in memory — minimal and correct for the 0.1 surface. A future version composing@convex-dev/aggregatefor O(1) counts is the documented growth path (see IDEAS). -
Backend-only (no
./reactentry): a reaction tally / reactor list is an ordinary reactiveuseQueryover the host's own re-exportedcounts/reactorsrefs — a dedicated hook would wrap the host'sapiwith no added value. Re-run the analysis when a real management-surface consumer appears.
- Mutations in
mutations.ts, queries inqueries.ts(enforced by@vllnt/eslint-config/convex). - Explicit
args+returnson every Convex function. - Opaque refs as typed
v.string()— neverv.any()dumps; the component holds no arbitrary host data. - 100% test coverage is BLOCKING (
vitest.config.mtsthresholds: statements, branches, functions, lines). - Runtime deps: only official
@convex-dev/*+@vllnt/*.
| Changed | Update in the same commit |
|---|---|
| Public API (react/unreact/counts/hasReacted/myReactions/reactors signatures) | README API Reference table, docs/API.md, llms.txt context |
| Config options / defaults (allowedKinds) | README API Reference, docs/API.md constructor section |
| Schema / table / indexes | README Architecture, docs/API.md |
| Error model | docs/API.md → ## Error codes section |
peerDependencies.convex version |
llms.txt context line (convex@^X.Y.Z), docs/API.md Compatibility line, README Installation peer note |
| Uniqueness / toggle semantics | docs/API.md mutation sections, Key design decisions above |
Grep old values before committing (e.g. after a peerDependencies.convex bump, git grep "1.41.0" → only the new range survives).