diff --git a/.cursor/rules/convex_rules.mdc b/.cursor/rules/convex_rules.mdc index a598d39..1d98480 100644 --- a/.cursor/rules/convex_rules.mdc +++ b/.cursor/rules/convex_rules.mdc @@ -1,90 +1,103 @@ --- description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples -globs: **/*.{ts,tsx,js,jsx} +globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx --- # Convex guidelines ## Function guidelines ### New function syntax - ALWAYS use the new function syntax for Convex functions. For example: - ```typescript - import { query } from "./_generated/server"; - import { v } from "convex/values"; - export const f = query({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { - // Function body - }, - }); - ``` +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; +export const f = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + // Function body + }, +}); +``` ### Http endpoint syntax - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: - ```typescript - import { httpRouter } from "convex/server"; - import { httpAction } from "./_generated/server"; - const http = httpRouter(); - http.route({ - path: "/echo", - method: "POST", - handler: httpAction(async (ctx, req) => { - const body = await req.bytes(); - return new Response(body, { status: 200 }); - }), - }); - ``` +```typescript +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; +const http = httpRouter(); +http.route({ + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { + const body = await req.bytes(); + return new Response(body, { status: 200 }); + }), +}); +``` - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. ### Validators - Below is an example of an array validator: - ```typescript - import { mutation } from "./_generated/server"; - import { v } from "convex/values"; - - export default mutation({ - args: { - simpleArray: v.array(v.union(v.string(), v.number())), - }, - handler: async (ctx, args) => { - //... - }, - }); - ``` +```typescript +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export default mutation({ +args: { + simpleArray: v.array(v.union(v.string(), v.number())), +}, +handler: async (ctx, args) => { + //... +}, +}); +``` - Below is an example of a schema with validators that codify a discriminated union type: - ```typescript - import { defineSchema, defineTable } from "convex/server"; - import { v } from "convex/values"; - - export default defineSchema({ - results: defineTable( - v.union( - v.object({ - kind: v.literal("error"), - errorMessage: v.string(), - }), - v.object({ - kind: v.literal("success"), - value: v.number(), - }), - ), - ) - }); - ``` +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ) +}); +``` - Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value: - ```typescript - import { query } from "./_generated/server"; - import { v } from "convex/values"; - - export const exampleQuery = query({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { - console.log("This query returns a null value"); - return null; - }, - }); - ``` +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const exampleQuery = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This query returns a null value"); + return null; + }, +}); +``` +- Here are the valid Convex types along with their respective validators: +Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | +| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Id | string | `doc._id` | `v.id(tableName)` | | +| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | +| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | +| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | +| Boolean | boolean | `true` | `v.boolean()` | +| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | +| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | +| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | +| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | ### Function registration - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. @@ -101,24 +114,24 @@ globs: **/*.{ts,tsx,js,jsx} - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, - ``` - export const f = query({ - args: { name: v.string() }, - returns: v.string(), - handler: async (ctx, args) => { - return "Hello " + args.name; - }, - }); - - export const g = query({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { - const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); - return null; - }, - }); - ``` +``` +export const f = query({ + args: { name: v.string() }, + returns: v.string(), + handler: async (ctx, args) => { + return "Hello " + args.name; + }, +}); + +export const g = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); + return null; + }, +}); +``` ### Function references - Function references are pointers to registered Convex functions. @@ -137,21 +150,24 @@ globs: **/*.{ts,tsx,js,jsx} - Paginated queries are queries that return a list of results in incremental pages. - You can define pagination using the following syntax: - ```ts - import { v } from "convex/values"; - import { query, mutation } from "./_generated/server"; - import { paginationOptsValidator } from "convex/server"; - export const listWithExtraArg = query({ - args: { paginationOpts: paginationOptsValidator, author: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("author"), args.author)) - .order("desc") - .paginate(args.paginationOpts); - }, - }); - ``` +```ts +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; +export const listWithExtraArg = query({ + args: { paginationOpts: paginationOptsValidator, author: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .filter((q) => q.eq(q.field("author"), args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); +``` +Note: `paginationOpts` is an object with the following properties: +- `numItems`: the maximum number of documents to return (the validator is `v.number()`) +- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) - A query that ends in `.paginate()` returns an object that has the following properties: - page (contains an array of documents that you fetches) - isDone (a boolean that represents whether or not this is the last page of documents) @@ -165,33 +181,33 @@ globs: **/*.{ts,tsx,js,jsx} ## Schema guidelines - Always define your schema in `convex/schema.ts`. - Always import the schema definition functions from `convex/server`: -- System fields are automatically added to all documents and are prefixed with an underscore. +- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. ## Typescript guidelines - You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: - ```ts - import { query } from "./_generated/server"; - import { Doc, Id } from "./_generated/dataModel"; - - export const exampleQuery = query({ - args: { userIds: v.array(v.id("users")) }, - returns: v.record(v.id("users"), v.string()), - handler: async (ctx, args) => { - const idToUsername: Record, string> = {}; - for (const userId of args.userIds) { - const user = await ctx.db.get(userId); - if (user) { - users[user._id] = user.username; - } - } - - return idToUsername; - }, - }); - ``` +```ts +import { query } from "./_generated/server"; +import { Doc, Id } from "./_generated/dataModel"; + +export const exampleQuery = query({ + args: { userIds: v.array(v.id("users")) }, + returns: v.record(v.id("users"), v.string()), + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get(userId); + if (user) { + idToUsername[user._id] = user.username; + } + } + + return idToUsername; + }, +}); +``` - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. - Always use `as const` for string literals in discriminated union types. - When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];` @@ -227,46 +243,46 @@ const messages = await ctx.db - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. - Never use `ctx.db` inside of an action. Actions don't have access to the database. - Below is an example of the syntax for an action: - ```ts - import { action } from "./_generated/server"; - - export const exampleAction = action({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { - console.log("This action does not return anything"); - return null; - }, - }); - ``` +```ts +import { action } from "./_generated/server"; + +export const exampleAction = action({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, +}); +``` ## Scheduling guidelines ### Cron guidelines - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, - ```ts - import { cronJobs } from "convex/server"; - import { internal } from "./_generated/api"; - import { internalAction } from "./_generated/server"; - - const empty = internalAction({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { - console.log("empty"); - }, - }); - - const crons = cronJobs(); - - // Run `internal.crons.empty` every two hours. - crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); - - export default crons; - ``` +```ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; + +const empty = internalAction({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("empty"); + }, +}); + +const crons = cronJobs(); + +// Run `internal.crons.empty` every two hours. +crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); + +export default crons; +``` - You can register Convex functions within `crons.ts` just like any other file. -- If a cron calls an internal function, always import the `internal` object from '_generated/api`, even if the internal function is registered in the same file. +- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. ## File storage guidelines @@ -275,28 +291,28 @@ const messages = await ctx.db - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. - ``` - import { query } from "./_generated/server"; - import { Id } from "./_generated/dataModel"; - - type FileMetadata = { - _id: Id<"_storage">; - _creationTime: number; - contentType?: string; - sha256: string; - size: number; - } - - export const exampleQuery = query({ - args: { fileId: v.id("_storage") }, - returns: v.null(); - handler: async (ctx, args) => { - const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId); - console.log(metadata); - return null; - }, - }); - ``` +``` +import { query } from "./_generated/server"; +import { Id } from "./_generated/dataModel"; + +type FileMetadata = { + _id: Id<"_storage">; + _creationTime: number; + contentType?: string; + sha256: string; + size: number; +} + +export const exampleQuery = query({ + args: { fileId: v.id("_storage") }, + returns: v.null(), + handler: async (ctx, args) => { + const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId); + console.log(metadata); + return null; + }, +}); +``` - Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml deleted file mode 100644 index 236eac6..0000000 --- a/.github/workflows/node.js.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Run tests -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - cache-dependency-path: | - example/package.json - package.json - node-version: "18.x" - cache: "npm" - - run: npm i - - run: npm ci - - run: npm run build - - run: cd example && npm i && cd .. - - run: npx pkg-pr-new publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..117b322 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test and lint +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +on: + push: + branches: [main] + pull_request: + branches: ["**"] + +jobs: + check: + name: Test and lint + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Node setup + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + cache-dependency-path: package.json + node-version: "20.x" + cache: "npm" + + - name: Install and build + run: | + npm i + npm run build + - name: Publish package for testing branch + run: npx pkg-pr-new publish || echo "Have you set up pkg-pr-new for this repo?" + - name: Test + run: | + npm run test + npm run typecheck + npm run lint diff --git a/.gitignore b/.gitignore index 19c77ca..e101dac 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ node_modules react/package.json # npm pack output *.tgz +*.tsbuildinfo diff --git a/.prettierrc.json b/.prettierrc.json index 757fd64..f3877f7 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,3 +1,4 @@ { - "trailingComma": "es5" + "trailingComma": "all", + "proseWrap": "always" } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..70dae43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.2.0 + +- Adds /test and /\_generated/component.js entrypoints +- Drops commonjs support +- Improves source mapping for generated files +- Changes to a statically generated component API diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49ef6ee..b59c18f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,20 +4,17 @@ ```sh npm i -cd example -npm i -npx convex dev +npm run dev ``` ## Testing ```sh -rm -rf dist/ && npm run build +npm run clean +npm run build npm run typecheck -npm run test -cd example npm run lint -cd .. +npm run test ``` ## Deploying @@ -25,26 +22,19 @@ cd .. ### Building a one-off package ```sh -rm -rf dist/ && npm run build +npm run clean +npm ci npm pack ``` ### Deploying a new version ```sh -# this will change the version and commit it (if you run it in the root directory) -npm version patch -npm publish --dry-run -# sanity check files being included -npm publish -git push --tags +npm run release ``` -#### Alpha release - -The same as above, but it requires extra flags so the release is only installed with `@alpha`: +or for alpha release: ```sh -npm version prerelease --preid alpha -npm publish --tag alpha +npm run alpha ``` diff --git a/README.md b/README.md index f2746f7..00ff786 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,19 @@ [![npm version](https://badge.fury.io/js/@convex-dev%2Fresend.svg)](https://badge.fury.io/js/@convex-dev%2Fresend) -This component is the official way to integrate the Resend email service -with your Convex project. +This component is the official way to integrate the Resend email service with +your Convex project. Features: -- Queueing: Send as many emails as you want, as fast as you want—they'll all be delivered (eventually). -- Batching: Automatically batches large groups of emails and sends them to Resend efficiently. -- Durable execution: Uses Convex workpools to ensure emails are eventually delivered, even in the face of temporary failures or network outages. -- Idempotency: Manages Resend idempotency keys to guarantee emails are delivered exactly once, preventing accidental spamming from retries. +- Queueing: Send as many emails as you want, as fast as you want—they'll all be + delivered (eventually). +- Batching: Automatically batches large groups of emails and sends them to + Resend efficiently. +- Durable execution: Uses Convex workpools to ensure emails are eventually + delivered, even in the face of temporary failures or network outages. +- Idempotency: Manages Resend idempotency keys to guarantee emails are delivered + exactly once, preventing accidental spamming from retries. - Rate limiting: Honors API rate limits established by Resend. See [example](./example) for a demo of how to incorporate this hook into your @@ -33,7 +37,7 @@ Next, add the component to your Convex app via `convex/convex.config.ts`: ```ts import { defineApp } from "convex/server"; -import resend from "@convex-dev/resend/convex.config"; +import resend from "@convex-dev/resend/convex.config.js"; const app = defineApp(); app.use(resend); @@ -62,25 +66,28 @@ export const sendTestEmail = internalMutation({ }); ``` -Then, calling `sendTestEmail` from anywhere in your app will send this test email. +Then, calling `sendTestEmail` from anywhere in your app will send this test +email. If you want to send emails to real addresses, you need to disable `testMode`. -You can do this in `ResendOptions`, [as detailed below](#resend-component-options-and-going-into-production). +You can do this in `ResendOptions`, +[as detailed below](#resend-component-options-and-going-into-production). A note on test email addresses: -[Resend allows the use of labels](https://resend.com/docs/dashboard/emails/send-test-emails#using-labels-effectively) for test emails. -For simplicity, this component only allows labels matching `[a-zA-Z0-9_-]*`, e.g. `delivered+user-1@resend.dev`. +[Resend allows the use of labels](https://resend.com/docs/dashboard/emails/send-test-emails#using-labels-effectively) +for test emails. For simplicity, this component only allows labels matching +`[a-zA-Z0-9_-]*`, e.g. `delivered+user-1@resend.dev`. ## Advanced Usage ### Setting up a Resend webhook -While the setup we have so far will reliably send emails, you don't have any feedback -on anything delivering, bouncing, or triggering spam complaints. For that, we need -to set up a webhook! +While the setup we have so far will reliably send emails, you don't have any +feedback on anything delivering, bouncing, or triggering spam complaints. For +that, we need to set up a webhook! -On the Convex side, we need to mount an http endpoint to our project to route it to -the Resend component in `convex/http.ts`: +On the Convex side, we need to mount an http endpoint to our project to route it +to the Resend component in `convex/http.ts`: ```ts import { httpRouter } from "convex/server"; @@ -100,11 +107,11 @@ http.route({ export default http; ``` -If our Convex project is happy-leopard-123, we now have a Resend webhook for -our project running at `https://happy-leopard-123.convex.site/resend-webhook`. +If our Convex project is happy-leopard-123, we now have a Resend webhook for our +project running at `https://happy-leopard-123.convex.site/resend-webhook`. -So navigate to the Resend dashboard and create a new webhook at that URL. Make sure -to enable all the `email.*` events; the other event types will be ignored. +So navigate to the Resend dashboard and create a new webhook at that URL. Make +sure to enable all the `email.*` events; the other event types will be ignored. Finally, copy the webhook secret out of the Resend dashboard and set it to the `RESEND_WEBHOOK_SECRET` environment variable in your Convex deployment. @@ -116,8 +123,8 @@ Speaking of... ### Registering an email status event handler. -If you have your webhook established, you can also register an event handler in your -apps you get notifications when email statuses change. +If you have your webhook established, you can also register an event handler in +your apps you get notifications when email statuses change. Update your `sendEmails.ts` to look something like this: @@ -144,44 +151,47 @@ Check out the `example/` project in this repo for a full demo. ### Resend component options, and going into production -There is a `ResendOptions` argument to the component constructor to help customize -it's behavior. +There is a `ResendOptions` argument to the component constructor to help +customize it's behavior. Check out the [docstrings](./src/client/index.ts), but notable options include: -- `apiKey`: Provide the Resend API key instead of having it read from the environment - variable. +- `apiKey`: Provide the Resend API key instead of having it read from the + environment variable. - `webhookSecret`: Same thing, but for the webhook secret. -- `testMode`: Only allow delivery to test addresses. To keep you safe as you develop - your project, `testMode` is default **true**. You need to explicitly set this to - `false` for the component to allow you to enqueue emails to artibrary addresses. -- `onEmailEvent`: Your email event callback, as outlined above! - Check out the [docstrings](./src/client/index.ts) for details on the events that - are emitted. +- `testMode`: Only allow delivery to test addresses. To keep you safe as you + develop your project, `testMode` is default **true**. You need to explicitly + set this to `false` for the component to allow you to enqueue emails to + artibrary addresses. +- `onEmailEvent`: Your email event callback, as outlined above! Check out the + [docstrings](./src/client/index.ts) for details on the events that are + emitted. ### Optional email sending parameters -In addition to basic from/to/subject and html/plain text bodies, the `sendEmail` method -allows you to provide a list of `replyTo` addresses, and other email headers. +In addition to basic from/to/subject and html/plain text bodies, the `sendEmail` +method allows you to provide a list of `replyTo` addresses, and other email +headers. ### Tracking, getting status, and cancelling emails -The `sendEmail` method returns a branded type, `EmailId`. You can use this -for a few things: +The `sendEmail` method returns a branded type, `EmailId`. You can use this for a +few things: -- To reassociate the original email during status changes in your email event handler. +- To reassociate the original email during status changes in your email event + handler. - To check on the status any time using `resend.status(ctx, emailId)`. - To cancel the email using `resend.cancelEmail(ctx, emailId)`. -If the email has already been sent to the Resend API, it cannot be cancelled. Cancellations -do not trigger an email event. +If the email has already been sent to the Resend API, it cannot be cancelled. +Cancellations do not trigger an email event. ### Data retention -This component retains "finalized" (delivered, cancelled, bounced) emails. -It's your responsibility to clear out those emails on your own schedule. -You can run `cleanupOldEmails` and `cleanupAbandonedEmails` from the dashboard, -under the "resend" component tab in the function runner, or set up a cron job. +This component retains "finalized" (delivered, cancelled, bounced) emails. It's +your responsibility to clear out those emails on your own schedule. You can run +`cleanupOldEmails` and `cleanupAbandonedEmails` from the dashboard, under the +"resend" component tab in the function runner, or set up a cron job. If you pass no argument, it defaults to deleting emails older than 7 days. @@ -198,7 +208,7 @@ const crons = cronJobs(); crons.interval( "Remove old emails from the resend component", { hours: 1 }, - internal.crons.cleanupResend + internal.crons.cleanupResend, ); const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -212,7 +222,7 @@ export const cleanupResend = internalMutation({ 0, components.resend.lib.cleanupAbandonedEmails, // These generally indicate a bug, so keep them around for longer. - { olderThan: 4 * ONE_WEEK_MS } + { olderThan: 4 * ONE_WEEK_MS }, ); }, }); @@ -222,9 +232,11 @@ export default crons; ### Using React Email -You can use [React Email](https://react.email/) to generate your HTML for you from JSX. +You can use [React Email](https://react.email/) to generate your HTML for you +from JSX. -First install the [dependencies](https://react.email/docs/getting-started/manual-setup#2-install-dependencies): +First install the +[dependencies](https://react.email/docs/getting-started/manual-setup#2-install-dependencies): ```bash npm install @react-email/components react react-dom react-email @react-email/render @@ -261,8 +273,8 @@ export const sendEmail = action({ > Click me - - ) + , + ), ); // 2. Send your email as usual using the component @@ -276,8 +288,10 @@ export const sendEmail = action({ }); ``` -> [!WARNING] -> React Email requires some Node dependencies thus it must run in a Convex [Node action](https://docs.convex.dev/functions/actions#choosing-the-runtime-use-node) and not a regular Action. +> [!WARNING] React Email requires some Node dependencies thus it must run in a +> Convex +> [Node action](https://docs.convex.dev/functions/actions#choosing-the-runtime-use-node) +> and not a regular Action. ### Sending emails manually, e.g. for attachments @@ -329,7 +343,7 @@ export const sendManualEmail = internalMutation({ ], }); return data.id; - } + }, ); }, }); diff --git a/convex.json b/convex.json new file mode 100644 index 0000000..8cd4cfd --- /dev/null +++ b/convex.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/convex/schemas/convex.schema.json", + "functions": "example/convex", + "codegen": { + "legacyComponentApi": false + } +} diff --git a/eslint.config.js b/eslint.config.js index 35c2a7a..0963fd0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,45 +1,76 @@ import globals from "globals"; import pluginJs from "@eslint/js"; import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; export default [ - { files: ["src/**/*.{js,mjs,cjs,ts,tsx}"] }, { ignores: [ "dist/**", "eslint.config.js", - "example/eslint.config.js", + "vitest.config.ts", "**/_generated/", - "node10stubs.mjs", ], }, { + files: ["src/**/*.{js,mjs,cjs,ts,tsx}", "example/**/*.{js,mjs,cjs,ts,tsx}"], languageOptions: { - globals: globals.worker, parser: tseslint.parser, - parserOptions: { - project: ["./tsconfig.json", "./example/tsconfig.json"], - tsconfigRootDir: ".", + project: ["./tsconfig.json", "./example/convex/tsconfig.json"], + tsconfigRootDir: import.meta.dirname, }, }, }, pluginJs.configs.recommended, ...tseslint.configs.recommended, + // Convex code - Worker environment { + files: ["src/**/*.{ts,tsx}", "example/convex/**/*.{ts,tsx}"], + ignores: ["src/react/**"], + languageOptions: { + globals: globals.worker, + }, rules: { "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/consistent-type-imports": [ + "@typescript-eslint/no-explicit-any": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-unused-expressions": [ "error", { - prefer: "type-imports", - fixStyle: "separate-type-imports", - disallowTypeAnnotations: false, + allowShortCircuit: true, + allowTernary: true, + allowTaggedTemplates: true, }, ], - "eslint-comments/no-unused-disable": "off", - - // allow (_arg: number) => {} and const _foo = 1; + }, + }, + // React app code - Browser environment + { + files: ["src/react/**/*.{ts,tsx}", "example/src/**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-explicit-any": "off", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ "warn", @@ -50,4 +81,14 @@ export default [ ], }, }, + // Example config files (vite.config.ts, etc.) - Node environment + { + files: ["example/vite.config.ts", "example/**/*.config.{js,ts}"], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + }, + }, + }, ]; diff --git a/example/README.md b/example/README.md index b63948e..2008f80 100644 --- a/example/README.md +++ b/example/README.md @@ -1,7 +1,9 @@ # Example Resend component deployment -It's a simple one. The meat is in the `convex/` directory. If you want to fire it up, here's how: +It's a simple one. The meat is in the `convex/` directory. If you want to fire +it up, here's how: 1. Run `npm run dev` -2. Grab a Resend api key and webhook secret and set them in your deployment environment. +2. Grab a Resend api key and webhook secret and set them in your deployment + environment. 3. Go mess around in your Convex dashboard with `testBatch`. There is no UI! diff --git a/example/convex/README.md b/example/convex/README.md index b317aef..e2ede9d 100644 --- a/example/convex/README.md +++ b/example/convex/README.md @@ -1,7 +1,7 @@ # Welcome to your Convex functions directory! -Write your Convex functions here. -See https://docs.convex.dev/functions for more. +Write your Convex functions here. See https://docs.convex.dev/functions for +more. A query function that takes two arguments looks like: @@ -80,11 +80,11 @@ function handleButtonPress() { // OR // use the result once the mutation has completed mutation({ first: "Hello!", second: "me" }).then((result) => - console.log(result) + console.log(result), ); } ``` -Use the Convex CLI to push your functions to a deployment. See everything -the Convex CLI can do by running `npx convex -h` in your project root -directory. To learn more, launch the docs with `npx convex docs`. +Use the Convex CLI to push your functions to a deployment. See everything the +Convex CLI can do by running `npx convex -h` in your project root directory. To +learn more, launch the docs with `npx convex docs`. diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index ea2f76a..da717d1 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -18,158 +18,38 @@ import type { FunctionReference, } from "convex/server"; +declare const fullApi: ApiFromModules<{ + crons: typeof crons; + example: typeof example; + http: typeof http; +}>; + /** - * A utility for referencing Convex functions in your app's API. + * A utility for referencing Convex functions in your app's public API. * * Usage: * ```js * const myFunctionReference = api.myModule.myFunction; * ``` */ -declare const fullApi: ApiFromModules<{ - crons: typeof crons; - example: typeof example; - http: typeof http; -}>; -declare const fullApiWithMounts: typeof fullApi; - export declare const api: FilterApi< - typeof fullApiWithMounts, + typeof fullApi, FunctionReference >; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ export declare const internal: FilterApi< - typeof fullApiWithMounts, + typeof fullApi, FunctionReference >; export declare const components: { - resend: { - lib: { - cancelEmail: FunctionReference< - "mutation", - "internal", - { emailId: string }, - null - >; - cleanupAbandonedEmails: FunctionReference< - "mutation", - "internal", - { olderThan?: number }, - null - >; - cleanupOldEmails: FunctionReference< - "mutation", - "internal", - { olderThan?: number }, - null - >; - createManualEmail: FunctionReference< - "mutation", - "internal", - { - from: string; - headers?: Array<{ name: string; value: string }>; - replyTo?: Array; - subject: string; - to: string; - }, - string - >; - get: FunctionReference< - "query", - "internal", - { emailId: string }, - { - complained: boolean; - createdAt: number; - errorMessage?: string; - finalizedAt: number; - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - opened: boolean; - replyTo: Array; - resendId?: string; - segment: number; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - subject: string; - text?: string; - to: string; - } | null - >; - getStatus: FunctionReference< - "query", - "internal", - { emailId: string }, - { - complained: boolean; - errorMessage: string | null; - opened: boolean; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - } | null - >; - handleEmailEvent: FunctionReference< - "mutation", - "internal", - { event: any }, - null - >; - sendEmail: FunctionReference< - "mutation", - "internal", - { - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - options: { - apiKey: string; - initialBackoffMs: number; - onEmailEvent?: { fnHandle: string }; - retryAttempts: number; - testMode: boolean; - }; - replyTo?: Array; - subject: string; - text?: string; - to: string; - }, - string - >; - updateManualEmail: FunctionReference< - "mutation", - "internal", - { - emailId: string; - errorMessage?: string; - resendId?: string; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - }, - null - >; - }; - }; + resend: import("@convex-dev/resend/_generated/component.js").ComponentApi<"resend">; }; diff --git a/example/convex/_generated/server.d.ts b/example/convex/_generated/server.d.ts index b5c6828..bec05e6 100644 --- a/example/convex/_generated/server.d.ts +++ b/example/convex/_generated/server.d.ts @@ -10,7 +10,6 @@ import { ActionBuilder, - AnyComponents, HttpActionBuilder, MutationBuilder, QueryBuilder, @@ -19,15 +18,9 @@ import { GenericQueryCtx, GenericDatabaseReader, GenericDatabaseWriter, - FunctionReference, } from "convex/server"; import type { DataModel } from "./dataModel.js"; -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - /** * Define a query in this Convex app's public API. * @@ -92,11 +85,12 @@ export declare const internalAction: ActionBuilder; /** * Define an HTTP action. * - * This function will be used to respond to HTTP requests received by a Convex - * deployment if the requests matches the path and method where this action - * is routed. Be sure to route your action in `convex/http.js`. + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export declare const httpAction: HttpActionBuilder; diff --git a/example/convex/_generated/server.js b/example/convex/_generated/server.js index 4a21df4..bf3d25a 100644 --- a/example/convex/_generated/server.js +++ b/example/convex/_generated/server.js @@ -16,7 +16,6 @@ import { internalActionGeneric, internalMutationGeneric, internalQueryGeneric, - componentsGeneric, } from "convex/server"; /** @@ -81,10 +80,14 @@ export const action = actionGeneric; export const internalAction = internalActionGeneric; /** - * Define a Convex HTTP action. + * Define an HTTP action. * - * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object - * as its second. - * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export const httpAction = httpActionGeneric; diff --git a/example/convex/crons.ts b/example/convex/crons.ts index 8ddfd0b..a74b119 100644 --- a/example/convex/crons.ts +++ b/example/convex/crons.ts @@ -7,7 +7,7 @@ const crons = cronJobs(); crons.interval( "Remove old emails from the resend component", { hours: 1 }, - internal.crons.cleanupResend + internal.crons.cleanupResend, ); const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -20,7 +20,7 @@ export const cleanupResend = internalMutation({ await ctx.scheduler.runAfter( 0, components.resend.lib.cleanupAbandonedEmails, - { olderThan: ONE_WEEK_MS } + { olderThan: ONE_WEEK_MS }, ); }, }); diff --git a/example/convex/example.ts b/example/convex/example.ts index 0af738a..f3be3ce 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -49,7 +49,7 @@ export const sendOne = internalAction({ args: { to: v.optional(v.string()) }, handler: async (ctx, args) => { const email = await resend.sendEmail(ctx, { - from: "", + from: "onboarding@resend.dev", to: args.to ?? "delivered@resend.dev", subject: "Test Email", html: "This is a test email", @@ -74,7 +74,7 @@ export const insertExpectation = internalMutation({ expectation: v.union( v.literal("delivered"), v.literal("bounced"), - v.literal("complained") + v.literal("complained"), ), }, returns: v.null(), @@ -111,7 +111,7 @@ export const handleEmailEvent = internalMutation({ } if (testEmail.expectation === "complained") { console.log( - "Complained email was delivered, expecting complaint coming..." + "Complained email was delivered, expecting complaint coming...", ); return; } @@ -121,7 +121,7 @@ export const handleEmailEvent = internalMutation({ if (args.event.type === "email.bounced") { if (testEmail.expectation !== "bounced") { throw new Error( - `Email was bounced but expected to be ${testEmail.expectation}` + `Email was bounced but expected to be ${testEmail.expectation}`, ); } // All good. Bounced email was bounced. @@ -130,7 +130,7 @@ export const handleEmailEvent = internalMutation({ if (args.event.type === "email.complained") { if (testEmail.expectation !== "complained") { throw new Error( - `Email was complained but expected to be ${testEmail.expectation}` + `Email was complained but expected to be ${testEmail.expectation}`, ); } // All good. Complained email was complained. @@ -182,7 +182,7 @@ export const sendManualEmail = internalAction({ }); const json = await data.json(); return json.id; - } + }, ); return emailId; }, diff --git a/example/convex/schema.ts b/example/convex/schema.ts index cc6df2c..689ccfb 100644 --- a/example/convex/schema.ts +++ b/example/convex/schema.ts @@ -7,7 +7,7 @@ export default defineSchema({ expectation: v.union( v.literal("delivered"), v.literal("bounced"), - v.literal("complained") + v.literal("complained"), ), }).index("by_email", ["email"]), }); diff --git a/example/convex/tsconfig.json b/example/convex/tsconfig.json index 4c79336..118af96 100644 --- a/example/convex/tsconfig.json +++ b/example/convex/tsconfig.json @@ -1,30 +1,18 @@ { - /* This TypeScript project config describes the environment that - * Convex functions run in and is used to typecheck them. - * You can modify it, but some settings required to use Convex. - */ "compilerOptions": { - /* These settings are not required by Convex and can be modified. */ - "allowJs": true, - "strict": true, - "skipLibCheck": true, - - /* These compiler options are required by Convex */ "target": "ESNext", - "lib": ["ES2021", "dom", "ESNext.Array"], - "forceConsistentCasingInFileNames": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "skipLibCheck": false, "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true, - - /* This should only be used in this example. Real apps should not attempt - * to compile TypeScript because differences between tsconfig.json files can - * cause the code to be compiled differently. - */ - "customConditions": ["@convex-dev/component-source"] + "jsx": "react-jsx", + "noEmit": true }, - "include": ["./**/*"], - "exclude": ["./_generated"] + "include": ["."], + "exclude": ["_generated"] } diff --git a/example/eslint.config.js b/example/eslint.config.js deleted file mode 100644 index 0761d6c..0000000 --- a/example/eslint.config.js +++ /dev/null @@ -1,28 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import tseslint from "typescript-eslint"; - -export default tseslint.config( - { ignores: ["dist"] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ["**/*.{ts,tsx}"], - ignores: ["convex"], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - rules: { - // Allow explicit `any`s - "@typescript-eslint/no-explicit-any": "off", - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - }, - ], - }, - } -); diff --git a/example/index.html b/example/index.html deleted file mode 100644 index ec30207..0000000 --- a/example/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Sharded Counter Component Example - - -
- - - diff --git a/example/tsconfig.json b/example/tsconfig.json deleted file mode 100644 index de3004b..0000000 --- a/example/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "skipLibCheck": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "allowImportingTsExtensions": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "include": ["./src", "vite.config.ts", "./convex"] -} diff --git a/node10stubs.mjs b/node10stubs.mjs deleted file mode 100644 index d4eac9b..0000000 --- a/node10stubs.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; - -async function findPackageJson(directory) { - const packagePath = path.join(directory, "package.json"); - try { - await fs.access(packagePath); - return packagePath; - } catch (error) { - const parentDir = path.dirname(directory); - if (parentDir === directory) { - throw new Error("package.json not found"); - } - return findPackageJson(parentDir); - } -} - -async function processSubPackages(packageJsonPath, exports, cleanup = false) { - const baseDir = path.dirname(packageJsonPath); - - for (const [subDir, _] of Object.entries(exports)) { - // package.json is already right where Node10 resolution would expect it. - if (subDir.endsWith("package.json")) continue; - // No need for Node10 resolution for component.config.ts - if (subDir.endsWith("convex.config.js")) continue; - // . just works with Node10 resolution - if (subDir === ".") continue; - console.log(subDir); - - const newDir = path.join(baseDir, subDir); - const newPackageJsonPath = path.join(newDir, "package.json"); - - if (cleanup) { - try { - await fs.rm(newDir, { recursive: true, force: true }); - } catch (error) { - console.error(`Failed to remove ${newDir}:`, error.message); - } - } else { - const newPackageJson = { - main: `../dist/${subDir}/index.js`, - module: `../dist/${subDir}/index.js`, - types: `../dist/${subDir}/index.d.ts`, - }; - - await fs.mkdir(newDir, { recursive: true }); - await fs.writeFile( - newPackageJsonPath, - JSON.stringify(newPackageJson, null, 2) - ); - } - } -} - -async function main() { - try { - const isCleanup = process.argv.includes("--cleanup"); - const isAddFiles = process.argv.includes("--addFiles"); - const packageJsonPath = await findPackageJson(process.cwd()); - const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8")); - - if (!packageJson.exports) { - throw new Error("exports not found in package.json"); - } - - if (isAddFiles) { - return; - } - - await processSubPackages(packageJsonPath, packageJson.exports, isCleanup); - - if (isCleanup) { - console.log( - "Node10 module resolution compatibility stub directories removed." - ); - } else { - console.log( - "Node10 module resolution compatibility stub directories created" - ); - } - } catch (error) { - console.error("Error:", error.message); - } -} - -main(); diff --git a/package-lock.json b/package-lock.json index a5d63d2..7061bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,33 @@ { "name": "@convex-dev/resend", - "version": "0.1.13", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@convex-dev/resend", - "version": "0.1.13", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@convex-dev/rate-limiter": "^0.2.10", - "@convex-dev/workpool": "^0.2.17", + "@convex-dev/rate-limiter": "^0.3.0", + "@convex-dev/workpool": "^0.3.0", "remeda": "^2.26.0", "svix": "^1.70.0" }, "devDependencies": { - "@edge-runtime/vm": "^5.0.0", + "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.38.0", - "@types/node": "^22.18.13", - "chokidar-cli": "^3.0.0", + "@types/node": "20.19.24", + "chokidar-cli": "3.0.0", "convex": "1.29.0", "convex-test": "^0.0.33", "cpy-cli": "^5.0.0", "eslint": "^9.38.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", "globals": "^15.15.0", - "npm-run-all2": "^7.0.2", + "npm-run-all2": "7.0.2", "pkg-pr-new": "^0.0.54", "prettier": "3.2.5", "react": "^19.2.0", @@ -36,18 +38,286 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "convex": "^1.23.0", + "convex": "^1.24.8", "convex-helpers": "^0.1.99" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@convex-dev/rate-limiter": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@convex-dev/rate-limiter/-/rate-limiter-0.2.10.tgz", - "integrity": "sha512-EU6wJ5XEAE1s1GnYW85w/PpcFs2/q6sJY9ndKX6oVk6nhhRmxmwNzwITl5/Y3ufS6BtoQ12sQ+0tqYy2SQWlkg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@convex-dev/rate-limiter/-/rate-limiter-0.3.0.tgz", + "integrity": "sha512-8R1gos0KoGU9+1bahTpOuZgU04d4aAmk7SJw6D37HXLn7DLylAIKegaWmDgdcpaOGHDDDfnvuoEmC1AnoqHruA==", "license": "Apache-2.0", "peerDependencies": { - "convex": "~1.16.5 || >=1.17.0 <1.35.0", - "react": "^18.3.1 || ^19.0.0" + "convex": "^1.24.8", + "react": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { "react": { @@ -56,12 +326,12 @@ } }, "node_modules/@convex-dev/workpool": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.2.17.tgz", - "integrity": "sha512-Z2GjubsieZATdLYip+WTRLQNRmo+Jl9OoeN0GjsgIhLriHrNyddijosgGH656LtdmVs4SdqyqkwvMwabRixakA==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@convex-dev/workpool/-/workpool-0.3.0.tgz", + "integrity": "sha512-nY8Ub0pmfuxZ2rcnNwVeESYPyJqLU4h+afodEdg8Ifnr+vcFUuee/p69vMFmeqC2y4yo9IDPHrdiVZVyjbBE7A==", "license": "Apache-2.0", "peerDependencies": { - "convex": ">=1.25.0 <1.35.0", + "convex": "^1.24.8", "convex-helpers": "^0.1.94" } }, @@ -738,6 +1008,38 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", @@ -745,6 +1047,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jsdevtools/ez-spawn": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@jsdevtools/ez-spawn/-/ez-spawn-3.0.4.tgz", @@ -1390,10 +1703,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz", - "integrity": "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==", + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1917,6 +2232,16 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.23.tgz", + "integrity": "sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -1961,6 +2286,41 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1998,6 +2358,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001753", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", + "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", @@ -2184,6 +2565,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convex": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/convex/-/convex-1.29.0.tgz", @@ -2414,6 +2802,13 @@ "node": ">=8" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.244", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", + "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -2474,6 +2869,16 @@ "@esbuild/win32-x64": "0.25.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2548,6 +2953,36 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -2821,6 +3256,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2914,6 +3359,23 @@ "node": ">=8" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3060,6 +3522,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3091,6 +3566,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/junk": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", @@ -3172,6 +3660,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -3328,6 +3826,13 @@ } } }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4271,6 +4776,15 @@ "whatwg-fetch": "^3.4.1" } }, + "node_modules/svix/node_modules/@types/node": { + "version": "22.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", + "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4550,6 +5064,37 @@ "dev": true, "license": "ISC" }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4948,6 +5493,13 @@ "dev": true, "license": "ISC" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", @@ -5080,6 +5632,19 @@ "engines": { "node": ">=20" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 924d51d..4345a1b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "email": "support@convex.dev", "url": "https://github.com/get-convex/resend/issues" }, - "version": "0.1.13", + "version": "0.2.0", "license": "Apache-2.0", "keywords": [ "convex", @@ -15,30 +15,28 @@ ], "type": "module", "scripts": { - "example": "cd example && npm run dev", - "dev": "run-p -r 'example' 'build:watch'", - "dashboard": "cd example && npx convex dashboard", - "all": "run-p -r 'example' 'build:watch' 'test:watch'", - "setup": "npm i && npm run build && cd example && npm i && npx convex dev --once --live-component-sources --typecheck-components", - "build:watch": "npx chokidar 'tsconfig*.json' 'src/**/*.ts' -c 'npm run build' --initial", - "build": "tsc --project ./tsconfig.build.json && tsc-alias -p tsconfig.build.json", - "alpha": "npm run clean && npm run build && run-p test lint typecheck && npm version prerelease --preid alpha && npm publish --tag alpha && git push --tags", - "release": "npm run clean && npm run build && run-p test lint typecheck && npm version patch && npm publish && git push --tags", - "clean": "rm -rf dist tsconfig.build.tsbuildinfo", - "typecheck": "tsc --noEmit", - "prepare": "npm run build", - "prepack": "node node10stubs.mjs", - "postpack": "node node10stubs.mjs --cleanup", - "test": "vitest run --typecheck --config ./src/vitest.config.ts", - "test:watch": "vitest --typecheck --config ./src/vitest.config.ts", - "test:debug": "vitest --inspect-brk --no-file-parallelism --config ./src/vitest.config.ts", + "dev": "run-p -r 'dev:*'", + "dev:backend": "convex dev --typecheck-components", + "dev:frontend": "cd example && vite --clearScreen false", + "dev:build": "chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.ts' -c 'convex codegen --component-dir ./src/component && npm run build' --initial", + "predev": "npm run dev:backend -- --until-success", + "clean": "rm -rf dist *.tsbuildinfo", + "build": "tsc --project ./tsconfig.build.json", + "typecheck": "tsc --noEmit && tsc -p example/convex", + "lint": "eslint .", + "all": "run-p -r 'dev:*' 'test:watch'", + "test": "vitest run --typecheck", + "test:watch": "vitest --typecheck --clearScreen false", + "test:debug": "vitest --inspect-brk --no-file-parallelism", "test:coverage": "vitest run --coverage --coverage.reporter=text", - "lint": "eslint src" + "prepare": "npm run build", + "alpha": "npm run clean && npm ci && run-p test lint typecheck && npm version prerelease --preid alpha && npm publish --tag alpha && git push --tags", + "release": "npm run clean && npm ci && run-p test lint typecheck && npm version patch && npm publish && git push --tags", + "version": "pbcopy <<<$npm_package_version; vim CHANGELOG.md && prettier -w CHANGELOG.md && git add CHANGELOG.md" }, "files": [ "dist", - "src", - "react" + "src" ], "exports": { "./package.json": "./package.json", @@ -46,27 +44,37 @@ "types": "./dist/client/index.d.ts", "default": "./dist/client/index.js" }, + "./test": "./src/test.ts", + "./_generated/component.js": { + "types": "./dist/component/_generated/component.d.ts" + }, "./convex.config": { "types": "./dist/component/convex.config.d.ts", "default": "./dist/component/convex.config.js" + }, + "./convex.config.js": { + "types": "./dist/component/convex.config.d.ts", + "default": "./dist/component/convex.config.js" } }, "peerDependencies": { - "convex": "^1.23.0", + "convex": "^1.24.8", "convex-helpers": "^0.1.99" }, "devDependencies": { - "@edge-runtime/vm": "^5.0.0", + "@edge-runtime/vm": "5.0.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.38.0", - "@types/node": "^22.18.13", - "chokidar-cli": "^3.0.0", + "@types/node": "20.19.24", + "chokidar-cli": "3.0.0", "convex": "1.29.0", "convex-test": "^0.0.33", "cpy-cli": "^5.0.0", "eslint": "^9.38.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", "globals": "^15.15.0", - "npm-run-all2": "^7.0.2", + "npm-run-all2": "7.0.2", "pkg-pr-new": "^0.0.54", "prettier": "3.2.5", "react": "^19.2.0", @@ -76,12 +84,11 @@ "typescript-eslint": "^8.46.2", "vitest": "^3.2.4" }, - "main": "./dist/client/index.js", "types": "./dist/client/index.d.ts", "module": "./dist/client/index.js", "dependencies": { - "@convex-dev/rate-limiter": "^0.2.10", - "@convex-dev/workpool": "^0.2.17", + "@convex-dev/rate-limiter": "^0.3.0", + "@convex-dev/workpool": "^0.3.0", "remeda": "^2.26.0", "svix": "^1.70.0" } diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 12f99aa..eb6c0a7 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -8,11 +8,13 @@ const schema = defineSchema({}); function setupTest() { const t = convexTest(schema, modules); t.registerComponent("resend", componentSchema, componentModules); - return { t }; + return t; } -type ConvexTest = ReturnType["t"]; +type ConvexTest = ReturnType; describe("Resend", () => { - test("handleResendEventWebhook", async () => {}); + test("handleResendEventWebhook", async () => { + const _t: ConvexTest = setupTest(); + }); }); diff --git a/src/client/index.ts b/src/client/index.ts index 547c92e..646b99f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,15 +1,13 @@ import { createFunctionHandle, internalMutationGeneric, - type Expand, type FunctionReference, type FunctionVisibility, type GenericDataModel, type GenericMutationCtx, } from "convex/server"; -import { v, type GenericId, type VString } from "convex/values"; +import { v, type VString } from "convex/values"; import { Webhook } from "svix"; -import type { api } from "../component/_generated/api.js"; import { vEmailEvent, type EmailEvent, @@ -18,8 +16,9 @@ import { type RuntimeConfig, type Status, } from "../component/shared.js"; +import { ComponentApi } from "../component/_generated/component.js"; -export type ResendComponent = UseApi; +export type ResendComponent = ComponentApi; export type EmailId = string & { __isEmailId: true }; export const vEmailId = v.string() as VString; @@ -100,7 +99,7 @@ async function configToRuntimeConfig( id: EmailId; event: EmailEvent; } - > | null + > | null, ): Promise { return { apiKey: config.apiKey, @@ -171,8 +170,8 @@ export class Resend { * @param options The {@link ResendOptions} to use for this component. */ constructor( - public component: UseApi, - options?: ResendOptions + public component: ComponentApi, + options?: ResendOptions, ) { const defaultConfig = getDefaultConfig(); this.config = { @@ -204,7 +203,7 @@ export class Resend { */ async sendEmail( ctx: RunMutationCtx, - options: SendEmailOptions + options: SendEmailOptions, ): Promise; /** * Sends an email by providing individual arguments for `from`, `to`, `subject`, and optionally `html`, `text`, `replyTo`, and `headers`. @@ -234,7 +233,7 @@ export class Resend { html?: string, text?: string, replyTo?: string[], - headers?: { name: string; value: string }[] + headers?: { name: string; value: string }[], ): Promise; /** @deprecated Use the object format e.g. `{ from, to, subject, html }` */ async sendEmail( @@ -245,7 +244,7 @@ export class Resend { html?: string, text?: string, replyTo?: string[], - headers?: { name: string; value: string }[] + headers?: { name: string; value: string }[], ) { const sendEmailArgs = typeof fromOrOptions === "string" @@ -273,7 +272,7 @@ export class Resend { async sendEmailManually( ctx: RunMutationCtx, options: Omit, - sendCallback: (emailId: EmailId) => Promise + sendCallback: (emailId: EmailId) => Promise, ): Promise { const emailId = (await ctx.runMutation( this.component.lib.createManualEmail, @@ -283,7 +282,7 @@ export class Resend { subject: options.subject, replyTo: options.replyTo, headers: options.headers, - } + }, )) as EmailId; try { const resendId = await sendCallback(emailId); @@ -335,7 +334,7 @@ export class Resend { */ async status( ctx: RunQueryCtx, - emailId: EmailId + emailId: EmailId, ): Promise { return await ctx.runQuery(this.component.lib.getStatus, { emailId, @@ -352,7 +351,7 @@ export class Resend { */ async get( ctx: RunQueryCtx, - emailId: EmailId + emailId: EmailId, ): Promise<{ from: string; to: string; @@ -386,7 +385,7 @@ export class Resend { */ async handleResendEventWebhook( ctx: RunMutationCtx, - req: Request + req: Request, ): Promise { if (this.config.webhookSecret === "") { throw new Error("Webhook secret is not set"); @@ -425,8 +424,8 @@ export class Resend { defineOnEmailEvent( handler: ( ctx: GenericMutationCtx, - args: { id: EmailId; event: EmailEvent } - ) => Promise + args: { id: EmailId; event: EmailEvent }, + ) => Promise, ) { return internalMutationGeneric({ args: { @@ -437,32 +436,3 @@ export class Resend { }); } } - -export type OpaqueIds = - T extends GenericId - ? string - : T extends (infer U)[] - ? OpaqueIds[] - : T extends ArrayBuffer - ? ArrayBuffer - : T extends object - ? { [K in keyof T]: OpaqueIds } - : T; - -export type UseApi = Expand<{ - [mod in keyof API]: API[mod] extends FunctionReference< - infer FType, - "public", - infer FArgs, - infer FReturnType, - infer FComponentPath - > - ? FunctionReference< - FType, - "internal", - OpaqueIds, - OpaqueIds, - FComponentPath - > - : UseApi; -}>; diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts deleted file mode 100644 index 6b5ea51..0000000 --- a/src/component/_generated/api.d.ts +++ /dev/null @@ -1,370 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type * as crons from "../crons.js"; -import type * as lib from "../lib.js"; -import type * as shared from "../shared.js"; - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -declare const fullApi: ApiFromModules<{ - crons: typeof crons; - lib: typeof lib; - shared: typeof shared; -}>; -export type Mounts = { - lib: { - cancelEmail: FunctionReference< - "mutation", - "public", - { emailId: string }, - null - >; - get: FunctionReference< - "query", - "public", - { emailId: string }, - { - complained: boolean; - errorMessage?: string; - finalizedAt: number; - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - opened: boolean; - replyTo: Array; - resendId?: string; - segment: number; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced"; - subject: string; - text?: string; - to: string; - } - >; - getStatus: FunctionReference< - "query", - "public", - { emailId: string }, - { - complained: boolean; - errorMessage: string | null; - opened: boolean; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced"; - } - >; - handleEmailEvent: FunctionReference< - "mutation", - "public", - { event: any }, - null - >; - sendEmail: FunctionReference< - "mutation", - "public", - { - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - options: { - apiKey: string; - initialBackoffMs: number; - onEmailEvent?: { fnHandle: string }; - retryAttempts: number; - testMode: boolean; - }; - replyTo?: Array; - subject: string; - text?: string; - to: string; - }, - string - >; - }; -}; -// For now fullApiWithMounts is only fullApi which provides -// jump-to-definition in component client code. -// Use Mounts for the same type without the inference. -declare const fullApiWithMounts: typeof fullApi; - -export declare const api: FilterApi< - typeof fullApiWithMounts, - FunctionReference ->; -export declare const internal: FilterApi< - typeof fullApiWithMounts, - FunctionReference ->; - -export declare const components: { - rateLimiter: { - lib: { - checkRateLimit: FunctionReference< - "query", - "internal", - { - config: - | { - capacity?: number; - kind: "token bucket"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: null; - } - | { - capacity?: number; - kind: "fixed window"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: number; - }; - count?: number; - key?: string; - name: string; - reserve?: boolean; - throws?: boolean; - }, - { ok: true; retryAfter?: number } | { ok: false; retryAfter: number } - >; - clearAll: FunctionReference< - "mutation", - "internal", - { before?: number }, - null - >; - getServerTime: FunctionReference<"mutation", "internal", {}, number>; - getValue: FunctionReference< - "query", - "internal", - { - config: - | { - capacity?: number; - kind: "token bucket"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: null; - } - | { - capacity?: number; - kind: "fixed window"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: number; - }; - key?: string; - name: string; - sampleShards?: number; - }, - { - config: - | { - capacity?: number; - kind: "token bucket"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: null; - } - | { - capacity?: number; - kind: "fixed window"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: number; - }; - shard: number; - ts: number; - value: number; - } - >; - rateLimit: FunctionReference< - "mutation", - "internal", - { - config: - | { - capacity?: number; - kind: "token bucket"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: null; - } - | { - capacity?: number; - kind: "fixed window"; - maxReserved?: number; - period: number; - rate: number; - shards?: number; - start?: number; - }; - count?: number; - key?: string; - name: string; - reserve?: boolean; - throws?: boolean; - }, - { ok: true; retryAfter?: number } | { ok: false; retryAfter: number } - >; - resetRateLimit: FunctionReference< - "mutation", - "internal", - { key?: string; name: string }, - null - >; - }; - time: { - getServerTime: FunctionReference<"mutation", "internal", {}, number>; - }; - }; - emailWorkpool: { - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - }; - }; - callbackWorkpool: { - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - }; - }; -}; diff --git a/src/component/_generated/api.js b/src/component/_generated/api.js deleted file mode 100644 index 44bf985..0000000 --- a/src/component/_generated/api.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi, componentsGeneric } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/src/component/_generated/api.ts b/src/component/_generated/api.ts new file mode 100644 index 0000000..24fe41a --- /dev/null +++ b/src/component/_generated/api.ts @@ -0,0 +1,58 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as lib from "../lib.js"; +import type * as shared from "../shared.js"; +import type * as utils from "../utils.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; +import { anyApi, componentsGeneric } from "convex/server"; + +const fullApi: ApiFromModules<{ + lib: typeof lib; + shared: typeof shared; + utils: typeof utils; +}> = anyApi as any; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api: FilterApi< + typeof fullApi, + FunctionReference +> = anyApi as any; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export const internal: FilterApi< + typeof fullApi, + FunctionReference +> = anyApi as any; + +export const components = componentsGeneric() as unknown as { + rateLimiter: import("@convex-dev/rate-limiter/_generated/component.js").ComponentApi<"rateLimiter">; + emailWorkpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"emailWorkpool">; + callbackWorkpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"callbackWorkpool">; +}; diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts new file mode 100644 index 0000000..df02d70 --- /dev/null +++ b/src/component/_generated/component.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * Generated `ComponentApi` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { FunctionReference } from "convex/server"; + +/** + * A utility for referencing a Convex component's exposed API. + * + * Useful when expecting a parameter like `components.myComponent`. + * Usage: + * ```ts + * async function myFunction(ctx: QueryCtx, component: ComponentApi) { + * return ctx.runQuery(component.someFile.someQuery, { ...args }); + * } + * ``` + */ +export type ComponentApi = + { + lib: { + cancelEmail: FunctionReference< + "mutation", + "internal", + { emailId: string }, + null, + Name + >; + cleanupAbandonedEmails: FunctionReference< + "mutation", + "internal", + { olderThan?: number }, + null, + Name + >; + cleanupOldEmails: FunctionReference< + "mutation", + "internal", + { olderThan?: number }, + null, + Name + >; + createManualEmail: FunctionReference< + "mutation", + "internal", + { + from: string; + headers?: Array<{ name: string; value: string }>; + replyTo?: Array; + subject: string; + to: string; + }, + string, + Name + >; + get: FunctionReference< + "query", + "internal", + { emailId: string }, + { + complained: boolean; + createdAt: number; + errorMessage?: string; + finalizedAt: number; + from: string; + headers?: Array<{ name: string; value: string }>; + html?: string; + opened: boolean; + replyTo: Array; + resendId?: string; + segment: number; + status: + | "waiting" + | "queued" + | "cancelled" + | "sent" + | "delivered" + | "delivery_delayed" + | "bounced" + | "failed"; + subject: string; + text?: string; + to: string; + } | null, + Name + >; + getStatus: FunctionReference< + "query", + "internal", + { emailId: string }, + { + complained: boolean; + errorMessage: string | null; + opened: boolean; + status: + | "waiting" + | "queued" + | "cancelled" + | "sent" + | "delivered" + | "delivery_delayed" + | "bounced" + | "failed"; + } | null, + Name + >; + handleEmailEvent: FunctionReference< + "mutation", + "internal", + { event: any }, + null, + Name + >; + sendEmail: FunctionReference< + "mutation", + "internal", + { + from: string; + headers?: Array<{ name: string; value: string }>; + html?: string; + options: { + apiKey: string; + initialBackoffMs: number; + onEmailEvent?: { fnHandle: string }; + retryAttempts: number; + testMode: boolean; + }; + replyTo?: Array; + subject: string; + text?: string; + to: string; + }, + string, + Name + >; + updateManualEmail: FunctionReference< + "mutation", + "internal", + { + emailId: string; + errorMessage?: string; + resendId?: string; + status: + | "waiting" + | "queued" + | "cancelled" + | "sent" + | "delivered" + | "delivery_delayed" + | "bounced" + | "failed"; + }, + null, + Name + >; + }; + }; diff --git a/src/component/_generated/dataModel.d.ts b/src/component/_generated/dataModel.ts similarity index 100% rename from src/component/_generated/dataModel.d.ts rename to src/component/_generated/dataModel.ts diff --git a/src/component/_generated/server.js b/src/component/_generated/server.js deleted file mode 100644 index 4a21df4..0000000 --- a/src/component/_generated/server.js +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, - componentsGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export const internalAction = internalActionGeneric; - -/** - * Define a Convex HTTP action. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object - * as its second. - * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. - */ -export const httpAction = httpActionGeneric; diff --git a/src/component/_generated/server.d.ts b/src/component/_generated/server.ts similarity index 79% rename from src/component/_generated/server.d.ts rename to src/component/_generated/server.ts index b5c6828..24994e4 100644 --- a/src/component/_generated/server.d.ts +++ b/src/component/_generated/server.ts @@ -8,9 +8,8 @@ * @module */ -import { +import type { ActionBuilder, - AnyComponents, HttpActionBuilder, MutationBuilder, QueryBuilder, @@ -19,15 +18,18 @@ import { GenericQueryCtx, GenericDatabaseReader, GenericDatabaseWriter, - FunctionReference, +} from "convex/server"; +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, } from "convex/server"; import type { DataModel } from "./dataModel.js"; -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - /** * Define a query in this Convex app's public API. * @@ -36,7 +38,7 @@ type GenericCtx = * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const query: QueryBuilder; +export const query: QueryBuilder = queryGeneric; /** * Define a query that is only accessible from other Convex functions (but not from the client). @@ -46,7 +48,8 @@ export declare const query: QueryBuilder; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const internalQuery: QueryBuilder; +export const internalQuery: QueryBuilder = + internalQueryGeneric; /** * Define a mutation in this Convex app's public API. @@ -56,7 +59,7 @@ export declare const internalQuery: QueryBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const mutation: MutationBuilder; +export const mutation: MutationBuilder = mutationGeneric; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). @@ -66,7 +69,8 @@ export declare const mutation: MutationBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const internalMutation: MutationBuilder; +export const internalMutation: MutationBuilder = + internalMutationGeneric; /** * Define an action in this Convex app's public API. @@ -79,7 +83,7 @@ export declare const internalMutation: MutationBuilder; * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ -export declare const action: ActionBuilder; +export const action: ActionBuilder = actionGeneric; /** * Define an action that is only accessible from other Convex functions (but not from the client). @@ -87,19 +91,26 @@ export declare const action: ActionBuilder; * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ -export declare const internalAction: ActionBuilder; +export const internalAction: ActionBuilder = + internalActionGeneric; /** * Define an HTTP action. * - * This function will be used to respond to HTTP requests received by a Convex - * deployment if the requests matches the path and method where this action - * is routed. Be sure to route your action in `convex/http.js`. + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ -export declare const httpAction: HttpActionBuilder; +export const httpAction: HttpActionBuilder = httpActionGeneric; + +type GenericCtx = + | GenericActionCtx + | GenericMutationCtx + | GenericQueryCtx; /** * A set of services for use within Convex query functions. @@ -107,8 +118,7 @@ export declare const httpAction: HttpActionBuilder; * The query context is passed as the first argument to any Convex query * function run on the server. * - * This differs from the {@link MutationCtx} because all of the services are - * read-only. + * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead. */ export type QueryCtx = GenericQueryCtx; @@ -117,6 +127,8 @@ export type QueryCtx = GenericQueryCtx; * * The mutation context is passed as the first argument to any Convex mutation * function run on the server. + * + * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead. */ export type MutationCtx = GenericMutationCtx; diff --git a/src/component/lib.test.ts b/src/component/lib.test.ts index b139756..203fceb 100644 --- a/src/component/lib.test.ts +++ b/src/component/lib.test.ts @@ -65,7 +65,7 @@ describe("handleEmailEvent", () => { expect(updatedEmail.finalizedAt).toBeLessThan(Number.MAX_SAFE_INTEGER); expect(updatedEmail.finalizedAt).toBeGreaterThan(Date.now() - 10000); // Within last 10 seconds expect(updatedEmail.errorMessage).toBe( - "The email bounced due to invalid recipient" + "The email bounced due to invalid recipient", ); }); diff --git a/src/component/lib.ts b/src/component/lib.ts index 15e5ec3..d6c7c63 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -19,7 +19,7 @@ import { vStatus, } from "./shared.js"; import type { FunctionHandle } from "convex/server"; -import type { EmailEvent, RunMutationCtx } from "./shared.js"; +import type { EmailEvent, RunMutationCtx, RunQueryCtx } from "./shared.js"; import { isDeepEqual } from "remeda"; import schema from "./schema.js"; import { omit } from "convex-helpers"; @@ -92,8 +92,8 @@ export const sendEmail = mutation({ v.object({ name: v.string(), value: v.string(), - }) - ) + }), + ), ), }, returns: v.id("emails"), @@ -101,7 +101,7 @@ export const sendEmail = mutation({ // We only allow test emails in test mode. if (args.options.testMode && !isValidResendTestEmail(args.to)) { throw new Error( - `Test mode is enabled, but email address is not a valid resend test address. Did you want to set testMode: false in your ResendOptions?` + `Test mode is enabled, but email address is not a valid resend test address. Did you want to set testMode: false in your ResendOptions?`, ); } @@ -165,8 +165,8 @@ export const createManualEmail = mutation({ v.object({ name: v.string(), value: v.string(), - }) - ) + }), + ), ), }, returns: v.id("emails"), @@ -243,7 +243,7 @@ export const getStatus = query({ complained: v.boolean(), opened: v.boolean(), }), - v.null() + v.null(), ), handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); @@ -271,7 +271,7 @@ export const get = query({ html: v.optional(v.string()), text: v.optional(v.string()), }), - v.null() + v.null(), ), handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); @@ -319,7 +319,7 @@ async function scheduleBatchRun(ctx: MutationCtx, options: RuntimeConfig) { const runId = await ctx.scheduler.runAfter( BASE_BATCH_DELAY, internal.lib.makeBatch, - { reloop: false, segment: getSegment(Date.now() + BASE_BATCH_DELAY) } + { reloop: false, segment: getSegment(Date.now() + BASE_BATCH_DELAY) }, ); // Insert the new worker to reserve exactly one running. @@ -345,7 +345,7 @@ export const makeBatch = internalMutation({ .query("emails") .withIndex("by_status_segment", (q) => // We scan earlier than two segments ago to avoid contention between new email insertions and batch creation. - q.eq("status", "waiting").lte("segment", args.segment - 2) + q.eq("status", "waiting").lte("segment", args.segment - 2), ) .take(BATCH_SIZE); @@ -385,7 +385,7 @@ export const makeBatch = internalMutation({ runAfter: delay, context: { emailIds: emails.map((e) => e._id) }, onComplete: internal.lib.onEmailComplete, - } + }, ); // Let's go around again until there are no more batches to make in this particular segment range. @@ -426,7 +426,7 @@ async function reschedule(ctx: MutationCtx, emailsLeft: boolean) { // Helper to fetch content. We'll use batch apis here to avoid lots of action->query calls. async function getAllContent( ctx: ActionCtx, - contentIds: Id<"content">[] + contentIds: Id<"content">[], ): Promise, string>> { const docs = await ctx.runQuery(internal.lib.getAllContentByIds, { contentIds, @@ -439,7 +439,7 @@ const vBatchReturns = v.union( v.object({ emailIds: v.array(v.id("emails")), resendIds: v.array(v.string()), - }) + }), ); // Okay, finally! Let's call the Resend API with the batch of emails. @@ -510,7 +510,7 @@ async function markEmailsFailedHandler( args: { emailIds: Id<"emails">[]; errorMessage: string; - } + }, ) { await Promise.all( args.emailIds.map(async (emailId) => { @@ -523,7 +523,7 @@ async function markEmailsFailedHandler( errorMessage: args.errorMessage, finalizedAt: Date.now(), }); - }) + }), ); } @@ -543,8 +543,8 @@ export const onEmailComplete = emailPool.defineOnComplete({ ctx.db.patch(emailId, { status: "sent", resendId: resendIds[i], - }) - ) + }), + ), ); } else if (args.result.kind === "failed") { await markEmailsFailedHandler(ctx, { @@ -563,7 +563,7 @@ export const onEmailComplete = emailPool.defineOnComplete({ errorMessage: "Resend API batch job was cancelled", finalizedAt: Date.now(), }); - }) + }), ); } }, @@ -572,7 +572,7 @@ export const onEmailComplete = emailPool.defineOnComplete({ // Helper to create the JSON payload for the Resend API. async function createResendBatchPayload( ctx: ActionCtx, - emailIds: Id<"emails">[] + emailIds: Id<"emails">[], ): Promise<[Id<"emails">[], string] | null> { // Fetch emails from database. const allEmails = await ctx.runQuery(internal.lib.getEmailsByIds, { @@ -588,7 +588,7 @@ async function createResendBatchPayload( ctx, emails .flatMap((e) => [e.html, e.text]) - .filter((id): id is Id<"content"> => id !== undefined) + .filter((id): id is Id<"content"> => id !== undefined), ); // Build payload for resend API. @@ -604,7 +604,7 @@ async function createResendBatchPayload( email.headers.map((h: { name: string; value: string }) => [ h.name, h.value, - ]) + ]), ) : undefined, })); @@ -613,7 +613,7 @@ async function createResendBatchPayload( } const FIXED_WINDOW_DELAY = 100; -async function getDelay(ctx: RunMutationCtx): Promise { +async function getDelay(ctx: RunMutationCtx & RunQueryCtx): Promise { const limit = await resendApiRateLimiter.limit(ctx, "resendApi", { reserve: true, }); @@ -682,7 +682,7 @@ export const handleEmailEvent = mutation({ const result = attemptToParse(vEmailEvent, args.event); if (result.kind === "error") { console.warn( - `Invalid email event received. You might want to to exclude this event from your Resend webhook settings in the Resend dashboard. ${result.error}.` + `Invalid email event received. You might want to to exclude this event from your Resend webhook settings in the Resend dashboard. ${result.error}.`, ); return; } @@ -696,7 +696,7 @@ export const handleEmailEvent = mutation({ if (!email) { console.info( - `Email not found for resendId: ${event.data.email_id}, ignoring...` + `Email not found for resendId: ${event.data.email_id}, ignoring...`, ); return; } @@ -757,7 +757,7 @@ export const handleEmailEvent = mutation({ async function enqueueCallbackIfExists( ctx: MutationCtx, email: Doc<"emails">, - event: EmailEvent + event: EmailEvent, ) { const lastOptions = await ctx.db.query("lastOptions").unique(); if (!lastOptions) { @@ -789,7 +789,7 @@ export const cleanupOldEmails = mutation({ const oldAndDone = await ctx.db .query("emails") .withIndex("by_finalizedAt", (q) => - q.lt("finalizedAt", Date.now() - olderThan) + q.lt("finalizedAt", Date.now() - olderThan), ) .take(500); for (const email of oldAndDone) { @@ -822,7 +822,7 @@ export const cleanupAbandonedEmails = mutation({ const oldAndAbandoned = await ctx.db .query("emails") .withIndex("by_creation_time", (q) => - q.lt("_creationTime", Date.now() - olderThan) + q.lt("_creationTime", Date.now() - olderThan), ) .take(500); diff --git a/src/component/schema.ts b/src/component/schema.ts index 5a0a9e4..00f4f8a 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -27,8 +27,8 @@ export default defineSchema({ v.object({ name: v.string(), value: v.string(), - }) - ) + }), + ), ), status: vStatus, errorMessage: v.optional(v.string()), diff --git a/src/component/setup.test.ts b/src/component/setup.test.ts index 5bb3117..8257ab4 100644 --- a/src/component/setup.test.ts +++ b/src/component/setup.test.ts @@ -23,7 +23,7 @@ test("setup", () => {}); export const createTestEventOfType = ( type: T, - overrides?: Partial> + overrides?: Partial>, ): EventEventOfType => { const baseData = { email_id: "test-resend-id-123", @@ -159,7 +159,7 @@ export const createTestRuntimeConfig = (): RuntimeConfig => ({ export const setupTestLastOptions = ( t: Tester, - overrides?: Partial> + overrides?: Partial>, ) => t.run(async (ctx) => { await ctx.db.insert("lastOptions", { @@ -172,7 +172,7 @@ export const setupTestLastOptions = ( export const insertTestEmail = ( t: Tester, - overrides: Omit, "_id" | "_creationTime"> + overrides: Omit, "_id" | "_creationTime">, ) => t.run(async (ctx) => { const id = await ctx.db.insert("emails", overrides); @@ -183,7 +183,7 @@ export const insertTestEmail = ( export const insertTestSentEmail = ( t: Tester, - overrides?: Partial> + overrides?: Partial>, ) => insertTestEmail(t, { from: "test@example.com", diff --git a/src/component/shared.ts b/src/component/shared.ts index 68e09ec..e78f747 100644 --- a/src/component/shared.ts +++ b/src/component/shared.ts @@ -19,7 +19,7 @@ export const vStatus = v.union( v.literal("delivered"), v.literal("delivery_delayed"), v.literal("bounced"), - v.literal("failed") + v.literal("failed"), ); export type Status = Infer; @@ -48,8 +48,8 @@ const commonFields = { v.object({ name: v.string(), value: v.string(), - }) - ) + }), + ), ), subject: v.string(), tags: v.optional( @@ -59,9 +59,9 @@ const commonFields = { v.object({ name: v.string(), value: v.string(), - }) - ) - ) + }), + ), + ), ), }; @@ -133,7 +133,7 @@ export const vEmailEvent = v.union( reason: v.string(), }), }), - }) + }), ); export type EmailEvent = Infer; diff --git a/src/component/utils.test.ts b/src/component/utils.test.ts index 1487b33..ddae6d5 100644 --- a/src/component/utils.test.ts +++ b/src/component/utils.test.ts @@ -12,14 +12,14 @@ describe("isValidResendTestEmail", () => { expect(isValidResendTestEmail("delivered+user-1@resend.dev")).toBe(true); expect(isValidResendTestEmail("delivered+foo-bar@resend.dev")).toBe(true); expect( - isValidResendTestEmail("bounced+user-pw-reset-test@resend.dev") + isValidResendTestEmail("bounced+user-pw-reset-test@resend.dev"), ).toBe(true); expect( - isValidResendTestEmail("complained+account-reset_test1@resend.dev") + isValidResendTestEmail("complained+account-reset_test1@resend.dev"), ).toBe(true); expect(isValidResendTestEmail("complained+@resend.dev")).toBe(true); expect(isValidResendTestEmail("complained+COMPLAINED@resend.dev")).toBe( - true + true, ); }); diff --git a/src/component/utils.ts b/src/component/utils.ts index d677486..2587d02 100644 --- a/src/component/utils.ts +++ b/src/component/utils.ts @@ -10,10 +10,9 @@ export const iife = (fn: () => T): T => fn(); /** * Generic function to attempt parsing with proper TypeScript type narrowing */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export function attemptToParse>( validator: T, - value: unknown + value: unknown, ): { kind: "success"; data: Infer } | { kind: "error"; error: unknown } { try { return { diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..0974add --- /dev/null +++ b/src/test.ts @@ -0,0 +1,18 @@ +/// +import type { TestConvex } from "convex-test"; +import type { GenericSchema, SchemaDefinition } from "convex/server"; +import schema from "./component/schema.js"; +const modules = import.meta.glob("./component/**/*.ts"); + +/** + * Register the component with the test convex instance. + * @param t - The test convex instance, e.g. from calling `convexTest`. + * @param name - The name of the component, as registered in convex.config.ts. + */ +export function register( + t: TestConvex>, + name: string = "resend", +) { + t.registerComponent(name, schema, modules); +} +export default { register, schema, modules }; diff --git a/tsconfig.build.json b/tsconfig.build.json index 3329d66..4bbd336 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.d.ts"], - "exclude": ["src/**/*.test.*", "src/vitest.config.ts"], + "exclude": ["src/**/*.test.*", "src/vitest.config.ts", "src/test.ts"], "compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", diff --git a/tsconfig.json b/tsconfig.json index 91ec09c..131de35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,8 @@ "lib": ["ES2021", "dom"], "forceConsistentCasingInFileNames": true, "allowSyntheticDefaultImports": true, + // We enforce stricter module resolution for Node16 compatibility + // But when building we use Bundler & ESNext for ESM "module": "NodeNext", "moduleResolution": "NodeNext", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..aff5230 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "example/src/**/*.ts", + "example/src/**/*.tsx", + "example/convex/**/*.ts" + ], + "exclude": ["node_modules", "dist", "**/_generated"] +} diff --git a/src/vitest.config.ts b/vitest.config.js similarity index 66% rename from src/vitest.config.ts rename to vitest.config.js index 28ce6fa..e9408f8 100644 --- a/src/vitest.config.ts +++ b/vitest.config.js @@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "edge-runtime", + typecheck: { + tsconfig: "./tsconfig.test.json", + }, }, });