From 2cd3edfbf5646f0e888d43c07e94555d5491750c Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:53:42 +0200 Subject: [PATCH] docs: drop llms-full.txt, keep only llms.txt Remove the generated llms-full.txt artifact and its generate:llms generator; the curated llms.txt index remains the single AI-discovery file. Claude-Session: https://claude.ai/code/session_01RmQ1xbKKSpjsEEMdMRZ8ye --- AGENTS.md | 3 +- CLAUDE.md | 3 +- llms-full.txt | 1076 ------------------------------------- llms.txt | 1 - package.json | 3 +- scripts/generate-llms.mjs | 32 -- 6 files changed, 3 insertions(+), 1115 deletions(-) delete mode 100644 llms-full.txt delete mode 100644 scripts/generate-llms.mjs diff --git a/AGENTS.md b/AGENTS.md index c27fb3c..6dcc378 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,12 +116,11 @@ mounts a second instance (`app.use(component, { name })`) for a static partition | Changed | Update in the same commit | |---------|--------------------------| -| Public API (deliver/markRead/markAllRead/get/list/unreadCount/purge signatures) | README API Reference table, `docs/API.md`, `llms.txt` context, regenerate `llms-full.txt` | +| Public API (deliver/markRead/markAllRead/get/list/unreadCount/purge signatures) | README API Reference table, `docs/API.md`, `llms.txt` context | | Config options / defaults (validator, retention, batch, maxFanOut) | README API Reference, `docs/API.md` constructor section | | Schema / table / indexes | README Architecture, `docs/API.md` | | Error codes | `docs/API.md` → `## Error codes` table | | `peerDependencies.convex` version | `llms.txt` context line (`convex@^X.Y.Z`), `docs/API.md` Compatibility line, README Installation peer note | | Read-state / inbox semantics | `docs/API.md` mutation/query 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 c27fb3c..6dcc378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,12 +116,11 @@ mounts a second instance (`app.use(component, { name })`) for a static partition | Changed | Update in the same commit | |---------|--------------------------| -| Public API (deliver/markRead/markAllRead/get/list/unreadCount/purge signatures) | README API Reference table, `docs/API.md`, `llms.txt` context, regenerate `llms-full.txt` | +| Public API (deliver/markRead/markAllRead/get/list/unreadCount/purge signatures) | README API Reference table, `docs/API.md`, `llms.txt` context | | Config options / defaults (validator, retention, batch, maxFanOut) | README API Reference, `docs/API.md` constructor section | | Schema / table / indexes | README Architecture, `docs/API.md` | | Error codes | `docs/API.md` → `## Error codes` table | | `peerDependencies.convex` version | `llms.txt` context line (`convex@^X.Y.Z`), `docs/API.md` Compatibility line, README Installation peer note | | Read-state / inbox semantics | `docs/API.md` mutation/query 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 0b2710c..0000000 --- a/llms-full.txt +++ /dev/null @@ -1,1076 +0,0 @@ -# @vllnt/convex-notifications — 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-notifications.svg)](https://www.npmjs.com/package/@vllnt/convex-notifications) -[![CI](https://github.com/vllnt/convex-notifications/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-notifications/actions/workflows/ci.yml) -[![license](https://img.shields.io/npm/l/@vllnt/convex-notifications.svg)](./LICENSE) - -# @vllnt/convex-notifications - -A per-subject directed inbox — notifications with read/unread state and fan-out, as a Convex component. - -```ts -const inbox = new Notifications(components.notifications); -await inbox.deliver(ctx, recipients, "mention", { actor }); // one row per recipient -await inbox.list(ctx, subjectRef, paginationOpts, { unreadOnly: true }); // reactive inbox -await inbox.unreadCount(ctx, subjectRef); -await inbox.markRead(ctx, notificationId); -``` - -## Features - -- **Deliver + fan-out** — `deliver(subjectRef | subjectRefs[], type, payload?)` writes one notification per recipient and mints a `notificationId` for each. -- **Read state** — every notification arrives unread; `markRead` (idempotent) and `markAllRead` (bounded, self-rescheduling) clear it. -- **Inbox queries** — `list` pages newest-first (all or unread-only), `unreadCount` totals the unread, `get` fetches one. Reactive in a Convex query. -- **Subject-bounded reads** — a `list`/`unreadCount` is keyed to one subject and never spans another's inbox. -- **Server-sourced time** — `createdAt`/`readAt` are stamped from the server clock; a caller can't supply a timestamp. -- **Typed, opaque payload** — `Notifications` types the stored `payload`; `payloadValidator` narrows it at the boundary. -- **Bounded purge + cron** — a daily cron sweeps read notifications past retention in batches; unread are never purged. -- **Mount-safe** — correct under multiple named `app.use` mounts; each instance is an isolated sandbox. - -## Installation - -```bash -pnpm add @vllnt/convex-notifications -``` - -Peer dependency: `convex@^1.41.0`. - -## Usage - -```ts -// convex/convex.config.ts -import { defineApp } from "convex/server"; -import notifications from "@vllnt/convex-notifications/convex.config"; - -const app = defineApp(); -app.use(notifications); -export default app; -``` - -```ts -// convex/notify.ts — host owns auth; pass opaque subjectRefs in. -import { components } from "./_generated/api"; -import { mutation, query } from "./_generated/server"; -import { v } from "convex/values"; -import { Notifications } from "@vllnt/convex-notifications"; - -const inbox = new Notifications<{ actor: string }>(components.notifications, { - payloadValidator: v.object({ actor: v.string() }).parse, // narrow at the boundary -}); - -export const notifyMention = mutation({ - args: { recipients: v.array(v.string()), actor: v.string() }, - handler: (ctx, { recipients, actor }) => - inbox.deliver(ctx, recipients, "mention", { actor }), -}); - -export const myUnread = query({ - args: { subjectRef: v.string(), paginationOpts: v.any() }, - handler: (ctx, { subjectRef, paginationOpts }) => - inbox.list(ctx, subjectRef, paginationOpts, { unreadOnly: true }), -}); -``` - -## API Reference - -| Method | Kind | Result | -|--------|------|--------| -| `deliver(ctx, subjects, type, payload?)` | mutation | `{ notificationIds }` (`subjects`: one ref or an array) | -| `markRead(ctx, notificationId)` | mutation | `null` | -| `markAllRead(ctx, subjectRef, opts?)` | mutation | `number` (marked in the first bounded pass) | -| `get(ctx, notificationId)` | query | `NotificationView \| null` | -| `list(ctx, subjectRef, paginationOpts, opts?)` | query | `PaginationResult` (`opts`: `{ unreadOnly? }`) | -| `unreadCount(ctx, subjectRef)` | query | `number` | -| `purge(ctx, opts?)` | mutation | `number` (read notifications removed in the first bounded pass) | - -Full reference: [docs/API.md](docs/API.md). - -## React - -Backend-only — no `./react` entry. An inbox, unread count, and unread list are ordinary reactive `useQuery` calls over the host's own re-exported `list` / `unreadCount` / `get` refs. - -## Security - -- Auth-agnostic — the host resolves identity and decides who may deliver to or read a `subjectRef`. -- Tables sandboxed — reached only through the exported functions; never touches host or sibling tables. -- Subject-bounded reads + server-sourced time; `subjectRef` / `payload` 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-notifications` — a per-subject directed inbox - with read/unread state and fan-out. -- `deliver(subjectRef | subjectRefs[], type, payload?)` fans one notification out - to each recipient, minting a `notificationId` per row and inserting it unread; - rejects an empty fan-out (`EMPTY_FANOUT`) and one over `maxFanOut` - (`FANOUT_TOO_LARGE`). -- `markRead(notificationId)` marks one notification read (idempotent), stamping - `readAt`; `markAllRead(subjectRef)` marks a subject's whole unread inbox in - bounded, self-rescheduling batches. -- `get(notificationId)` returns the current notification (or `null`); - `list(subjectRef, paginationOpts, { unreadOnly? })` pages a subject's inbox - newest-first via the standard Convex pagination envelope; `unreadCount(subjectRef)` - returns the unread total. -- Subject-bounded reads: a `list`/`unreadCount` for one subject never spans - another subject's rows (the indexes are subject-keyed). -- Server-sourced time: every handler stamps `createdAt`/`readAt` from `Date.now()` - inside the mutation — no caller-supplied clock. -- Typed generics: `Notifications` with an optional `payloadValidator` - host parser narrowing the opaque stored `payload` at the client boundary on - write and read — no `v.any()` dump, no unchecked cast. -- Bounded, self-rescheduling `purge` (`take(batch)` + scheduler) that removes only - **read** notifications past their `createdAt` cutoff, plus a built-in daily purge - cron (`crons.ts`); unread notifications are never purged. Default retention 30 - days. -- Mount-safe: correct under multiple `app.use(component, { name })` mounts — each - instance is sandboxed, the cron is registered per instance. - - ---- - -## docs/API.md - -# API Reference — @vllnt/convex-notifications - -**Compatibility:** `convex@^1.41.0` - -Construct the client with the mounted component and optional host config: - -```ts -import { Notifications } from "@vllnt/convex-notifications"; -import { v } from "convex/values"; - -const inbox = new Notifications(components.notifications, { - payloadValidator: v.object({ actor: v.string() }).parse, // narrow stored payload - maxFanOut: 256, // cap a single deliver fan-out (default 256) -}); -``` - -`Notifications` is generic over the host's opaque `payload` -type. All methods take the host `ctx` (a query or mutation context) as the first -argument. - -**Time is server-sourced.** Every handler stamps `createdAt`/`readAt` from -`Date.now()` itself; no method accepts a caller-supplied clock. - -**Validation.** When `payloadValidator` is set it runs at the client boundary: -over the value written by `deliver` (before storage) and over the value returned by -`get` / `list` (on read). It must return the typed value or throw. Omit it to leave -the opaque data unvalidated. - -## Mutations - -### `deliver(ctx, subjects, type, payload?) → { notificationIds }` - -`subjects` is a single opaque `subjectRef` or an array of them. Deliver one -notification to each recipient — the fan-out. Each row is minted a component -`notificationId`, inserted unread, with `createdAt` stamped from the server clock. -`type` tags the notification; `payload` is opaque host data validated against -`payloadValidator` before storage. Returns one `notificationId` per recipient, in -order. - -An empty recipient set throws `ConvexError({ code: "EMPTY_FANOUT" })`; a set larger -than the client's `maxFanOut` throws `ConvexError({ code: "FANOUT_TOO_LARGE" })`, so -one mutation can never silently amplify past the transaction write budget. - -### `markRead(ctx, notificationId) → null` - -Mark one notification read, stamping `readAt` from the server clock. Idempotent — -re-marking a read notification is a no-op. A missing id throws -`ConvexError({ code: "NOT_FOUND" })`. - -### `markAllRead(ctx, subjectRef, opts?) → number` - -`opts`: `{ batch?: number }` (default `batch = 200`). - -Mark every unread notification for `subjectRef` read, oldest first, sharing one -server-clock `readAt`. Returns the count marked in the first pass. If a full batch -was marked the sweep self-reschedules through the component scheduler until the -unread tail is clean. Idempotent — an already-empty unread inbox marks nothing. - -### `purge(ctx, opts?) → number` - -`opts`: `{ before?: number; batch?: number }` (defaults: `before = Date.now()`, -`batch = 200`). - -Delete up to `batch` **read** notifications whose `createdAt < before`, oldest -first via the `by_read_created` index, and return the count removed in the first -pass. Unread notifications are never purged. If a full batch was removed the sweep -self-reschedules until the read tail is clean. Idempotent — safe to run anytime. A -built-in daily cron drives it automatically; call `purge` directly only for an -extra or custom-cadence sweep. - -## Queries - -### `get(ctx, notificationId) → NotificationView | null` - -The current notification for `notificationId`, or `null` if none is held. -`NotificationView` is `{ notificationId, subjectRef, type, payload?, readAt?, -createdAt }`; `payload` is narrowed by the host validator when set; `readAt` is -absent while unread. - -### `list(ctx, subjectRef, paginationOpts, opts?) → PaginationResult` - -`opts`: `{ unreadOnly?: boolean }` (default `false`). - -Page a subject's inbox, newest first. With `unreadOnly`, only unread notifications -are returned (via the `by_subject_read_created` index, `read == false`); otherwise -the whole inbox via `by_subject_created`. Takes the standard Convex -`paginationOpts` and returns the standard paginated envelope (`page`, `isDone`, -`continueCursor`) with each row narrowed to the typed view. **Subject-bounded** — -never returns another subject's rows. - -### `unreadCount(ctx, subjectRef) → number` - -The number of unread notifications for `subjectRef`, via the -`by_subject_read_created` index. Subject-bounded. The count walks the unread slice; -a host expecting very large unread inboxes pairs this with `@convex-dev/aggregate`. - -## Error codes - -Coded `ConvexError`s thrown by the component (`error.data.code`): - -| Code | Thrown by | Meaning | -|------|-----------|---------| -| `EMPTY_FANOUT` | `deliver` | The recipient set was empty. | -| `FANOUT_TOO_LARGE` | `deliver` | The recipient set exceeded the client's `maxFanOut`. | -| `NOT_FOUND` | `markRead` | No notification has this `notificationId`. | - -## Cron / Maintenance - -The component registers one cron (`crons.ts`): - -| Job | Cadence | Action | -|-----|---------|--------| -| `notifications:purge` | every 24h (`PURGE_INTERVAL`) | runs `purge` with `batch = PURGE_BATCH` (200), self-rescheduling until the read tail is clean | - -Cadence is a static module constant (Convex cron definitions are static per -deployment). A host wanting a different cadence drives `purge` from its own -scheduler with an explicit `before` cutoff. The cron is per-mount, so each -`app.use(component, { name })` instance purges its own sandbox independently. - - ---- - -## src/shared.ts - -```ts -/** Shared constants used by both `client/` and `component/`. */ - -export const COMPONENT_NAME = "notifications"; - -/** - * Default retention (ms) for read notifications before the purge cron sweeps - * them: 30 days. Bounds unbounded growth of a subject's inbox while leaving a - * generous window to surface recently-read items. Unread notifications are - * never removed by the retention sweep. A host that wants a different window - * drives `purge` from its own scheduler with an explicit `before` cutoff. - */ -export const DEFAULT_RETENTION_MS = 2_592_000_000; - -/** Default page size for a `purge` pass before the sweep self-reschedules. */ -export const DEFAULT_PURGE_BATCH = 200; - -/** - * Default hard cap on how many `subjectRef`s a single `deliver` fan-out may - * target. Bounds the write amplification of one mutation (Convex caps writes - * per transaction); a host fanning out wider batches its own calls. Override - * per-client with `maxFanOut`. - */ -export const DEFAULT_MAX_FANOUT = 256; - -``` - ---- - -## src/client/types.ts - -```ts -/** Public TypeScript surface for the notifications client. */ - -/** - * Validates and narrows an opaque stored value to a host type `T` at the client - * boundary. Receives the raw value the component returned (`unknown`) and MUST - * return a typed `T` or throw. A `convex/values` validator's `.parse` (or a Zod - * `.parse`) fits directly; omit it to keep the value unvalidated. - * - * @typeParam T - The host's stored type (a notification `payload`). - */ -export type Parser = (value: unknown) => T; - -/** The public projection returned by {@link Notifications.get} / `list`. */ -export interface NotificationView { - /** The component-minted id naming this notification. */ - notificationId: string; - /** The host-opaque recipient this notification is addressed at. */ - subjectRef: string; - /** The host-supplied type tag (e.g. "mention", "invite_accepted"). */ - type: string; - /** The opaque host payload (narrowed if a `payloadValidator` is set). */ - payload?: TPayload; - /** Absolute ms timestamp the notification was marked read; absent while unread. */ - readAt?: number; - /** Absolute ms timestamp the notification was delivered. */ - createdAt: number; -} - -/** Per-call options for {@link Notifications.list}. */ -export interface ListOptions { - /** Return only unread notifications (default `false` — the whole inbox). */ - unreadOnly?: boolean; -} - -/** Construction options for the {@link Notifications} client. */ -export interface NotificationsOptions { - /** - * Validates/narrows a stored `payload` to `TPayload` at the boundary — applied - * to the `payload` passed into `deliver` (before storage) and the `payload` - * returned by `get` / `list`. Throws on a mismatch. Omit to leave payloads - * unvalidated. - */ - payloadValidator?: Parser; - /** - * Hard cap on a single `deliver` fan-out's recipient count. Defaults to - * `DEFAULT_MAX_FANOUT` (256); bounds the write amplification of one mutation. - */ - maxFanOut?: number; -} - -``` - ---- - -## src/client/index.ts - -```ts -import type { - FunctionArgs, - FunctionReference, - FunctionReturnType, - PaginationOptions, - PaginationResult, -} from "convex/server"; -import type { - ListOptions, - NotificationsOptions, - NotificationView, - Parser, -} from "./types.js"; -import { DEFAULT_MAX_FANOUT, DEFAULT_PURGE_BATCH } from "../shared.js"; - -/** - * The component's raw notification view, before the client narrows opaque host - * data. `payload` is `unknown` here; the {@link Notifications} client runs the - * host validator over it at its typed boundary. - */ -type RawView = { - notificationId: string; - subjectRef: string; - type: string; - payload?: unknown; - readAt?: number; - createdAt: number; -}; - -/** - * The notifications component's function references, as exposed on the host via - * `components.notifications`. The host's stored `payload` is opaque here - * (`unknown`); the {@link Notifications} client narrows it at its own typed - * boundary. - */ -export interface NotificationsComponent { - mutations: { - deliver: FunctionReference< - "mutation", - "internal", - { - subjectRefs: string[]; - type: string; - payload?: unknown; - maxFanOut: number; - }, - { notificationIds: string[] } - >; - markRead: FunctionReference< - "mutation", - "internal", - { notificationId: string }, - null - >; - markAllRead: FunctionReference< - "mutation", - "internal", - { subjectRef: string; batch: number }, - number - >; - purge: FunctionReference< - "mutation", - "internal", - { before?: number; batch: number }, - number - >; - }; - queries: { - get: FunctionReference< - "query", - "internal", - { notificationId: string }, - RawView | null - >; - list: FunctionReference< - "query", - "internal", - { - subjectRef: string; - unreadOnly: boolean; - paginationOpts: PaginationOptions; - }, - PaginationResult - >; - unreadCount: FunctionReference< - "query", - "internal", - { subjectRef: string }, - number - >; - }; -} - -interface RunQueryCtx { - runQuery>( - reference: Q, - args: FunctionArgs, - ): Promise>; -} - -interface RunMutationCtx { - runMutation>( - reference: M, - args: FunctionArgs, - ): Promise>; -} - -/** - * Consumer-facing client for a per-subject directed inbox. A host mutation - * delivers a notification to one or many opaque `subjectRef`s (fan-out); the - * recipient's UI lists their inbox (all or unread-only, paginated, reactively in - * Convex), reads the unread count, and marks notifications read. The host owns - * meaning and auth — it resolves identity, decides who may deliver or read, and - * passes opaque `subjectRef`s, a `type` tag, and arbitrary `payload` data the - * component stores without inspecting. Pass `payloadValidator` to narrow that - * opaque data to `TPayload` at the boundary — there is no unchecked cast. - * - * @typeParam TPayload - The host's notification payload type (defaults to `unknown`). - * - * @example - * ```ts - * const inbox = new Notifications(components.notifications, { - * payloadValidator: v.object({ actor: v.string() }).parse, - * }); - * const { notificationIds } = await inbox.deliver(ctx, ["u1", "u2"], "mention", { actor: "u9" }); - * const count = await inbox.unreadCount(ctx, "u1"); // 1 - * const page = await inbox.list(ctx, "u1", { cursor: null, numItems: 20 }, { unreadOnly: true }); - * await inbox.markRead(ctx, notificationIds[0]); - * ``` - */ -export class Notifications { - private readonly payloadValidator: Parser | undefined; - private readonly maxFanOut: number; - - constructor( - private readonly component: NotificationsComponent, - options: NotificationsOptions = {}, - ) { - this.payloadValidator = options.payloadValidator; - this.maxFanOut = options.maxFanOut ?? DEFAULT_MAX_FANOUT; - } - - /** Narrow an opaque value through a host parser; pass `undefined` and unset parsers through. */ - private parse(value: unknown): TPayload | undefined { - if (value === undefined) { - return undefined; - } - if (this.payloadValidator === undefined) { - return value as TPayload; - } - return this.payloadValidator(value); - } - - /** Project a raw component view into the typed, validated client view. */ - private view(raw: RawView): NotificationView { - return { - notificationId: raw.notificationId, - subjectRef: raw.subjectRef, - type: raw.type, - payload: this.parse(raw.payload), - readAt: raw.readAt, - createdAt: raw.createdAt, - }; - } - - /** - * Deliver one notification to each recipient — the fan-out. `subjects` is a - * single opaque `subjectRef` or an array of them; `type` tags the - * notification; `payload` is opaque host data validated against - * `payloadValidator` before storage. Returns one minted `notificationId` per - * recipient, in order. Rejects an empty or over-`maxFanOut` recipient set. - */ - async deliver( - ctx: RunMutationCtx, - subjects: string | string[], - type: string, - payload?: TPayload, - ): Promise<{ notificationIds: string[] }> { - const subjectRefs = Array.isArray(subjects) ? subjects : [subjects]; - return ctx.runMutation(this.component.mutations.deliver, { - subjectRefs, - type, - payload: payload === undefined ? undefined : this.parse(payload), - maxFanOut: this.maxFanOut, - }); - } - - /** Mark one notification read (idempotent). Rejects a missing id (`NOT_FOUND`). */ - markRead(ctx: RunMutationCtx, notificationId: string): Promise { - return ctx.runMutation(this.component.mutations.markRead, { - notificationId, - }); - } - - /** - * Mark every unread notification for `subjectRef` read, in bounded batches - * (the component self-reschedules until the tail is clean). Returns the count - * marked in the first pass. - */ - markAllRead( - ctx: RunMutationCtx, - subjectRef: string, - opts: { batch?: number } = {}, - ): Promise { - return ctx.runMutation(this.component.mutations.markAllRead, { - subjectRef, - batch: opts.batch ?? DEFAULT_PURGE_BATCH, - }); - } - - /** The current notification for `notificationId`, or `null` if none is held. */ - async get( - ctx: RunQueryCtx, - notificationId: string, - ): Promise | null> { - const raw = await ctx.runQuery(this.component.queries.get, { - notificationId, - }); - return raw === null ? null : this.view(raw); - } - - /** - * Page a subject's inbox, newest first. Pass `{ unreadOnly: true }` to page - * only unread notifications. Returns the standard Convex pagination envelope - * with each row narrowed to the typed view. - */ - async list( - ctx: RunQueryCtx, - subjectRef: string, - paginationOpts: PaginationOptions, - opts: ListOptions = {}, - ): Promise>> { - const result = await ctx.runQuery(this.component.queries.list, { - subjectRef, - unreadOnly: opts.unreadOnly ?? false, - paginationOpts, - }); - return { ...result, page: result.page.map((raw) => this.view(raw)) }; - } - - /** The number of unread notifications for `subjectRef`. */ - unreadCount(ctx: RunQueryCtx, subjectRef: string): Promise { - return ctx.runQuery(this.component.queries.unreadCount, { subjectRef }); - } - - /** - * Delete read notifications whose `createdAt < before` in bounded batches, - * oldest first. `before` defaults to the server clock; `batch` caps each pass - * and the sweep self-reschedules until the read tail is clean. Returns the - * count removed in the first pass. Unread notifications are never purged. The - * built-in daily cron drives this automatically. - */ - purge( - ctx: RunMutationCtx, - opts: { before?: number; batch?: number } = {}, - ): Promise { - return ctx.runMutation(this.component.mutations.purge, { - before: opts.before, - batch: opts.batch ?? DEFAULT_PURGE_BATCH, - }); - } -} - -export type { - ListOptions, - NotificationsOptions, - NotificationView, - Parser, -}; - -``` - ---- - -## src/component/schema.ts - -```ts -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; -import { jsonValue } from "./validators"; - -/** - * Sandboxed table — one subject's directed inbox. A notification is addressed at - * a host-opaque `subjectRef` (the recipient), tagged by `type`, and carries - * opaque host `payload` (never inspected). `read` is a derived boolean stamped - * at insert (`false`) and flipped on `markRead`; it backs the unread index and - * count without a post-query `.filter()`. `readAt` records when it was read. - * - * Indexes: - * - `by_notification_id` — direct lookup / markRead by the minted `notificationId`. - * - `by_subject_created` — list a subject's whole inbox, newest first. - * - `by_subject_read_created` — list unread (or read) for a subject, newest - * first, and count unread — the unread inbox is `read == false` on this index. - * - `by_read_created` — global retention sweep: read rows past a `createdAt` - * cutoff, oldest first, across all subjects (the purge cron is not - * subject-bounded). - */ -export default defineSchema({ - notifications: defineTable({ - notificationId: v.string(), - subjectRef: v.string(), - type: v.string(), - payload: v.optional(jsonValue), - read: v.boolean(), - readAt: v.optional(v.number()), - createdAt: v.number(), - }) - .index("by_notification_id", ["notificationId"]) - .index("by_subject_created", ["subjectRef", "createdAt"]) - .index("by_subject_read_created", ["subjectRef", "read", "createdAt"]) - .index("by_read_created", ["read", "createdAt"]), -}); - -``` - ---- - -## src/component/validators.ts - -```ts -import { v } from "convex/values"; - -/** - * Opaque host-owned data carried on a notification — its `payload`. The - * component never inspects it; it is last-resort arbitrary data, aliased here - * rather than left bare in function signatures. The host narrows it at the - * {@link Notifications} client boundary via an optional `payloadValidator` - * parser. - * - * This is the single documented `v.any()` escape hatch in the component; the - * lint rule `convex-rules/no-bare-v-any` is satisfied by routing every arbitrary - * host payload through this alias instead of a bare `v.any()`. - */ -export const jsonValue = v.any(); - -/** - * Public projection of a notification returned by {@link get} / {@link list}. - * `payload` is opaque host data; `readAt` is the absolute ms timestamp the - * notification was marked read, absent while unread. - */ -export const notificationView = v.object({ - notificationId: v.string(), - subjectRef: v.string(), - type: v.string(), - payload: v.optional(jsonValue), - readAt: v.optional(v.number()), - createdAt: v.number(), -}); - -``` - ---- - -## src/component/mutations.ts - -```ts -import { ConvexError, v } from "convex/values"; -import { api } from "./_generated/api"; -import { mutation } from "./_generated/server"; -import { jsonValue } from "./validators"; - -/** - * Deliver one notification to each of `subjectRefs` — the directed-inbox - * fan-out. Every recipient gets its own freshly-minted `notificationId` - * (component-generated via `crypto.randomUUID()`; the host names the recipient, - * not the row) inserted unread (`read: false`) with `createdAt` stamped from the - * server clock (`Date.now()` inside the handler — never caller-supplied). The - * host owns the meaning of `type` and the opaque `payload`. - * - * `subjectRefs` must be non-empty and within `maxFanOut`; an empty list throws - * `ConvexError({ code: "EMPTY_FANOUT" })` and an over-cap list throws - * `ConvexError({ code: "FANOUT_TOO_LARGE" })` so a runaway fan-out can never - * silently amplify one mutation past the transaction write budget. - * - * @returns the minted `notificationId`s, one per recipient, in `subjectRefs` - * order. - */ -export const deliver = mutation({ - args: { - subjectRefs: v.array(v.string()), - type: v.string(), - payload: v.optional(jsonValue), - maxFanOut: v.number(), - }, - returns: v.object({ notificationIds: v.array(v.string()) }), - handler: async (ctx, args) => { - if (args.subjectRefs.length === 0) { - throw new ConvexError({ - code: "EMPTY_FANOUT", - message: "deliver requires at least one subjectRef", - }); - } - if (args.subjectRefs.length > args.maxFanOut) { - throw new ConvexError({ - code: "FANOUT_TOO_LARGE", - message: `deliver fan-out of ${args.subjectRefs.length} exceeds maxFanOut ${args.maxFanOut}`, - }); - } - - const now = Date.now(); - const notificationIds: string[] = []; - for (const subjectRef of args.subjectRefs) { - const notificationId = crypto.randomUUID(); - await ctx.db.insert("notifications", { - notificationId, - subjectRef, - type: args.type, - payload: args.payload, - read: false, - createdAt: now, - }); - notificationIds.push(notificationId); - } - return { notificationIds }; - }, -}); - -/** - * Mark one notification read, stamping `readAt` from the server clock. Marking - * an already-read notification is a no-op (idempotent) so a double-tap or a - * replayed client call never churns the timestamp. - * - * @throws `ConvexError({ code: "NOT_FOUND" })` when no notification has - * `notificationId`. - */ -export const markRead = mutation({ - args: { notificationId: v.string() }, - returns: v.null(), - handler: async (ctx, args) => { - const row = await ctx.db - .query("notifications") - .withIndex("by_notification_id", (q) => q.eq("notificationId", args.notificationId)) - .unique(); - if (row === null) { - throw new ConvexError({ - code: "NOT_FOUND", - message: `notification "${args.notificationId}" not found`, - }); - } - if (row.read) { - return null; - } - await ctx.db.patch(row._id, { read: true, readAt: Date.now() }); - return null; - }, -}); - -/** - * Mark every unread notification for `subjectRef` read in bounded batches, - * oldest first via the `by_subject_read_created` index (the unread slice is - * `read == false`). Stamps each `readAt` from one server-clock read so a single - * mark-all pass shares a timestamp. If a full batch was marked there may be more, - * so the pass self-reschedules through `ctx.scheduler` until the unread tail is - * clean. Idempotent — an already-empty unread inbox marks nothing. - * - * @returns the count marked in this pass. - */ -export const markAllRead = mutation({ - args: { subjectRef: v.string(), batch: v.number() }, - returns: v.number(), - handler: async (ctx, args) => { - const now = Date.now(); - const unread = await ctx.db - .query("notifications") - .withIndex("by_subject_read_created", (q) => - q.eq("subjectRef", args.subjectRef).eq("read", false), - ) - .take(args.batch); - - for (const row of unread) { - await ctx.db.patch(row._id, { read: true, readAt: now }); - } - - if (unread.length === args.batch) { - await ctx.scheduler.runAfter(0, api.mutations.markAllRead, { - subjectRef: args.subjectRef, - batch: args.batch, - }); - } - return unread.length; - }, -}); - -/** - * Delete up to `batch` **read** notifications whose `createdAt < before`, oldest - * first across all subjects via the `by_read_created` index. `before` defaults - * to the server clock when omitted. Unread notifications are never purged. If a - * full batch was removed there may be more, so the sweep self-reschedules through - * `ctx.scheduler` until the read tail is clean. Idempotent — only ever removes - * already-read, past-retention rows. The built-in daily cron drives this - * automatically. - */ -export const purge = mutation({ - args: { before: v.optional(v.number()), batch: v.number() }, - returns: v.number(), - handler: async (ctx, args) => { - const before = args.before ?? Date.now(); - - const stale = await ctx.db - .query("notifications") - .withIndex("by_read_created", (q) => - q.eq("read", true).lt("createdAt", before), - ) - .take(args.batch); - - for (const row of stale) { - await ctx.db.delete(row._id); - } - const removed = stale.length; - - if (removed === args.batch) { - await ctx.scheduler.runAfter(0, api.mutations.purge, { - before, - batch: args.batch, - }); - } - return removed; - }, -}); - -``` - ---- - -## src/component/queries.ts - -```ts -import { v } from "convex/values"; -import { paginationOptsValidator } from "convex/server"; -import { query } from "./_generated/server"; -import { notificationView } from "./validators"; -import type { Doc } from "./_generated/dataModel"; - -/** Project a stored notification row to its public view (drops internal fields). */ -function view(row: Doc<"notifications">) { - return { - notificationId: row.notificationId, - subjectRef: row.subjectRef, - type: row.type, - payload: row.payload, - readAt: row.readAt, - createdAt: row.createdAt, - }; -} - -/** The current notification for `notificationId`, or `null` if none is held. */ -export const get = query({ - args: { notificationId: v.string() }, - returns: v.union(v.null(), notificationView), - handler: async (ctx, args) => { - const row = await ctx.db - .query("notifications") - .withIndex("by_notification_id", (q) => q.eq("notificationId", args.notificationId)) - .unique(); - return row === null ? null : view(row); - }, -}); - -/** - * Page a subject's inbox, newest first. With `unreadOnly` set, only unread - * notifications are returned via the `by_subject_read_created` index - * (`read == false`); otherwise the whole inbox is paged via `by_subject_created`. - * Takes the standard Convex `paginationOpts` and returns the standard paginated - * envelope (`page`, `isDone`, `continueCursor`) so the host renders an inbox - * reactively. A read for one subject never spans another subject's rows — the - * index is subject-bounded. - */ -export const list = query({ - args: { - subjectRef: v.string(), - unreadOnly: v.boolean(), - paginationOpts: paginationOptsValidator, - }, - returns: v.object({ - page: v.array(notificationView), - 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 = args.unreadOnly - ? await ctx.db - .query("notifications") - .withIndex("by_subject_read_created", (q) => - q.eq("subjectRef", args.subjectRef).eq("read", false), - ) - .order("desc") - .paginate(args.paginationOpts) - : await ctx.db - .query("notifications") - .withIndex("by_subject_created", (q) => - q.eq("subjectRef", args.subjectRef), - ) - .order("desc") - .paginate(args.paginationOpts); - return { ...result, page: result.page.map(view) }; - }, -}); - -/** - * Count a subject's unread notifications via the `by_subject_read_created` index - * (`read == false`). Subject-bounded — never counts another subject's rows. The - * count walks the unread slice, so a host that expects very large unread inboxes - * pairs this with `@convex-dev/aggregate`; for ordinary inboxes the index scan is - * cheap. - */ -export const unreadCount = query({ - args: { subjectRef: v.string() }, - returns: v.number(), - handler: async (ctx, args) => { - const unread = await ctx.db - .query("notifications") - .withIndex("by_subject_read_created", (q) => - q.eq("subjectRef", args.subjectRef).eq("read", false), - ) - .collect(); - return unread.length; - }, -}); - -``` - ---- - -## src/component/crons.ts - -```ts -import { cronJobs } from "convex/server"; -import { api } from "./_generated/api"; - -/** - * Default sweep cadence and page size for the built-in purge cron. The cron is - * the component's own self-healing safety net — a host that wants a different - * cadence drives `purge` from its own scheduler instead (the client exposes it). - * Convex cron definitions are static per deployment, so cadence is a documented - * module constant rather than a mount-time option; the page size bounds each - * sweep and `purge` self-reschedules until the read tail is clean. - */ -export const PURGE_INTERVAL = { hours: 24 } as const; - -/** Rows deleted per `purge` pass before the sweep self-reschedules. */ -export const PURGE_BATCH = 200; - -const crons = cronJobs(); - -crons.interval("notifications:purge", PURGE_INTERVAL, api.mutations.purge, { - batch: PURGE_BATCH, -}); - -export default crons; - -``` - ---- - -## src/test.ts - -```ts -import type { TestConvex } from "convex-test"; -import schema from "./component/schema"; - -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-notifications/test"`. - */ -export function register( - t: TestConvex, - name = "notifications", -): void { - t.registerComponent(name, schema, modules); -} - -``` diff --git a/llms.txt b/llms.txt index ac26fd9..73834dd 100644 --- a/llms.txt +++ b/llms.txt @@ -23,4 +23,3 @@ It stores one sandboxed table - `notifications` (a component-minted `notificatio - [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 e2ae0a2..a4a6f42 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 e0ef0ea..0000000 --- a/scripts/generate-llms.mjs +++ /dev/null @@ -1,32 +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/component/crons.ts", - "src/test.ts", -]; - -let out = `# @vllnt/convex-notifications — 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)`);