commit 2eaff5e77f090dcf5e8af34ce8ea3d2304d7df66 Author: Shawn Erquhart Date: Thu Oct 31 17:35:23 2024 -0400 wip diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..587745f --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,23 @@ +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: cd example && npm i && cd .. + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbf89d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +.idea +*.local +*.log +/.vscode/ +/docs/.vitepress/cache +dist +dist-ssr +explorations +node_modules +.eslintcache +# components are libraries! +package-lock.json + +# this is a package-json-redirect stub dir, see https://github.com/andrewbranch/example-subpath-exports-ts-compat?tab=readme-ov-file +react/package.json +# npm pack output +*.tgz diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..757fd64 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "trailingComma": "es5" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..49ef6ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Developing guide + +## Running locally + +```sh +npm i +cd example +npm i +npx convex dev +``` + +## Testing + +```sh +rm -rf dist/ && npm run build +npm run typecheck +npm run test +cd example +npm run lint +cd .. +``` + +## Deploying + +### Building a one-off package + +```sh +rm -rf dist/ && npm run build +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 +``` + +#### Alpha release + +The same as above, but it requires extra flags so the release is only installed with `@alpha`: + +```sh +npm version prerelease --preid alpha +npm publish --tag alpha +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c61b663 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f68161 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# Convex Component Template + +This is a Convex component, ready to be published on npm. + +To create your own component: + +1. Find and replace "Counter" to your component's Name. +1. Find and replace "counter" to your component's name. +1. Write code in src/component for your component. +1. Write code in src/client for your thick client. +1. Write example usage in example/convex/example.ts. +1. Delete the text in this readme until `---` and flesh out the README. + +It is safe to find & replace "counter" project-wide. + +To develop your component run a dev process in the example project. + +``` +npm i +cd example +npm i +npx convex dev +``` + +Modify the schema and index files in src/component/ to define your component. + +Write a client for using this component in src/client/index.ts. + +If you won't be adding frontend code (e.g. React components) to this +component you can delete the following: + +- "prepack" and "postpack" scripts of package.json +- "./react" exports in package.json +- the "src/react/" directory +- the "node10stubs.mjs" file + +### Component Directory structure + +``` +. +├── README.md documentation of your component +├── package.json component name, version number, other metadata +├── package-lock.json Components are like libraries, package-lock.json +│ is .gitignored and ignored by consumers. +├── src +│   ├── component/ +│   │   ├── _generated/ Files here are generated. +│   │   ├── convex.config.ts Name your component here and use other components +│   │   ├── index.ts Define functions here and in new files in this directory +│   │   └── schema.ts schema specific to this component +│   ├── client/index.ts "Thick" client code goes here. +│   └── react/ Code intended to be used on the frontend goes here. +│ │ Your are free to delete this if this component +│ │ does not provide code. +│   └── index.ts +├── example/ example Convex app that uses this component +│ │ Run 'npx convex dev' from here during development. +│   ├── package.json.ts Thick client code goes here. +│   └── convex/ +│      ├── _generated/ +│      ├── convex.config.ts Imports and uses this component +│      ├── myFunctions.ts Functions that use the component +│      ├── schema.ts Example app schema +│      └── tsconfig.json +│   +├── dist/ Publishing artifacts will be created here. +├── commonjs.json Used during build by TypeScript. +├── esm.json Used during build by TypeScript. +├── node10stubs.mjs Script used during build for compatibility +│ with the Metro bundler used with React Native. +├── eslint.config.mjs Recommended lints for writing a component. +│ Feel free to customize it. +└── tsconfig.json Recommended tsconfig.json for writing a component. + Some settings can be customized, some are required. +``` + +### Structure of a Convex Component + +A Convex components exposes the entry point convex.config.js. The on-disk +location of this file must be a directory containing implementation files. These +files should be compiled to ESM. +The package.json should contain `"type": "module"` and the tsconfig.json should +contain `"moduleResolution": "Bundler"` or `"Node16"` in order to import other +component definitions. + +In addition to convex.config.js, a component typically exposes a client that +wraps communication with the component for use in the Convex +environment is typically exposed as a named export `MyComponentClient` or +`MyComponent` imported from the root package. + +``` +import { MyComponentClient } from "my-convex-component"; +``` + +When frontend code is included it is typically published at a subpath: + +``` +import { helper } from "my-convex-component/react"; +import { FrontendReactComponent } from "my-convex-component/react"; +``` + +Frontend code should be compiled as CommonJS code as well as ESM and make use of +subpackage stubs (see next section). + +If you do include frontend components, prefer peer dependencies to avoid using +more than one version of e.g. React. + +### Support for Node10 module resolution + +The [Metro](https://reactnative.dev/docs/metro) bundler for React Native +requires setting +[`resolver.unstable_enablePackageExports`](https://metrobundler.dev/docs/package-exports/) +in order to import code that lives in `dist/esm/react.js` from a path like +`my-convex-component/react`. + +Authors of Convex component that provide frontend components are encouraged to +support these legacy "Node10-style" module resolution algorithms by generating +stub directories with special pre- and post-pack scripts. + +--- + +# Convex Counter Component + +[![npm version](https://badge.fury.io/js/@convex-dev%2Fcounter.svg)](https://badge.fury.io/js/@convex-dev%2Fcounter) + +**Note: Convex Components are currently in beta** + + + +- [ ] What is some compelling syntax as a hook? +- [ ] Why should you use this component? +- [ ] Links to Stack / other resources? + +Found a bug? Feature request? [File it here](https://github.com/get-convex/counter/issues). + +## Pre-requisite: Convex + +You'll need an existing Convex project to use the component. +Convex is a hosted backend platform, including a database, serverless functions, +and a ton more you can learn about [here](https://docs.convex.dev/get-started). + +Run `npm create convex` or follow any of the [quickstarts](https://docs.convex.dev/home) to set one up. + +## Installation + +Install the component package: + +```ts +npm install @convex-dev/counter +``` + +Create a `convex.config.ts` file in your app's `convex/` folder and install the component by calling `use`: + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import counter from "@convex-dev/counter/convex.config"; + +const app = defineApp(); +app.use(counter); + +export default app; +``` + +## Usage + +```ts +import { components } from "./_generated/api"; +import { Counter } from "@convex-dev/counter"; + +const counter = new Counter(components.counter, { + ...options, +}); +``` + +See more example usage in [example.ts](./example/convex/example.ts). + + diff --git a/commonjs.json b/commonjs.json new file mode 100644 index 0000000..29e7d92 --- /dev/null +++ b/commonjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.*", "../src/package.json"], + "compilerOptions": { + "outDir": "./dist/commonjs" + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..6f7c313 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + { files: ["src/**/*.{js,mjs,cjs,ts,tsx}"] }, + { + ignores: [ + "dist/**", + "eslint.config.js", + "**/_generated/", + "node10stubs.mjs", + ], + }, + { + languageOptions: { + globals: globals.worker, + parser: tseslint.parser, + + parserOptions: { + project: true, + tsconfigRootDir: ".", + }, + }, + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-floating-promises": "error", + "eslint-comments/no-unused-disable": "off", + + // allow (_arg: number) => {} and const _foo = 1; + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, +]; diff --git a/esm.json b/esm.json new file mode 100644 index 0000000..f984cc5 --- /dev/null +++ b/esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.*", "../src/package.json"], + "compilerOptions": { + "outDir": "./dist/esm" + } +} diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..983c26f --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,16 @@ +!**/glob-import/dir/node_modules +.DS_Store +.idea +*.cpuprofile +*.local +*.log +/.vscode/ +/docs/.vitepress/cache +dist +dist-ssr +explorations +node_modules +playground-temp +temp +TODOs.md +.eslintcache diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..c894719 --- /dev/null +++ b/example/README.md @@ -0,0 +1,5 @@ +# Example app + +Components need an app that uses them in order to run codegen. An example app is also useful +for testing and documentation. + diff --git a/example/convex/README.md b/example/convex/README.md new file mode 100644 index 0000000..4d82e13 --- /dev/null +++ b/example/convex/README.md @@ -0,0 +1,90 @@ +# Welcome to your Convex functions directory! + +Write your Convex functions here. +See https://docs.convex.dev/functions for more. + +A query function that takes two arguments looks like: + +```ts +// functions.js +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const myQueryFunction = query({ + // Validators for arguments. + args: { + first: v.number(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Read the database as many times as you need here. + // See https://docs.convex.dev/database/reading-data. + const documents = await ctx.db.query("tablename").collect(); + + // Arguments passed from the client are properties of the args object. + console.log(args.first, args.second); + + // Write arbitrary JavaScript here: filter, aggregate, build derived data, + // remove non-public properties, or create new objects. + return documents; + }, +}); +``` + +Using this query function in a React component looks like: + +```ts +const data = useQuery(api.functions.myQueryFunction, { + first: 10, + second: "hello", +}); +``` + +A mutation function looks like: + +```ts +// functions.js +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const myMutationFunction = mutation({ + // Validators for arguments. + args: { + first: v.string(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Insert or modify documents in the database here. + // Mutations can also read from the database like queries. + // See https://docs.convex.dev/database/writing-data. + const message = { body: args.first, author: args.second }; + const id = await ctx.db.insert("messages", message); + + // Optionally, return a value from your mutation. + return await ctx.db.get(id); + }, +}); +``` + +Using this mutation function in a React component looks like: + +```ts +const mutation = useMutation(api.functions.myMutationFunction); +function handleButtonPress() { + // fire and forget, the most common way to use mutations + mutation({ first: "Hello!", second: "me" }); + // OR + // use the result once the mutation has completed + mutation({ first: "Hello!", second: "me" }).then((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`. diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts new file mode 100644 index 0000000..08c2f51 --- /dev/null +++ b/example/convex/_generated/api.d.ts @@ -0,0 +1,63 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as example from "../example.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<{ + example: typeof example; +}>; +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: { + polar: { + lib: { + getOnboardingCheckoutUrl: FunctionReference< + "action", + "internal", + { + polarAccessToken: string; + successUrl: string; + userEmail: string; + userId: string; + }, + any + >; + listPlans: FunctionReference<"query", "internal", {}, any>; + setSubscriptionPending: FunctionReference< + "mutation", + "internal", + any, + any + >; + }; + }; +}; diff --git a/example/convex/_generated/api.js b/example/convex/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/example/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* 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/example/convex/_generated/dataModel.d.ts b/example/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..8541f31 --- /dev/null +++ b/example/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/example/convex/_generated/server.d.ts b/example/convex/_generated/server.d.ts new file mode 100644 index 0000000..b5c6828 --- /dev/null +++ b/example/convex/_generated/server.d.ts @@ -0,0 +1,149 @@ +/* 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 { + ActionBuilder, + AnyComponents, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + 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. + * + * 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 declare const query: QueryBuilder; + +/** + * 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 declare const internalQuery: QueryBuilder; + +/** + * 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 declare const mutation: MutationBuilder; + +/** + * 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 declare const internalMutation: MutationBuilder; + +/** + * 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 declare const action: ActionBuilder; + +/** + * 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 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`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * 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. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/example/convex/_generated/server.js b/example/convex/_generated/server.js new file mode 100644 index 0000000..4a21df4 --- /dev/null +++ b/example/convex/_generated/server.js @@ -0,0 +1,90 @@ +/* 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/example/convex/convex.config.ts b/example/convex/convex.config.ts new file mode 100644 index 0000000..998da50 --- /dev/null +++ b/example/convex/convex.config.ts @@ -0,0 +1,7 @@ +import { defineApp } from "convex/server"; +import polar from "@convex-dev/polar/convex.config"; + +const app = defineApp(); +app.use(polar); + +export default app; diff --git a/example/convex/example.ts b/example/convex/example.ts new file mode 100644 index 0000000..8fa7333 --- /dev/null +++ b/example/convex/example.ts @@ -0,0 +1,60 @@ +import { internalMutation, query, mutation } from "./_generated/server"; +import { components } from "./_generated/api"; +import { Polar } from "@convex-dev/polar"; + +const polar = new Polar(components.polar); + +/* +export const addOne = mutation({ + args: {}, + handler: async (ctx, _args) => { + await numUsers.inc(ctx); + }, +}); + +export const getCount = query({ + args: {}, + handler: async (ctx, _args) => { + return await numUsers.count(ctx); + }, +}); + +export const usingClient = internalMutation({ + args: {}, + handler: async (ctx, _args) => { + await polar.add(ctx, "accomplishments"); + await polar.add(ctx, "beans", 2); + const count = await polar.count(ctx, "beans"); + return count; + }, +}); + +export const usingFunctions = internalMutation({ + args: {}, + handler: async (ctx, _args) => { + await numUsers.inc(ctx); + await numUsers.inc(ctx); + await numUsers.dec(ctx); + return numUsers.count(ctx); + }, +}); + +export const directCall = internalMutation({ + args: {}, + handler: async (ctx, _args) => { + await ctx.runMutation(components.polar.lib.add, { + name: "pennies", + count: 250, + }); + await ctx.runMutation(components.polar.lib.add, { + name: "beans", + count: 3, + shards: 100, + }); + const count = await ctx.runQuery(components.polar.lib.count, { + name: "beans", + }); + return count; + }, +}); +*/ diff --git a/example/convex/schema.ts b/example/convex/schema.ts new file mode 100644 index 0000000..46f2860 --- /dev/null +++ b/example/convex/schema.ts @@ -0,0 +1,7 @@ +import { defineSchema } from "convex/server"; + +export default defineSchema( + { + // Any tables used by the example app go here. + }, +); diff --git a/example/convex/tsconfig.json b/example/convex/tsconfig.json new file mode 100644 index 0000000..b8145fd --- /dev/null +++ b/example/convex/tsconfig.json @@ -0,0 +1,31 @@ +{ + /* 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", "DOM.Iterable"], + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* 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"] + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/example/eslint.config.js b/example/eslint.config.js new file mode 100644 index 0000000..f0c101e --- /dev/null +++ b/example/eslint.config.js @@ -0,0 +1,40 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +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, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + // Allow explicit `any`s + "@typescript-eslint/no-explicit-any": "off", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + } +); diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/example/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/example/package.json b/example/package.json new file mode 100644 index 0000000..b7e6119 --- /dev/null +++ b/example/package.json @@ -0,0 +1,32 @@ +{ + "name": "uses-component", + "private": true, + "type": "module", + "version": "0.0.0", + "scripts": { + "dev": "convex dev --live-component-sources --typecheck-components", + "dev:frontend": "vite", + "logs": "convex logs", + "lint": "tsc -p convex && eslint convex" + }, + "dependencies": { + "@convex-dev/polar": "file:..", + "convex": "file:../node_modules/convex", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.9.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/example/src/App.css b/example/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/example/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/example/src/App.tsx b/example/src/App.tsx new file mode 100644 index 0000000..e61c8b8 --- /dev/null +++ b/example/src/App.tsx @@ -0,0 +1,23 @@ +import "./App.css"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function App() { + const count = useQuery(api.example.getCount); + const addOne = useMutation(api.example.addOne); + + return ( + <> +

