diff --git a/AGENTS.md b/AGENTS.md index f6f2b94..a2c1875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,12 +100,11 @@ second instance (`app.use(component, { name })`) for a static partition (e.g. em | 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, regenerate `llms-full.txt` | +| 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 | -| Any change | `pnpm generate:llms` to keep `llms-full.txt` current | Grep old values before committing (e.g. after a `peerDependencies.convex` bump, `git grep "1.41.0"` → only the new range survives). diff --git a/CLAUDE.md b/CLAUDE.md index f6f2b94..a2c1875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,12 +100,11 @@ second instance (`app.use(component, { name })`) for a static partition (e.g. em | 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, regenerate `llms-full.txt` | +| 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 | -| Any change | `pnpm generate:llms` to keep `llms-full.txt` current | Grep old values before committing (e.g. after a `peerDependencies.convex` bump, `git grep "1.41.0"` → only the new range survives). diff --git a/llms-full.txt b/llms-full.txt deleted file mode 100644 index 50453d5..0000000 --- a/llms-full.txt +++ /dev/null @@ -1,864 +0,0 @@ -# @vllnt/convex-reactions — Full Source - -Auto-generated by `pnpm generate:llms`. Do not edit manually. - ---- - -## README.md - - -[![convex-component](https://img.shields.io/badge/convex-component-EE342F.svg)](https://www.convex.dev/components) -[![npm](https://img.shields.io/npm/v/@vllnt/convex-reactions.svg)](https://www.npmjs.com/package/@vllnt/convex-reactions) -[![CI](https://github.com/vllnt/convex-reactions/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-reactions/actions/workflows/ci.yml) -[![license](https://img.shields.io/npm/l/@vllnt/convex-reactions.svg)](./LICENSE) - -# @vllnt/convex-reactions - -Reactions, votes, and likes on any resource, as a Convex component. - -```ts -const reactions = new Reactions(components.reactions); -await reactions.react(ctx, authorRef, resourceRef, "up"); // toggle the edge in one transaction -await reactions.counts(ctx, resourceRef); // [{ kind, count }], reactive -await reactions.hasReacted(ctx, authorRef, resourceRef, "up"); -``` - -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. One edge per -subject per kind per resource is enforced in the mutation transaction, so toggles and counts stay -correct under concurrency. - -## Features - -- **Toggle** — `react(authorRef, resourceRef, kind)` adds the edge if absent and removes it if present in one transaction, returning `{ reacted, action }`. -- **Idempotent remove** — `unreact(authorRef, resourceRef, kind)` deletes the edge; removing an absent one is a safe no-op (`false`). -- **Count by kind** — `counts(resourceRef)` returns `[{ kind, count }, ...]` sorted by kind; no reactions returns `[]`. -- **List reactors** — `reactors(resourceRef, kind, paginationOpts)` pages who reacted with one kind, oldest first. Reactive in a Convex query. -- **Per-subject state** — `hasReacted` and `myReactions` give the host the subject's own reaction state for rendering controls. -- **Configurable vocabulary** — `allowedKinds` pins the reaction set (`["up", "down"]`, a fixed emoji list); an unknown kind is rejected at the boundary. Omit for freeform. -- **Server-sourced time** — `createdAt` is stamped from the server clock; a caller can't supply a timestamp. -- **Mount-safe** — correct under multiple named `app.use` mounts; each instance is an isolated sandbox. - -## Installation - -```bash -pnpm add @vllnt/convex-reactions -``` - -Peer dependency: `convex@^1.41.0`. - -## Usage - -```ts -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import reactions from "@vllnt/convex-reactions/convex.config"; - -const app = defineApp(); -app.use(reactions); -export default app; -``` - -```ts -// convex/reactions.ts — host owns auth; pass opaque refs in. -import { components } from "./_generated/api"; -import { mutation, query } from "./_generated/server"; -import { paginationOptsValidator } from "convex/server"; -import { v } from "convex/values"; -import { Reactions } from "@vllnt/convex-reactions"; - -const reactions = new Reactions(components.reactions, { - allowedKinds: ["up", "down"], // pin the vocabulary (optional) -}); - -export const vote = mutation({ - args: { postId: v.string(), kind: v.string() }, - handler: async (ctx, { postId, kind }) => { - const userId = await requireUser(ctx); // host auth - return reactions.react(ctx, userId, postId, kind); - }, -}); - -export const tally = query({ - args: { postId: v.string() }, - handler: (ctx, { postId }) => reactions.counts(ctx, postId), -}); -``` - -## API Reference - -| Method | Kind | Result | -|--------|------|--------| -| `react(ctx, authorRef, resourceRef, kind)` | mutation | `{ reacted, action }` (`action`: `"added" \| "removed"`) | -| `unreact(ctx, authorRef, resourceRef, kind)` | mutation | `boolean` (true if an edge was removed) | -| `counts(ctx, resourceRef)` | query | `{ kind, count }[]` (sorted by kind) | -| `hasReacted(ctx, authorRef, resourceRef, kind)` | query | `boolean` | -| `myReactions(ctx, authorRef, resourceRef)` | query | `string[]` (kinds the subject placed) | -| `reactors(ctx, resourceRef, kind, paginationOpts)` | query | `PaginationResult` | - -Full reference: [docs/API.md](docs/API.md). - -## React - -Backend-only — no `./react` entry. A reaction tally or reactor list is an ordinary reactive `useQuery` over the host's own re-exported `counts` / `reactors` refs. - -## Security - -- Auth-agnostic — the host resolves identity and decides who may react. -- Tables sandboxed — reached only through the exported functions; never touches host or sibling tables. -- Uniqueness is transactional + time is server-sourced; refs and `kind` stay opaque to the component. - -See [docs/API.md](docs/API.md). - -## Testing - -```bash -pnpm test # single run -pnpm test:coverage # enforced 100% on covered files -``` - -Tests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks. - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md). - -## Author - -Built by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt) - -Part of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com) - -If this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt). - -## License - -MIT — see [LICENSE](LICENSE). - - ---- - -## CHANGELOG.md - -# Changelog - -All notable changes to this project are documented here. The format is based on -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to -[Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.1.0] - 2026-06-14 - -### Added - -- First release of `@vllnt/convex-reactions` — reactions, votes, and likes on any - resource, modeled as the opaque edge `(authorRef, resourceRef, kind)`. -- `react(authorRef, resourceRef, kind)` toggles the edge in one transaction (add - if absent, remove if present), returning `{ reacted, action }`. One edge per - subject per kind per resource is enforced inside the mutation transaction. -- `unreact(authorRef, resourceRef, kind)` removes the edge; removing one that does - not exist is an idempotent no-op returning `false`. -- `counts(resourceRef)` tallies edges per kind (sorted by kind); `reactors( - resourceRef, kind, paginationOpts)` pages the subjects who reacted oldest-first - via the standard Convex pagination envelope. -- `hasReacted(authorRef, resourceRef, kind)` and `myReactions(authorRef, - resourceRef)` expose a subject's own reaction state. -- Configurable reaction vocabulary: `new Reactions(component, { allowedKinds })` - rejects an unknown `kind` at the client boundary before the component is called. -- Server-sourced time: `createdAt` is stamped from `Date.now()` inside the - mutation — no caller-supplied clock. -- Opaque refs: `authorRef`, `resourceRef`, and `kind` are plain strings the - component never de-references; it never reads host or sibling tables. -- Mount-safe: correct under multiple `app.use(component, { name })` mounts — each - instance is sandboxed. - - ---- - -## docs/API.md - -# API Reference — @vllnt/convex-reactions - -**Compatibility:** `convex@^1.41.0` - -Construct the client with the mounted component and optional config: - -```ts -import { Reactions } from "@vllnt/convex-reactions"; - -const reactions = new Reactions(components.reactions, { - allowedKinds: ["up", "down"], // optional — pin the reaction vocabulary -}); -``` - -All methods take the host `ctx` (a query or mutation context) as the first -argument. `authorRef`, `resourceRef`, and `kind` are opaque strings the host owns -— the component never de-references them. - -**Time is server-sourced.** `react` stamps `createdAt` from `Date.now()` itself; -no method accepts a caller-supplied clock. - -**Vocabulary.** When `allowedKinds` is set, `react` and `unreact` throw before -calling the component if `kind` is not in the list. Omit it to accept any freeform -`kind`. - -## Mutations - -### `react(ctx, authorRef, resourceRef, kind) → { reacted, action }` - -Toggle a subject's reaction edge in one transaction. If the -`(authorRef, resourceRef, kind)` edge is absent it is inserted (`reacted: true`, -`action: "added"`); if it already exists it is deleted (`reacted: false`, -`action: "removed"`). The read and write share the transaction, so two concurrent -toggles cannot both insert — one edge per subject per kind per resource holds. - -`createdAt` is stamped from the server clock. A `kind` outside the configured -`allowedKinds` throws before storage. - -### `unreact(ctx, authorRef, resourceRef, kind) → boolean` - -Remove a subject's `(authorRef, resourceRef, kind)` edge. Returns `true` when an -edge was deleted, `false` when none existed. Idempotent — a duplicate or replayed -`unreact` is a safe no-op. A `kind` outside `allowedKinds` throws. - -## Queries - -### `counts(ctx, resourceRef) → { kind, count }[]` - -Tally reaction edges per `kind` on one resource, sorted by `kind`. `count` is the -number of distinct subjects that reacted with that kind. A resource with no -reactions returns `[]`. - -### `hasReacted(ctx, authorRef, resourceRef, kind) → boolean` - -Whether a subject holds a `(authorRef, resourceRef, kind)` edge — the per-subject -toggle state the host renders next to a reaction control. - -### `myReactions(ctx, authorRef, resourceRef) → string[]` - -Every reaction `kind` a subject placed on one resource. Empty when the subject has -not reacted to the resource. - -### `reactors(ctx, resourceRef, kind, paginationOpts) → PaginationResult` - -Page the subjects who reacted to `resourceRef` with one `kind`, oldest first via -the `by_resource_kind` index. Takes the standard Convex `paginationOpts` and -returns the standard paginated envelope (`page`, `isDone`, `continueCursor`). -`ReactionView` is `{ authorRef, resourceRef, kind, createdAt }`. - -## Error codes - -The component throws no coded `ConvexError`s — `react`/`unreact` are total over -valid string args (toggle and idempotent remove never reject on state). The only -boundary rejection is a client-side `Error("invalid reaction kind ... not in the -configured allowlist")` thrown by the `Reactions` client when `allowedKinds` is -set and `kind` is not in it — raised before the component is called. - - ---- - -## src/shared.ts - -```ts -/** Shared constants used by both `client/` and `component/`. */ - -/** - * The component id (`defineComponent("reactions")`) and the default mount name a - * host gets from `app.use(component)`. Used as the default `register()` mount in - * tests so the name is declared in exactly one place. - */ -export const COMPONENT_NAME = "reactions"; - -``` - ---- - -## src/client/types.ts - -```ts -/** Public TypeScript surface for the reactions client. */ - -/** A single reaction edge as returned by {@link Reactions.reactors}. */ -export interface ReactionView { - /** The opaque host subject reference that placed the reaction. */ - authorRef: string; - /** The opaque host resource reference the reaction is on. */ - resourceRef: string; - /** The reaction kind (host-defined: an emoji, `"up"`/`"down"`, `"like"`). */ - kind: string; - /** Absolute ms timestamp the edge was created (server clock). */ - createdAt: number; -} - -/** A per-kind tally returned by {@link Reactions.counts}. */ -export interface KindCount { - /** The reaction kind. */ - kind: string; - /** The number of distinct subjects that reacted with this kind. */ - count: number; -} - -/** The outcome of a toggle {@link Reactions.react} call. */ -export interface ReactResult { - /** Whether the subject now holds the edge (true after add, false after remove). */ - reacted: boolean; - /** Which side of the toggle ran. */ - action: "added" | "removed"; -} - -/** Construction options for the {@link Reactions} client. */ -export interface ReactionsOptions { - /** - * Optional allowlist of permitted reaction `kind` values. When set, `react` - * and `unreact` throw before calling the component if `kind` is not in the - * list — the host pins its reaction vocabulary (e.g. `["up", "down"]` or a - * fixed emoji set). Omit to accept any freeform `kind`. - */ - allowedKinds?: readonly string[]; -} - -``` - ---- - -## src/client/index.ts - -```ts -import type { - FunctionArgs, - FunctionReference, - FunctionReturnType, - PaginationOptions, - PaginationResult, -} from "convex/server"; -import type { - KindCount, - ReactResult, - ReactionView, - ReactionsOptions, -} from "./types.js"; - -/** - * The reactions component's function references, as exposed on the host via - * `components.reactions`. The opaque host refs (`authorRef`/`resourceRef`) and - * the `kind` are plain strings here; the host owns their meaning. - */ -export interface ReactionsComponent { - mutations: { - react: FunctionReference< - "mutation", - "internal", - { authorRef: string; resourceRef: string; kind: string }, - ReactResult - >; - unreact: FunctionReference< - "mutation", - "internal", - { authorRef: string; resourceRef: string; kind: string }, - boolean - >; - }; - queries: { - counts: FunctionReference< - "query", - "internal", - { resourceRef: string }, - KindCount[] - >; - hasReacted: FunctionReference< - "query", - "internal", - { authorRef: string; resourceRef: string; kind: string }, - boolean - >; - myReactions: FunctionReference< - "query", - "internal", - { authorRef: string; resourceRef: string }, - string[] - >; - reactors: FunctionReference< - "query", - "internal", - { resourceRef: string; kind: string; paginationOpts: PaginationOptions }, - PaginationResult - >; - }; -} - -interface RunQueryCtx { - runQuery>( - reference: Q, - args: FunctionArgs, - ): Promise>; -} - -interface RunMutationCtx { - runMutation>( - reference: M, - args: FunctionArgs, - ): Promise>; -} - -/** - * Consumer-facing client for reactions / votes / likes on any resource. A - * reaction is the opaque edge `(authorRef, resourceRef, kind)`: a host subject - * reacted to a host resource with one reaction kind. One edge per subject per - * kind per resource is enforced inside the mutation transaction, so `react` - * toggles (add if absent, remove if present) and counts stay correct under - * concurrent toggles. - * - * The host owns meaning and auth — it resolves identity, decides who may react, - * and passes opaque `authorRef` / `resourceRef` strings and a `kind`. Pin the - * reaction vocabulary with `allowedKinds` to reject unknown kinds at the - * boundary; omit it to accept freeform kinds. - * - * @example - * ```ts - * const reactions = new Reactions(components.reactions, { - * allowedKinds: ["up", "down"], - * }); - * await reactions.react(ctx, userId, postId, "up"); // toggle a vote - * const tally = await reactions.counts(ctx, postId); // [{ kind: "up", count: 1 }] - * const mine = await reactions.myReactions(ctx, userId, postId); // ["up"] - * ``` - */ -export class Reactions { - private readonly allowedKinds: ReadonlySet | undefined; - - constructor( - private readonly component: ReactionsComponent, - options: ReactionsOptions = {}, - ) { - this.allowedKinds = - options.allowedKinds === undefined - ? undefined - : new Set(options.allowedKinds); - } - - /** Reject a `kind` outside the configured allowlist (no-op when unset). */ - private assertKind(kind: string): void { - if (this.allowedKinds !== undefined && !this.allowedKinds.has(kind)) { - throw new Error( - `invalid reaction kind "${kind}": not in the configured allowlist`, - ); - } - } - - /** - * Toggle a subject's reaction on a resource. Adds the - * `(authorRef, resourceRef, kind)` edge if absent, removes it if present, in - * one transaction. Returns whether the edge now exists and which side ran. - * Throws if `kind` is outside the configured allowlist. - */ - react( - ctx: RunMutationCtx, - authorRef: string, - resourceRef: string, - kind: string, - ): Promise { - this.assertKind(kind); - return ctx.runMutation(this.component.mutations.react, { - authorRef, - resourceRef, - kind, - }); - } - - /** - * Remove a subject's reaction edge. Idempotent — removing an edge that does - * not exist returns `false`; a real removal returns `true`. Throws if `kind` - * is outside the configured allowlist. - */ - unreact( - ctx: RunMutationCtx, - authorRef: string, - resourceRef: string, - kind: string, - ): Promise { - this.assertKind(kind); - return ctx.runMutation(this.component.mutations.unreact, { - authorRef, - resourceRef, - kind, - }); - } - - /** - * Tally reactions per `kind` on a resource — `[{ kind, count }, ...]` sorted - * by `kind`. Empty when the resource has no reactions. - */ - counts(ctx: RunQueryCtx, resourceRef: string): Promise { - return ctx.runQuery(this.component.queries.counts, { resourceRef }); - } - - /** Whether a subject holds a `(authorRef, resourceRef, kind)` reaction edge. */ - hasReacted( - ctx: RunQueryCtx, - authorRef: string, - resourceRef: string, - kind: string, - ): Promise { - return ctx.runQuery(this.component.queries.hasReacted, { - authorRef, - resourceRef, - kind, - }); - } - - /** Every reaction `kind` a subject placed on one resource (their own state). */ - myReactions( - ctx: RunQueryCtx, - authorRef: string, - resourceRef: string, - ): Promise { - return ctx.runQuery(this.component.queries.myReactions, { - authorRef, - resourceRef, - }); - } - - /** - * Page the subjects who reacted to a resource with one `kind`, oldest first. - * Returns the standard Convex pagination envelope. - */ - reactors( - ctx: RunQueryCtx, - resourceRef: string, - kind: string, - paginationOpts: PaginationOptions, - ): Promise> { - return ctx.runQuery(this.component.queries.reactors, { - resourceRef, - kind, - paginationOpts, - }); - } -} - -export type { KindCount, ReactResult, ReactionView, ReactionsOptions }; - -``` - ---- - -## src/component/schema.ts - -```ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -/** - * Sandboxed table — one reaction edge per row. An edge is the opaque triple - * `(authorRef, resourceRef, kind)`: a host subject reacted to a host resource - * with one reaction kind. `createdAt` is stamped from the server clock. The - * component never de-references the opaque refs and never reads host tables. - * - * Indexes: - * - `by_author_resource_kind` — the uniqueness / toggle / dedup key. A - * `(authorRef, resourceRef, kind)` lookup is `.unique()`, so one subject can - * hold at most one edge per kind on a resource (enforced in the mutation - * transaction). - * - `by_resource_kind` — count edges of one kind on a resource and page its - * reactors oldest-first. - * - `by_author_resource` — list every kind a subject placed on a resource - * (`myReactions`). - */ -export default defineSchema({ - reactions: defineTable({ - authorRef: v.string(), - resourceRef: v.string(), - kind: v.string(), - createdAt: v.number(), - }) - .index("by_author_resource_kind", ["authorRef", "resourceRef", "kind"]) - .index("by_resource_kind", ["resourceRef", "kind", "createdAt"]) - .index("by_author_resource", ["authorRef", "resourceRef"]), -}); - -``` - ---- - -## src/component/validators.ts - -```ts -import { v } from "convex/values"; - -/** - * Public projection of a stored reaction edge returned by {@link reactors}. - * `authorRef` and `resourceRef` are opaque host references the component never - * de-references; `kind` is the reaction tag (an emoji, `"up"`/`"down"`, `"like"` - * — the host decides the vocabulary and may constrain it with an allowlist). - */ -export const reactionView = v.object({ - authorRef: v.string(), - resourceRef: v.string(), - kind: v.string(), - createdAt: v.number(), -}); - -/** - * A single `{ kind, count }` tally returned by {@link counts}. The component - * counts reaction edges per `kind` on a resource; `kind` is host-defined and - * opaque. - */ -export const kindCount = v.object({ - kind: v.string(), - count: v.number(), -}); - -/** - * The result of a toggle {@link react} call: whether the edge now exists - * (`reacted`) after the toggle, and the action that produced that state. - */ -export const reactResult = v.object({ - reacted: v.boolean(), - action: v.union(v.literal("added"), v.literal("removed")), -}); - -``` - ---- - -## src/component/mutations.ts - -```ts -import { v } from "convex/values"; -import { mutation } from "./_generated/server"; -import { reactResult } from "./validators"; - -/** - * Toggle a reaction edge in one transaction. If the subject has no - * `(authorRef, resourceRef, kind)` edge it is inserted (`action: "added"`, - * `reacted: true`); if it already exists the existing edge is deleted - * (`action: "removed"`, `reacted: false`). The read and the write share the - * mutation transaction, so two concurrent toggles cannot both insert — the - * uniqueness invariant (≤1 edge per subject per kind per resource) holds. - * - * `createdAt` is stamped from the server clock (`Date.now()` inside the handler - * — never caller-supplied). `kind` vocabulary and any allowlist are the host's - * concern, enforced at the client boundary before this call. - */ -export const react = mutation({ - args: { - authorRef: v.string(), - resourceRef: v.string(), - kind: v.string(), - }, - returns: reactResult, - handler: async (ctx, args) => { - const existing = await ctx.db - .query("reactions") - .withIndex("by_author_resource_kind", (q) => - q - .eq("authorRef", args.authorRef) - .eq("resourceRef", args.resourceRef) - .eq("kind", args.kind), - ) - .unique(); - - if (existing !== null) { - await ctx.db.delete(existing._id); - return { reacted: false, action: "removed" as const }; - } - - await ctx.db.insert("reactions", { - authorRef: args.authorRef, - resourceRef: args.resourceRef, - kind: args.kind, - createdAt: Date.now(), - }); - return { reacted: true, action: "added" as const }; - }, -}); - -/** - * Remove a subject's `(authorRef, resourceRef, kind)` reaction edge. Idempotent: - * removing an edge that does not exist is a no-op that returns `false`, so a - * duplicate or replayed `unreact` is safe. Returns `true` only when an edge was - * actually deleted. - */ -export const unreact = mutation({ - args: { - authorRef: v.string(), - resourceRef: v.string(), - kind: v.string(), - }, - returns: v.boolean(), - handler: async (ctx, args) => { - const existing = await ctx.db - .query("reactions") - .withIndex("by_author_resource_kind", (q) => - q - .eq("authorRef", args.authorRef) - .eq("resourceRef", args.resourceRef) - .eq("kind", args.kind), - ) - .unique(); - if (existing === null) { - return false; - } - await ctx.db.delete(existing._id); - return true; - }, -}); - -``` - ---- - -## src/component/queries.ts - -```ts -import { v } from "convex/values"; -import { paginationOptsValidator } from "convex/server"; -import { query } from "./_generated/server"; -import { kindCount, reactionView } from "./validators"; -import type { Doc } from "./_generated/dataModel"; - -/** Project a stored reaction row to its public view (drops internal fields). */ -function view(row: Doc<"reactions">) { - return { - authorRef: row.authorRef, - resourceRef: row.resourceRef, - kind: row.kind, - createdAt: row.createdAt, - }; -} - -/** - * Tally reaction edges per `kind` on one resource. Reads every edge for - * `resourceRef` via the `by_resource_kind` index (which is prefixed by - * `resourceRef`, so a `resourceRef`-only range spans all kinds) and groups them - * into `{ kind, count }` rows, sorted by `kind` for a stable order. A resource - * with no reactions returns an empty array. - */ -export const counts = query({ - args: { resourceRef: v.string() }, - returns: v.array(kindCount), - handler: async (ctx, args) => { - const rows = await ctx.db - .query("reactions") - .withIndex("by_resource_kind", (q) => q.eq("resourceRef", args.resourceRef)) - .collect(); - const tally = new Map(); - for (const row of rows) { - tally.set(row.kind, (tally.get(row.kind) ?? 0) + 1); - } - // Sort by kind for a stable, deterministic order. `localeCompare` is a - // single expression (no comparator branch), so coverage stays exact. - return [...tally.entries()] - .map(([kind, count]) => ({ kind, count })) - .sort((a, b) => a.kind.localeCompare(b.kind)); - }, -}); - -/** - * Whether a subject holds a `(authorRef, resourceRef, kind)` reaction edge. A - * unique point read over `by_author_resource_kind` — the per-subject toggle - * state the host renders next to a reaction control. - */ -export const hasReacted = query({ - args: { - authorRef: v.string(), - resourceRef: v.string(), - kind: v.string(), - }, - returns: v.boolean(), - handler: async (ctx, args) => { - const edge = await ctx.db - .query("reactions") - .withIndex("by_author_resource_kind", (q) => - q - .eq("authorRef", args.authorRef) - .eq("resourceRef", args.resourceRef) - .eq("kind", args.kind), - ) - .unique(); - return edge !== null; - }, -}); - -/** - * Every reaction kind a subject placed on one resource (their own reaction - * state across kinds), via the `by_author_resource` index. Returns the list of - * `kind` strings; empty when the subject has not reacted to the resource. - */ -export const myReactions = query({ - args: { authorRef: v.string(), resourceRef: v.string() }, - returns: v.array(v.string()), - handler: async (ctx, args) => { - const rows = await ctx.db - .query("reactions") - .withIndex("by_author_resource", (q) => - q.eq("authorRef", args.authorRef).eq("resourceRef", args.resourceRef), - ) - .collect(); - return rows.map((row) => row.kind); - }, -}); - -/** - * Page the subjects who reacted to `resourceRef` with one `kind`, oldest first - * via the `by_resource_kind` index. Takes the standard Convex `paginationOpts` - * and returns the standard paginated envelope (`page`, `isDone`, - * `continueCursor`) so the host can list reactors reactively. - */ -export const reactors = query({ - args: { - resourceRef: v.string(), - kind: v.string(), - paginationOpts: paginationOptsValidator, - }, - returns: v.object({ - page: v.array(reactionView), - isDone: v.boolean(), - continueCursor: v.string(), - splitCursor: v.optional(v.union(v.string(), v.null())), - pageStatus: v.optional( - v.union( - v.literal("SplitRecommended"), - v.literal("SplitRequired"), - v.null(), - ), - ), - }), - handler: async (ctx, args) => { - const result = await ctx.db - .query("reactions") - .withIndex("by_resource_kind", (q) => - q.eq("resourceRef", args.resourceRef).eq("kind", args.kind), - ) - .order("asc") - .paginate(args.paginationOpts); - return { ...result, page: result.page.map(view) }; - }, -}); - -``` - ---- - -## src/test.ts - -```ts -import type { TestConvex } from "convex-test"; -import schema from "./component/schema"; -import { COMPONENT_NAME } from "./shared"; - -const modules = import.meta.glob("./component/**/*.ts"); - -/** - * Register this component with a `convex-test` instance so consuming apps can - * test integration: `import { register } from "@vllnt/convex-reactions/test"`. - */ -export function register( - t: TestConvex, - name: string = COMPONENT_NAME, -): void { - t.registerComponent(name, schema, modules); -} - -``` diff --git a/llms.txt b/llms.txt index 5f08371..095517c 100644 --- a/llms.txt +++ b/llms.txt @@ -23,4 +23,3 @@ It stores one sandboxed table - `reactions` (a reaction edge `{ authorRef, resou - [Security Policy](SECURITY.md): Vulnerability reporting policy - [Code of Conduct](CODE_OF_CONDUCT.md): Community participation guidelines - [License](LICENSE): MIT license -- [Full source bundle](llms-full.txt): Aggregated README, changelog, API docs, and key source files diff --git a/package.json b/package.json index c22dafd..43d802b 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,7 @@ "test": "vitest run --passWithNoTests", "test:watch": "vitest --typecheck --clearScreen false", "test:coverage": "vitest run --coverage --coverage.reporter=text", - "generate:llms": "node scripts/generate-llms.mjs", - "preversion": "pnpm install --frozen-lockfile && pnpm build && pnpm test:coverage && pnpm typecheck && pnpm generate:llms", + "preversion": "pnpm install --frozen-lockfile && pnpm build && pnpm test:coverage && pnpm typecheck", "prepublishOnly": "npm whoami || npm login", "alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags", "release": "npm version patch && npm publish && git push --follow-tags" diff --git a/scripts/generate-llms.mjs b/scripts/generate-llms.mjs deleted file mode 100644 index 74b444e..0000000 --- a/scripts/generate-llms.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; - -// Files bundled into llms-full.txt, in order. Keep in sync with the public surface. -const files = [ - "README.md", - "CHANGELOG.md", - "docs/API.md", - "src/shared.ts", - "src/client/types.ts", - "src/client/index.ts", - "src/component/schema.ts", - "src/component/validators.ts", - "src/component/mutations.ts", - "src/component/queries.ts", - "src/test.ts", -]; - -let out = `# @vllnt/convex-reactions — Full Source\n\nAuto-generated by \`pnpm generate:llms\`. Do not edit manually.\n`; - -for (const f of files) { - out += `\n---\n\n## ${f}\n\n`; - if (f.endsWith(".md")) { - out += readFileSync(f, "utf8"); - } else { - out += "```ts\n" + readFileSync(f, "utf8") + "\n```"; - } - out += "\n"; -} - -writeFileSync("llms-full.txt", out); -console.log(`Wrote llms-full.txt (${files.length} files)`);