Convex Polar Component Example

+
+ +

+ See example/convex/example.ts for all the ways to use + this component +

+
+ + ); +} + +export default App; diff --git a/example/src/index.css b/example/src/index.css new file mode 100644 index 0000000..6119ad9 --- /dev/null +++ b/example/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/example/src/main.tsx b/example/src/main.tsx new file mode 100644 index 0000000..2669e61 --- /dev/null +++ b/example/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import App from "./App.tsx"; +import "./index.css"; + +const address = import.meta.env.VITE_CONVEX_URL; + +const convex = new ConvexReactClient(address); + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/example/src/vite-env.d.ts b/example/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/example/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 0000000..9b5c7dd --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,24 @@ +{ + "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", + + /* 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"] + }, + "include": ["./src", "vite.config.ts"] +} diff --git a/example/vite.config.ts b/example/vite.config.ts new file mode 100644 index 0000000..d5ff574 --- /dev/null +++ b/example/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + conditions: ["@convex-dev/component-source"], + }, +}); diff --git a/node10stubs.mjs b/node10stubs.mjs new file mode 100644 index 0000000..6a76782 --- /dev/null +++ b/node10stubs.mjs @@ -0,0 +1,86 @@ +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/commonjs/${subDir}/index.js`, + module: `../dist/esm/${subDir}/index.js`, + types: `../dist/commonjs/${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.json b/package.json new file mode 100644 index 0000000..40efbfc --- /dev/null +++ b/package.json @@ -0,0 +1,94 @@ +{ + "name": "@convex-dev/polar", + "description": "A Polar component for Convex.", + "repository": "github:get-convex/polar", + "homepage": "https://github.com/get-convex/polar#readme", + "bugs": { + "email": "support@convex.dev", + "url": "https://github.com/get-convex/polar/issues" + }, + "version": "0.1.0", + "license": "Apache-2.0", + "keywords": [ + "convex", + "component" + ], + "type": "module", + "scripts": { + "build": "npm run build:esm && npm run build:cjs", + "build:esm": "tsc --project ./esm.json && echo '{\\n \"type\": \"module\"\\n}' > dist/esm/package.json", + "build:cjs": "tsc --project ./commonjs.json && echo '{\\n \"type\": \"commonjs\"\\n}' > dist/commonjs/package.json", + "dev": "cd example; npm run dev", + "typecheck": "tsc --noEmit", + "prepare": "npm run build", + "prepack": "node node10stubs.mjs", + "postpack": "node node10stubs.mjs --cleanup", + "test": "vitest run", + "test:debug": "vitest --inspect-brk --no-file-parallelism", + "test:coverage": "vitest run --coverage --coverage.reporter=text" + }, + "files": [ + "dist", + "src", + "react" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "@convex-dev/component-source": "./src/client/index.ts", + "types": "./dist/esm/client/index.d.ts", + "default": "./dist/esm/client/index.js" + }, + "require": { + "@convex-dev/component-source": "./src/client/index.ts", + "types": "./dist/commonjs/client/index.d.ts", + "default": "./dist/commonjs/client/index.js" + } + }, + "./react": { + "import": { + "@convex-dev/component-source": "./src/react/index.ts", + "types": "./dist/esm/react.d.ts", + "default": "./dist/esm/react.js" + }, + "require": { + "@convex-dev/component-source": "./src/react/index.ts", + "types": "./dist/commonjs/react.d.ts", + "default": "./dist/commonjs/react.js" + } + }, + "./convex.config": { + "import": { + "@convex-dev/component-source": "./src/component/convex.config.ts", + "types": "./dist/esm/component/convex.config.d.ts", + "default": "./dist/esm/component/convex.config.js" + } + } + }, + "peerDependencies": { + "convex": "~1.16.5 || ~1.17.0" + }, + "devDependencies": { + "@eslint/js": "^9.9.1", + "@types/node": "^18.17.0", + "@types/react": "^18.3.12", + "convex-test": "^0.0.33", + "eslint": "^9.9.1", + "globals": "^15.9.0", + "prettier": "3.2.5", + "typescript": "~5.0.3", + "typescript-eslint": "^8.4.0", + "vitest": "^2.1.4" + }, + "main": "./dist/commonjs/client/index.js", + "types": "./dist/commonjs/client/index.d.ts", + "module": "./dist/esm/client/index.js", + "dependencies": { + "@convex-dev/auth": "^0.0.74", + "@polar-sh/sdk": "^0.13.5", + "@react-email/components": "0.0.26", + "convex-helpers": "^0.1.63", + "standardwebhooks": "^1.0.0" + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..6d0d250 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,242 @@ +import { + Expand, + FunctionReference, + GenericDataModel, + GenericMutationCtx, + GenericQueryCtx, + HttpRouter, +} from "convex/server"; +import { GenericId } from "convex/values"; +import { api } from "../component/_generated/api"; + +import { + type WebhookSubscriptionCreatedPayload, + type WebhookSubscriptionCreatedPayload$Outbound, + WebhookSubscriptionCreatedPayload$inboundSchema as WebhookSubscriptionCreatedPayloadSchema, +} from "@polar-sh/sdk/models/components/webhooksubscriptioncreatedpayload"; +import { + type WebhookSubscriptionUpdatedPayload, + type WebhookSubscriptionUpdatedPayload$Outbound, + WebhookSubscriptionUpdatedPayload$inboundSchema as WebhookSubscriptionUpdatedPayloadSchema, +} from "@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload"; +import { Webhook } from "standardwebhooks"; +import { internal } from "../component/_generated/api"; +import type { Doc } from "../component/_generated/dataModel"; +import { httpAction, type ActionCtx } from "../component/_generated/server"; +import { + sendSubscriptionErrorEmail, + sendSubscriptionSuccessEmail, +} from "../component/email/templates/subscriptionEmail"; + +const handleUpdateSubscription = async ( + ctx: ActionCtx, + user: Doc<"users">, + subscription: + | WebhookSubscriptionCreatedPayload + | WebhookSubscriptionUpdatedPayload +) => { + const subscriptionItem = subscription.data; + await ctx.runMutation(internal.lib.replaceSubscription, { + userId: user._id, + subscriptionPolarId: subscription.data.id, + input: { + productId: subscriptionItem.productId, + priceId: subscriptionItem.priceId, + interval: subscriptionItem.recurringInterval, + status: subscriptionItem.status, + currency: "usd", + currentPeriodStart: subscriptionItem.currentPeriodStart.getTime(), + currentPeriodEnd: subscriptionItem.currentPeriodEnd?.getTime(), + cancelAtPeriodEnd: subscriptionItem.cancelAtPeriodEnd, + }, + }); +}; + +const handleSubscriptionChange = async ( + ctx: ActionCtx, + event: WebhookSubscriptionCreatedPayload | WebhookSubscriptionUpdatedPayload +) => { + const user = await ctx.runMutation(internal.lib.getsertUser, { + polarId: event.data.userId, + email: event.data.user.email, + }); + if (!user?.email) { + throw new Error("User not found"); + } + + await handleUpdateSubscription(ctx, user, event); + + const freePlan = await ctx.runQuery(internal.lib.getPlanByKey, { + key: "free", + }); + + // Only send email for paid plans + if (event.data.productId !== freePlan?.polarProductId) { + await sendSubscriptionSuccessEmail({ + email: user.email, + subscriptionId: event.data.id, + }); + } + + return new Response(null); +}; + +const handlePolarSubscriptionUpdatedError = async ( + ctx: ActionCtx, + event: WebhookSubscriptionCreatedPayload | WebhookSubscriptionUpdatedPayload +) => { + const subscription = event.data; + + const user = await ctx.runMutation(internal.lib.getsertUser, { + polarId: subscription.userId, + email: subscription.user.email, + }); + if (!user?.email) throw new Error("User not found"); + + const freePlan = await ctx.runQuery(internal.lib.getPlanByKey, { + key: "free", + }); + + // Only send email for paid plans + if (event.data.productId !== freePlan?.polarProductId) { + await sendSubscriptionErrorEmail({ + email: user.email, + subscriptionId: subscription.id, + }); + } + return new Response(null); +}; + +export class Polar { + constructor(public component: UseApi) {} + + registerRoutes(http: HttpRouter) { + http.route({ + path: "/polar/message-status", + method: "POST", + handler: httpAction(async (ctx, request) => { + if (!request.body) { + return new Response(null, { status: 400 }); + } + + const wh = new Webhook(btoa(process.env.POLAR_WEBHOOK_SECRET!)); + const body = await request.text(); + const event = wh.verify( + body, + Object.fromEntries(request.headers.entries()) + ) as + | WebhookSubscriptionCreatedPayload$Outbound + | WebhookSubscriptionUpdatedPayload$Outbound; + + try { + switch (event.type) { + /** + * Occurs when a subscription has been created. + */ + case "subscription.created": { + return handleSubscriptionChange( + ctx, + WebhookSubscriptionCreatedPayloadSchema.parse(event) + ); + } + + /** + * Occurs when a subscription has been updated. + * E.g. when a user upgrades or downgrades their plan. + */ + case "subscription.updated": { + return handleSubscriptionChange( + ctx, + WebhookSubscriptionUpdatedPayloadSchema.parse(event) + ); + } + } + } catch { + switch (event.type) { + case "subscription.created": { + return handlePolarSubscriptionUpdatedError( + ctx, + WebhookSubscriptionCreatedPayloadSchema.parse(event) + ); + } + + case "subscription.updated": { + return handlePolarSubscriptionUpdatedError( + ctx, + WebhookSubscriptionUpdatedPayloadSchema.parse(event) + ); + } + } + } + }), + }); + } + /* + async add( + ctx: RunMutationCtx, + name: Name, + count: number = 1 + ) { + const shards = this.options?.shards?.[name] ?? this.options?.defaultShards; + return ctx.runMutation(this.component.lib.add, { + name, + count, + shards, + }); + } + async count( + ctx: RunQueryCtx, + name: Name + ) { + return ctx.runQuery(this.component.lib.count, { name }); + } + // Another way of exporting functionality + for(name: Name) { + return { + add: async (ctx: RunMutationCtx, count: number = 1) => + this.add(ctx, name, count), + subtract: async (ctx: RunMutationCtx, count: number = 1) => + this.add(ctx, name, -count), + inc: async (ctx: RunMutationCtx) => this.add(ctx, name, 1), + dec: async (ctx: RunMutationCtx) => this.add(ctx, name, -1), + count: async (ctx: RunQueryCtx) => this.count(ctx, name), + }; + } + */ +} + +/* Type utils follow */ + +type RunQueryCtx = { + runQuery: GenericQueryCtx["runQuery"]; +}; +type RunMutationCtx = { + runMutation: GenericMutationCtx["runMutation"]; +}; + +export type OpaqueIds = + T extends GenericId + ? string + : T extends (infer U)[] + ? OpaqueIds[] + : 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 new file mode 100644 index 0000000..4fdb657 --- /dev/null +++ b/src/component/_generated/api.d.ts @@ -0,0 +1,68 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as email_index from "../email/index.js"; +import type * as email_templates_subscriptionEmail from "../email/templates/subscriptionEmail.js"; +import type * as init from "../init.js"; +import type * as lib from "../lib.js"; +import type * as polar from "../polar.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<{ + "email/index": typeof email_index; + "email/templates/subscriptionEmail": typeof email_templates_subscriptionEmail; + init: typeof init; + lib: typeof lib; + polar: typeof polar; +}>; +export type Mounts = { + lib: { + getOnboardingCheckoutUrl: FunctionReference< + "action", + "public", + { + polarAccessToken: string; + successUrl: string; + userEmail: string; + userId: string; + }, + any + >; + listPlans: FunctionReference<"query", "public", {}, any>; + setSubscriptionPending: FunctionReference<"mutation", "public", any, any>; + }; +}; +// 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: {}; diff --git a/src/component/_generated/api.js b/src/component/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/src/component/_generated/api.js @@ -0,0 +1,23 @@ +/* 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/dataModel.d.ts b/src/component/_generated/dataModel.d.ts new file mode 100644 index 0000000..8541f31 --- /dev/null +++ b/src/component/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/src/component/_generated/server.d.ts b/src/component/_generated/server.d.ts new file mode 100644 index 0000000..b5c6828 --- /dev/null +++ b/src/component/_generated/server.d.ts @@ -0,0 +1,149 @@ +/* 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 { + ActionBuilder, + AnyComponents, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + 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. + * + * 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 declare const query: QueryBuilder; + +/** + * 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 declare const internalQuery: QueryBuilder; + +/** + * 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 declare const mutation: MutationBuilder; + +/** + * 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 declare const internalMutation: MutationBuilder; + +/** + * 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 declare const action: ActionBuilder; + +/** + * 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 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`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * 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. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/src/component/_generated/server.js b/src/component/_generated/server.js new file mode 100644 index 0000000..4a21df4 --- /dev/null +++ b/src/component/_generated/server.js @@ -0,0 +1,90 @@ +/* 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/convex.config.ts b/src/component/convex.config.ts new file mode 100644 index 0000000..aaa30a5 --- /dev/null +++ b/src/component/convex.config.ts @@ -0,0 +1,3 @@ +import { defineComponent } from "convex/server"; + +export default defineComponent("polar"); diff --git a/src/component/email/index.ts b/src/component/email/index.ts new file mode 100644 index 0000000..ff539b7 --- /dev/null +++ b/src/component/email/index.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +const ResendSuccessSchema = z.object({ + id: z.string(), +}); +const ResendErrorSchema = z.union([ + z.object({ + name: z.string(), + message: z.string(), + statusCode: z.number(), + }), + z.object({ + name: z.literal("UnknownError"), + message: z.literal("Unknown Error"), + statusCode: z.literal(500), + cause: z.any(), + }), +]); + +export type SendEmailOptions = { + to: string | string[]; + subject: string; + html: string; + text?: string; +}; + +export async function sendEmail(options: SendEmailOptions) { + const from = + process.env.RESEND_SENDER_EMAIL_AUTH ?? + "Convex SaaS "; + const email = { from, ...options }; + + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(email), + }); + + const data = await response.json(); + const parsedData = ResendSuccessSchema.safeParse(data); + + if (response.ok && parsedData.success) { + return { status: "success", data: parsedData } as const; + } + const parsedErrorResult = ResendErrorSchema.safeParse(data); + if (parsedErrorResult.success) { + console.error(parsedErrorResult.data); + throw new Error(`Error sending email: ${parsedErrorResult.data.message}`); + } + console.error(data); + throw new Error("Error sending email"); +} diff --git a/src/component/email/templates/subscriptionEmail.tsx b/src/component/email/templates/subscriptionEmail.tsx new file mode 100644 index 0000000..03c85f9 --- /dev/null +++ b/src/component/email/templates/subscriptionEmail.tsx @@ -0,0 +1,143 @@ +import { + Body, + Container, + Head, + Hr, + Html, + Img, + Link, + Preview, + Text, +} from "@react-email/components"; +import { render } from "@react-email/render"; +import { sendEmail } from "../index"; + +type SubscriptionEmailOptions = { + email: string; + subscriptionId: string; +}; + +/** + * Templates. + */ +export function SubscriptionSuccessEmail({ email }: SubscriptionEmailOptions) { + return ( + + + Successfully Subscribed to PRO + + + + + Hello {email}! + + + Your subscription to PRO has been successfully processed. +
+ We hope you enjoy the new features! +
+ + The domain-name.com{" "} + team. + +
+ + 200 domain-name.com + +
+ + + ); +} + +export function SubscriptionErrorEmail({ email }: SubscriptionEmailOptions) { + return ( + + + Subscription Issue - Customer Support + + + + + Hello {email}. + + + We were unable to process your subscription to PRO tier. +
+ But don't worry, we'll not charge you anything. +
+ + The domain-name.com{" "} + team. + +
+ + 200 domain-name.com + +
+ + + ); +} + +/** + * Renders. + */ +export function renderSubscriptionSuccessEmail(args: SubscriptionEmailOptions) { + return render(); +} + +export function renderSubscriptionErrorEmail(args: SubscriptionEmailOptions) { + return render(); +} + +/** + * Senders. + */ +export async function sendSubscriptionSuccessEmail({ + email, + subscriptionId, +}: SubscriptionEmailOptions) { + const html = await renderSubscriptionSuccessEmail({ email, subscriptionId }); + + await sendEmail({ + to: email, + subject: "Successfully Subscribed to PRO", + html, + }); +} + +export async function sendSubscriptionErrorEmail({ + email, + subscriptionId, +}: SubscriptionEmailOptions) { + const html = await renderSubscriptionErrorEmail({ email, subscriptionId }); + + await sendEmail({ + to: email, + subject: "Subscription Issue - Customer Support", + html, + }); +} diff --git a/src/component/init.ts b/src/component/init.ts new file mode 100644 index 0000000..4a4d4cb --- /dev/null +++ b/src/component/init.ts @@ -0,0 +1,130 @@ +import { Polar } from "@polar-sh/sdk"; +import { asyncMap } from "convex-helpers"; +import { internal } from "./_generated/api"; +import { internalAction, internalMutation } from "./_generated/server"; +import schema, { CURRENCIES, INTERVALS, PlanKey, PLANS } from "./schema"; + +const seedProducts = [ + { + key: PLANS.FREE, + name: "Free", + description: "Some of the things, free forever.", + amountType: "free", + prices: { + [INTERVALS.MONTH]: { + [CURRENCIES.USD]: 0, + }, + }, + }, + { + key: PLANS.PRO, + name: "Pro", + description: "All the things for one low monthly price.", + amountType: "fixed", + prices: { + [INTERVALS.MONTH]: { + [CURRENCIES.USD]: 2000, + }, + [INTERVALS.YEAR]: { + [CURRENCIES.USD]: 20000, + }, + }, + }, +] as const; + +export const insertSeedPlan = internalMutation({ + args: schema.tables.plans.validator, + handler: async (ctx, args) => { + await ctx.db.insert("plans", { + polarProductId: args.polarProductId, + key: args.key, + name: args.name, + description: args.description, + prices: args.prices, + }); + }, +}); + +const seedProductsAction = internalAction({ + args: {}, + handler: async (ctx) => { + /** + * Stripe Products. + */ + const polar = new Polar({ + server: "sandbox", + accessToken: process.env.POLAR_ACCESS_TOKEN, + }); + const products = await polar.products.list({ + organizationId: process.env.POLAR_ORGANIZATION_ID, + isArchived: false, + }); + if (products?.result?.items?.length) { + console.info("🏃‍♂️ Skipping Polar products creation and seeding."); + return; + } + + await asyncMap(seedProducts, async (product) => { + // Create Polar product. + const polarProduct = await polar.products.create({ + organizationId: process.env.POLAR_ORGANIZATION_ID, + name: product.name, + description: product.description, + prices: Object.entries(product.prices).map(([interval, amount]) => ({ + amountType: product.amountType, + priceAmount: amount.usd, + recurringInterval: interval, + })), + }); + const monthPrice = polarProduct.prices.find( + (price) => + price.type === "recurring" && + price.recurringInterval === INTERVALS.MONTH + ); + const yearPrice = polarProduct.prices.find( + (price) => + price.type === "recurring" && + price.recurringInterval === INTERVALS.YEAR + ); + + await ctx.runMutation(internal.init.insertSeedPlan, { + polarProductId: polarProduct.id, + key: product.key as PlanKey, + name: product.name, + description: product.description, + prices: { + ...(!monthPrice + ? {} + : { + month: { + usd: { + polarId: monthPrice?.id, + amount: + monthPrice.amountType === "fixed" + ? monthPrice.priceAmount + : 0, + }, + }, + }), + ...(!yearPrice + ? {} + : { + year: { + usd: { + polarId: yearPrice?.id, + amount: + yearPrice.amountType === "fixed" + ? yearPrice.priceAmount + : 0, + }, + }, + }), + }, + }); + }); + + console.info("📦 Polar Products have been successfully created."); + }, +}); + +export { seedProductsAction as seedProducts }; diff --git a/src/component/lib.ts b/src/component/lib.ts new file mode 100644 index 0000000..9b2e562 --- /dev/null +++ b/src/component/lib.ts @@ -0,0 +1,275 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { Polar } from "@polar-sh/sdk"; +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { + action, + internalAction, + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import schema from "./schema"; + +const createCheckout = async ({ + polarAccessToken, + customerEmail, + productPriceId, + successUrl, + subscriptionId, +}: { + polarAccessToken: string; + customerEmail: string; + productPriceId: string; + successUrl: string; + subscriptionId?: string; +}) => { + const polar = new Polar({ + server: "sandbox", + accessToken: polarAccessToken, + }); + const result = await polar.checkouts.create({ + productPriceId, + successUrl, + customerEmail, + subscriptionId, + }); + return result; +}; + +export const getPlanByKey = internalQuery({ + args: { + key: schema.tables.plans.validator.fields.key, + }, + handler: async (ctx, args) => { + return ctx.db + .query("plans") + .withIndex("key", (q) => q.eq("key", args.key)) + .unique(); + }, +}); + +export const getUserSubscription = internalQuery({ + args: { + userId: v.string(), + }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("users") + .withIndex("userId", (q) => q.eq("userId", args.userId)) + .unique(); + if (!user) { + throw new Error("User not found"); + } + const subscription = await ctx.db + .query("subscriptions") + .withIndex("userId", (q) => q.eq("userId", user._id)) + .unique(); + if (!subscription) { + return null; + } + const plan = await ctx.db.get(subscription.planId); + if (!plan) { + throw new Error("Plan not found"); + } + return { + ...subscription, + plan, + }; + }, +}); + +export const getOnboardingCheckoutUrl = action({ + args: { + polarAccessToken: v.string(), + successUrl: v.string(), + userId: v.string(), + userEmail: v.string(), + }, + handler: async (ctx, args) => { + const product = await ctx.runQuery(internal.lib.getPlanByKey, { + key: "free", + }); + const price = product?.prices.month?.usd; + if (!price) { + throw new Error("Price not found"); + } + const checkout = await createCheckout({ + polarAccessToken: args.polarAccessToken, + customerEmail: args.userEmail, + productPriceId: price.polarId, + successUrl: args.successUrl, + }); + return checkout.url; + }, +}); + +export const getProOnboardingCheckoutUrl = internalAction({ + args: { + interval: schema.tables.subscriptions.validator.fields.interval, + polarAccessToken: v.string(), + successUrl: v.string(), + userId: v.string(), + userEmail: v.string(), + }, + handler: async (ctx, args) => { + const product = await ctx.runQuery(internal.lib.getPlanByKey, { + key: "pro", + }); + const price = + args.interval === "month" + ? product?.prices.month?.usd + : product?.prices.year?.usd; + if (!price) { + throw new Error("Price not found"); + } + const subscription = await ctx.runQuery(internal.lib.getUserSubscription, { + userId: args.userId, + }); + const checkout = await createCheckout({ + polarAccessToken: args.polarAccessToken, + customerEmail: args.userEmail, + productPriceId: price.polarId, + successUrl: args.successUrl, + subscriptionId: subscription?.polarId, + }); + return checkout.url; + }, +}); + +export const listPlans = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User not found"); + } + const plans = await ctx.db.query("plans").collect(); + return plans.sort((a, b) => a.key.localeCompare(b.key)); + }, +}); + +export const getsertUser = internalMutation({ + args: { + polarId: v.string(), + email: v.string(), + }, + handler: async (ctx, args) => { + const getUser = () => + ctx.db + .query("users") + .withIndex("polarId", (q) => q.eq("polarId", args.polarId)) + .unique(); + const existingUser = await getUser(); + if (!existingUser) { + await ctx.db.insert("users", { + email: args.email, + polarId: args.polarId, + }); + } + const user = existingUser || (await getUser()); + if (!user) { + throw new Error("User not found"); + } + const subscription = await ctx.db + .query("subscriptions") + .withIndex("userId", (q) => q.eq("userId", user._id)) + .unique(); + if (!subscription) { + return user; + } + const plan = await ctx.db.get(subscription.planId); + if (!plan) { + throw new Error("Plan not found"); + } + return { + ...user, + subscription, + plan, + }; + }, +}); + +export const replaceSubscription = internalMutation({ + args: { + userId: v.id("users"), + subscriptionPolarId: v.string(), + input: v.object({ + currency: schema.tables.subscriptions.validator.fields.currency, + productId: v.string(), + priceId: v.string(), + interval: schema.tables.subscriptions.validator.fields.interval, + status: v.string(), + currentPeriodStart: v.number(), + currentPeriodEnd: v.optional(v.number()), + cancelAtPeriodEnd: v.optional(v.boolean()), + }), + }, + handler: async (ctx, args) => { + const subscription = await ctx.db + .query("subscriptions") + .withIndex("userId", (q) => q.eq("userId", args.userId)) + .unique(); + if (subscription) { + await ctx.db.delete(subscription._id); + } + const plan = await ctx.db + .query("plans") + .withIndex("polarProductId", (q) => + q.eq("polarProductId", args.input.productId) + ) + .unique(); + if (!plan) { + throw new Error("Plan not found"); + } + await ctx.db.insert("subscriptions", { + userId: args.userId, + planId: plan._id, + polarId: args.subscriptionPolarId, + polarPriceId: args.input.priceId, + interval: args.input.interval, + status: args.input.status, + currency: args.input.currency, + currentPeriodStart: args.input.currentPeriodStart, + currentPeriodEnd: args.input.currentPeriodEnd, + cancelAtPeriodEnd: args.input.cancelAtPeriodEnd, + }); + const user = await ctx.db.get(args.userId); + if (!user?.polarSubscriptionPendingId) { + return; + } + await ctx.scheduler.cancel(user.polarSubscriptionPendingId); + await ctx.db.patch(args.userId, { + polarSubscriptionPendingId: undefined, + }); + }, +}); + +export const setSubscriptionPending = mutation({ + handler: async (ctx, _args) => { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User not found"); + } + const scheduledFunctionId = await ctx.scheduler.runAfter( + 1000 * 120, + internal.lib.unsetSubscriptionPending, + { userId } + ); + await ctx.db.patch(userId, { + polarSubscriptionPendingId: scheduledFunctionId, + }); + }, +}); + +export const unsetSubscriptionPending = internalMutation({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { + polarSubscriptionPendingId: undefined, + }); + }, +}); diff --git a/src/component/polar.ts b/src/component/polar.ts new file mode 100644 index 0000000..70a8a9b --- /dev/null +++ b/src/component/polar.ts @@ -0,0 +1,4 @@ +import { Polar } from "@convex-dev/polar"; +import { components } from "./_generated/api"; + +export const polar = new Polar(components.polar); diff --git a/src/component/schema.ts b/src/component/schema.ts new file mode 100644 index 0000000..6931f4f --- /dev/null +++ b/src/component/schema.ts @@ -0,0 +1,77 @@ +import { defineSchema, defineTable } from "convex/server"; +import { Infer, v } from "convex/values"; + +export const CURRENCIES = { + USD: "usd", + EUR: "eur", +} as const; +export const currencyValidator = v.union( + v.literal(CURRENCIES.USD), + v.literal(CURRENCIES.EUR) +); + +export const INTERVALS = { + MONTH: "month", + YEAR: "year", +} as const; +export const intervalValidator = v.union( + v.literal(INTERVALS.MONTH), + v.literal(INTERVALS.YEAR) +); + +const priceValidator = v.object({ + polarId: v.string(), + amount: v.number(), +}); +const pricesValidator = v.object({ + [CURRENCIES.USD]: v.optional(priceValidator), + [CURRENCIES.EUR]: v.optional(priceValidator), +}); + +export const PLANS = { + FREE: "free", + PRO: "pro", +} as const; +export const planKeyValidator = v.union( + v.literal(PLANS.FREE), + v.literal(PLANS.PRO) +); + +export type PlanKey = Infer; + +export default defineSchema({ + users: defineTable({ + userId: v.optional(v.string()), + polarId: v.string(), + email: v.string(), + polarSubscriptionPendingId: v.optional(v.id("_scheduled_functions")), + }) + .index("userId", ["userId"]) + .index("polarId", ["polarId"]), + plans: defineTable({ + key: planKeyValidator, + polarProductId: v.string(), + name: v.string(), + description: v.string(), + prices: v.object({ + [INTERVALS.MONTH]: v.optional(pricesValidator), + [INTERVALS.YEAR]: v.optional(pricesValidator), + }), + }) + .index("key", ["key"]) + .index("polarProductId", ["polarProductId"]), + subscriptions: defineTable({ + planId: v.id("plans"), + polarId: v.string(), + polarPriceId: v.string(), + currency: currencyValidator, + interval: intervalValidator, + status: v.string(), + currentPeriodStart: v.optional(v.number()), + currentPeriodEnd: v.optional(v.number()), + cancelAtPeriodEnd: v.optional(v.boolean()), + userId: v.id("users"), + }) + .index("userId", ["userId"]) + .index("polarId", ["polarId"]), +}); diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..bf4dec2 --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,8 @@ +// This is where React components go. +if (typeof window === "undefined") { + throw new Error("this is frontend code, but it's running somewhere else!"); +} + +export function subtract(a: number, b: number): number { + return a - b; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..deec8fe --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "strict": true, + "jsx": "react-jsx", + + "target": "ESNext", + "lib": ["ES2021", "dom", "DOM.Iterable"], + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "module": "ESNext", + "moduleResolution": "Bundler", + + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "skipLibCheck": true + }, + "include": ["./src/**/*"] +}