├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── apps ├── api │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── drizzle.config.ts │ ├── eslint.config.mjs │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ └── .gitkeep │ ├── src │ │ ├── app.ts │ │ ├── db │ │ │ ├── index.ts │ │ │ ├── migrations │ │ │ │ ├── 0000_aromatic_master_mold.sql │ │ │ │ ├── 0001_melodic_micromax.sql │ │ │ │ ├── 0002_short_thunderball.sql │ │ │ │ └── meta │ │ │ │ │ ├── 0000_snapshot.json │ │ │ │ │ ├── 0001_snapshot.json │ │ │ │ │ ├── 0002_snapshot.json │ │ │ │ │ └── _journal.json │ │ │ └── schema │ │ │ │ ├── auth.ts │ │ │ │ └── index.ts │ │ ├── lib │ │ │ ├── configure-open-api.ts │ │ │ ├── constants.ts │ │ │ ├── create-app.ts │ │ │ ├── create-auth-config.ts │ │ │ ├── create-router.ts │ │ │ └── types.ts │ │ └── routes │ │ │ ├── index.route.ts │ │ │ ├── index.ts │ │ │ └── tasks │ │ │ ├── tasks.handlers.ts │ │ │ ├── tasks.index.ts │ │ │ ├── tasks.routes.ts │ │ │ └── tasks.test.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ └── wrangler.toml └── web │ ├── .gitignore │ ├── README.md │ ├── eslint.config.mjs │ ├── index.html │ ├── package.json │ ├── public │ ├── .gitkeep │ └── vite.svg │ ├── src │ ├── app.tsx │ ├── components │ │ ├── app-navbar.tsx │ │ ├── route-error.tsx │ │ └── route-pending.tsx │ ├── index.css │ ├── lib │ │ ├── api-client.ts │ │ ├── date-formatter.ts │ │ ├── format-api-error.ts │ │ ├── queries.ts │ │ └── query-client.ts │ ├── main.tsx │ ├── route-tree.gen.ts │ ├── routes │ │ ├── ~__root.tsx │ │ ├── ~index.tsx │ │ └── ~task │ │ │ ├── components │ │ │ ├── form.tsx │ │ │ ├── list.tsx │ │ │ └── task.tsx │ │ │ └── ~$id │ │ │ ├── ~edit.tsx │ │ │ └── ~index.tsx │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── eslint.config.mjs ├── package.json ├── packages ├── api-client │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json └── eslint-config │ ├── create-config.d.ts │ ├── create-config.js │ ├── eslint.config.js │ └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silent the stylistic rules in you IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "style/*", "severity": "off", "fixable": true }, 15 | { "rule": "format/*", "severity": "off", "fixable": true }, 16 | { "rule": "*-indent", "severity": "off", "fixable": true }, 17 | { "rule": "*-spacing", "severity": "off", "fixable": true }, 18 | { "rule": "*-spaces", "severity": "off", "fixable": true }, 19 | { "rule": "*-order", "severity": "off", "fixable": true }, 20 | { "rule": "*-dangle", "severity": "off", "fixable": true }, 21 | { "rule": "*-newline", "severity": "off", "fixable": true }, 22 | { "rule": "*quotes", "severity": "off", "fixable": true }, 23 | { "rule": "*semi", "severity": "off", "fixable": true } 24 | ], 25 | 26 | // Enable eslint for all supported languages 27 | "eslint.validate": [ 28 | "javascript", 29 | "javascriptreact", 30 | "typescript", 31 | "typescriptreact", 32 | "vue", 33 | "html", 34 | "markdown", 35 | "json", 36 | "jsonc", 37 | "yaml", 38 | "toml", 39 | "xml", 40 | "gql", 41 | "graphql", 42 | "astro", 43 | "css", 44 | "less", 45 | "scss", 46 | "pcss", 47 | "postcss" 48 | ], 49 | "cSpell.words": [ 50 | "Hono", 51 | "openapi" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hono + React / Vite + Cloudflare + pnpm workspaces monorepo 2 | 3 | A monorepo setup using pnpm workspaces with a Hono API and React / vite client deployed to Cloudflare Workers / Static Assets / D1. 4 | 5 | Features: 6 | 7 | - Run tasks in parallel across apps / packages with pnpm 8 | - Hono API [proxied with vite](./apps/web/vite.config.ts) during development 9 | - Hono [RPC client](packages/api-client/src/index.ts) built during development for faster inference 10 | - Shared Zod validators with drizzle-zod 11 | - Shared eslint config 12 | - Shared tsconfig 13 | 14 | Tech Stack: 15 | 16 | - api 17 | - hono 18 | - hono openapi 19 | - authjs 20 | - stoker 21 | - drizzle 22 | - drizzle-zod 23 | - web 24 | - react 25 | - vite 26 | - react-hook-form 27 | - tanstack router 28 | - dev tooling 29 | - typescript 30 | - eslint with `@antfu/eslint-config` 31 | 32 | Tour: 33 | 34 | - Base [tsconfig.json](./tsconfig.json) with default settings lives in the root 35 | - Shared packages live in [/packages] directory 36 | - Base [eslint.config.js](./packages/eslint-config/eslint.config.js) with default settings 37 | - Applications live in [/apps] directory 38 | - Use any cli to create new apps in here 39 | - If cloning a git repo in here be sure to delete the `.git` folder so it is not treated as a submodule 40 | 41 | > All pnpm commands are run from the root of the repo. 42 | 43 | ## Local Setup 44 | 45 | ### Install dependencies 46 | 47 | ```sh 48 | pnpm i 49 | ``` 50 | 51 | ### Create / Update Cloudflare D1 Database id 52 | 53 | ```sh 54 | pnpm dlx wrangler d1 create replace-with-your-database-name-here 55 | ``` 56 | 57 | * Update `database_name` and `database_id` in [apps/api/wrangler.toml](./apps/api/wrangler.toml) with the output from wrangler. 58 | * Update the `database_name` to match in the npm tasks [here](https://github.com/w3cj/monorepo-example-tasks-app/blob/main/apps/api/package.json#L18) 59 | 60 | ### Run DB migrations locally 61 | 62 | ```sh 63 | pnpm run -r db:migrate:local 64 | ``` 65 | 66 | ### Start Apps 67 | 68 | ```sh 69 | pnpm run dev 70 | ``` 71 | 72 | Visit [http://localhost:5173](http://localhost:5173) 73 | 74 | All requests to `/api` will be proxied to the hono server running on [http://localhost:8787](http://localhost:8787) 75 | 76 | ## Production Setup 77 | 78 | ### Run DB migrations on Cloudflare D1 79 | 80 | ```sh 81 | pnpm run -r db:migrate:remote 82 | ``` 83 | 84 | ### Deploy 85 | 86 | ```sh 87 | pnpm run deploy 88 | ``` 89 | 90 | ## Tasks 91 | 92 | ### Lint 93 | 94 | ```sh 95 | pnpm run lint 96 | ``` 97 | 98 | ### Test 99 | 100 | ```sh 101 | pnpm run test 102 | ``` 103 | 104 | ### Build 105 | 106 | ```sh 107 | pnpm run build 108 | ``` 109 | -------------------------------------------------------------------------------- /apps/api/.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | dev.db* 31 | test.db 32 | .vercel 33 | dist 34 | .dev.vars 35 | public 36 | .wrangler -------------------------------------------------------------------------------- /apps/api/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /apps/api/README.md: -------------------------------------------------------------------------------- 1 | # Hono Open API Starter 2 | 3 | A starter template for building fully documented type-safe JSON APIs with Hono and Open API. 4 | 5 | - [Hono Open API Starter](#hono-open-api-starter) 6 | - [Included](#included) 7 | - [Setup](#setup) 8 | - [Code Tour](#code-tour) 9 | - [Endpoints](#endpoints) 10 | - [References](#references) 11 | 12 | ## Included 13 | 14 | - Structured logging with [pino](https://getpino.io/) / [hono-pino](https://www.npmjs.com/package/hono-pino) 15 | - Documented / type-safe routes with [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 16 | - Interactive API documentation with [scalar](https://scalar.com/#api-docs) / [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/main/packages/hono-api-reference) 17 | - Convenience methods / helpers to reduce boilerplate with [stoker](https://www.npmjs.com/package/stoker) 18 | - Type-safe schemas and environment variables with [zod](https://zod.dev/) 19 | - Single source of truth database schemas with [drizzle](https://orm.drizzle.team/docs/overview) and [drizzle-zod](https://orm.drizzle.team/docs/zod) 20 | - Testing with [vitest](https://vitest.dev/) 21 | - Sensible editor, formatting and linting settings with [@antfu/eslint-config](https://github.com/antfu/eslint-config) 22 | 23 | ## Setup 24 | 25 | Clone this template without git history 26 | 27 | ```sh 28 | npx degit w3cj/hono-open-api-starter my-api 29 | cd my-api 30 | ``` 31 | 32 | Create `.env` file 33 | 34 | ```sh 35 | cp .env.sample .env 36 | ``` 37 | 38 | Create sqlite db / push schema 39 | 40 | ```sh 41 | pnpm drizzle-kit push 42 | ``` 43 | 44 | Install dependencies 45 | 46 | ```sh 47 | pnpm install 48 | ``` 49 | 50 | Run 51 | 52 | ```sh 53 | pnpm dev 54 | ``` 55 | 56 | Lint 57 | 58 | ```sh 59 | pnpm lint 60 | ``` 61 | 62 | Test 63 | 64 | ```sh 65 | pnpm test 66 | ``` 67 | 68 | ## Code Tour 69 | 70 | Base hono app exported from [app.ts](./src/app.ts). Local development uses [@hono/node-server](https://hono.dev/docs/getting-started/nodejs) defined in [index.ts](./src/index.ts) - update this file or create a new entry point to use your preferred runtime. 71 | 72 | Typesafe env defined in [env.ts](./src/env.ts) - add any other required environment variables here. The application will not start if any required environment variables are missing 73 | 74 | See [src/routes/tasks](./src/routes/tasks/) for an example Open API group. Copy this folder / use as an example for your route groups. 75 | 76 | - Router created in [tasks.index.ts](./src/routes/tasks/tasks.index.ts) 77 | - Route definitions defined in [tasks.routes.ts](./src/routes/tasks/tasks.routes.ts) 78 | - Hono request handlers defined in [tasks.handlers.ts](./src/routes/tasks/tasks.handlers.ts) 79 | - Group unit tests defined in [tasks.test.ts](./src/routes/tasks/tasks.test.ts) 80 | 81 | All app routes are grouped together and exported into single type as `AppType` in [app.ts](./src/app.ts) for use in [RPC / hono/client](https://hono.dev/docs/guides/rpc). 82 | 83 | ## Endpoints 84 | 85 | | Path | Description | 86 | | ------------------ | ------------------------ | 87 | | GET /doc | Open API Specification | 88 | | GET /reference | Scalar API Documentation | 89 | | GET /tasks | List all tasks | 90 | | POST /tasks | Create a task | 91 | | GET /tasks/{id} | Get one task by id | 92 | | PATCH /tasks/{id} | Patch one task by id | 93 | | DELETE /tasks/{id} | Delete one task by id | 94 | 95 | ## References 96 | 97 | - [What is Open API?](https://swagger.io/docs/specification/v3_0/about/) 98 | - [Hono](https://hono.dev/) 99 | - [Zod OpenAPI Example](https://hono.dev/examples/zod-openapi) 100 | - [Testing](https://hono.dev/docs/guides/testing) 101 | - [Testing Helper](https://hono.dev/docs/helpers/testing) 102 | - [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 103 | - [Scalar Documentation](https://github.com/scalar/scalar/tree/main/?tab=readme-ov-file#documentation) 104 | - [Themes / Layout](https://github.com/scalar/scalar/blob/main/documentation/themes.md) 105 | - [Configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) 106 | -------------------------------------------------------------------------------- /apps/api/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | // only used to create migrations 4 | // we use wrangler to apply migrations (see package.json) 5 | export default defineConfig({ 6 | out: "./src/db/migrations", 7 | schema: "./src/db/schema/index.ts", 8 | dialect: "sqlite", 9 | }); 10 | -------------------------------------------------------------------------------- /apps/api/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import createConfig from "@tasks-app/eslint-config/create-config"; 2 | import drizzle from "eslint-plugin-drizzle"; 3 | 4 | export default createConfig({ 5 | ignores: ["src/db/migrations/*", "public/*"], 6 | plugins: { drizzle }, 7 | rules: { 8 | ...drizzle.configs.recommended.rules, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tasks-app/api", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": true, 6 | "exports": { 7 | "./routes": "./src/routes/index.ts", 8 | "./schema": "./src/db/schema/index.ts" 9 | }, 10 | "scripts": { 11 | "dev": "wrangler dev", 12 | "deploy": "wrangler deploy --minify", 13 | "typecheck": "tsc --noEmit", 14 | "lint": "eslint .", 15 | "lint:fix": "npm run lint --fix", 16 | "test": "cross-env NODE_ENV=test vitest", 17 | "db:generate": "drizzle-kit generate", 18 | "db:migrate:local": "wrangler d1 migrations apply tasks-app --local", 19 | "db:migrate:remote": "wrangler d1 migrations apply tasks-app --remote" 20 | }, 21 | "dependencies": { 22 | "@auth/core": "^0.37.4", 23 | "@auth/drizzle-adapter": "^1.7.4", 24 | "@hono/auth-js": "^1.0.15", 25 | "@hono/node-server": "^1.13.7", 26 | "@hono/zod-openapi": "^0.18.3", 27 | "@libsql/client": "^0.14.0", 28 | "@logtape/logtape": "^0.8.0", 29 | "@scalar/hono-api-reference": "^0.5.164", 30 | "@tasks-app/eslint-config": "workspace:^", 31 | "drizzle-orm": "^0.38.2", 32 | "drizzle-zod": "^0.6.0", 33 | "hono": "^4.6.13", 34 | "nodemailer": "^6.9.16", 35 | "stoker": "^1.4.2", 36 | "worker-mailer": "^1.0.1", 37 | "zod": "^3.24.1" 38 | }, 39 | "devDependencies": { 40 | "@cloudflare/vitest-pool-workers": "^0.5.36", 41 | "@cloudflare/workers-types": "^4.20241205.0", 42 | "@types/node": "^22.10.2", 43 | "cross-env": "^7.0.3", 44 | "drizzle-kit": "^0.30.1", 45 | "eslint": "^9.17.0", 46 | "eslint-plugin-drizzle": "^0.2.3", 47 | "eslint-plugin-format": "^0.1.3", 48 | "tsc-alias": "^1.8.10", 49 | "tsx": "^4.19.2", 50 | "turso": "^0.1.0", 51 | "typescript": "^5.7.2", 52 | "vitest": "^2.1.8", 53 | "wrangler": "^3.95.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/api/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3cj/monorepo-example-tasks-app/57aa9c1662132ce7073ccbf49e46139d26c00cec/apps/api/public/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/app.ts: -------------------------------------------------------------------------------- 1 | import createApp from "@/api/lib/create-app"; 2 | import { registerRoutes } from "@/api/routes"; 3 | 4 | import configureOpenAPI from "./lib/configure-open-api"; 5 | 6 | const app = registerRoutes(createApp()); 7 | configureOpenAPI(app); 8 | 9 | export default app; 10 | -------------------------------------------------------------------------------- /apps/api/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/d1"; 2 | 3 | import type { AppEnv } from "../lib/types"; 4 | 5 | import * as schema from "./schema"; 6 | 7 | export function createDb(env: AppEnv["Bindings"]) { 8 | return drizzle(env.DB, { 9 | schema, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/src/db/migrations/0000_aromatic_master_mold.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tasks` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `name` text NOT NULL, 4 | `done` integer DEFAULT false NOT NULL, 5 | `createdAt` integer, 6 | `updatedAt` integer 7 | ); 8 | -------------------------------------------------------------------------------- /apps/api/src/db/migrations/0001_melodic_micromax.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `account` ( 2 | `userId` text NOT NULL, 3 | `type` text NOT NULL, 4 | `provider` text NOT NULL, 5 | `providerAccountId` text NOT NULL, 6 | `refresh_token` text, 7 | `access_token` text, 8 | `expires_at` integer, 9 | `token_type` text, 10 | `scope` text, 11 | `id_token` text, 12 | `session_state` text, 13 | PRIMARY KEY(`provider`, `providerAccountId`), 14 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 15 | ); 16 | --> statement-breakpoint 17 | CREATE TABLE `authenticator` ( 18 | `credentialID` text NOT NULL, 19 | `userId` text NOT NULL, 20 | `providerAccountId` text NOT NULL, 21 | `credentialPublicKey` text NOT NULL, 22 | `counter` integer NOT NULL, 23 | `credentialDeviceType` text NOT NULL, 24 | `credentialBackedUp` integer NOT NULL, 25 | `transports` text, 26 | PRIMARY KEY(`userId`, `credentialID`), 27 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 28 | ); 29 | --> statement-breakpoint 30 | CREATE UNIQUE INDEX `authenticator_credentialID_unique` ON `authenticator` (`credentialID`);--> statement-breakpoint 31 | CREATE TABLE `session` ( 32 | `sessionToken` text PRIMARY KEY NOT NULL, 33 | `userId` text NOT NULL, 34 | `expires` integer NOT NULL, 35 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 36 | ); 37 | --> statement-breakpoint 38 | CREATE TABLE `user` ( 39 | `id` text PRIMARY KEY NOT NULL, 40 | `name` text, 41 | `email` text, 42 | `emailVerified` integer, 43 | `image` text 44 | ); 45 | --> statement-breakpoint 46 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint 47 | CREATE TABLE `verificationToken` ( 48 | `identifier` text NOT NULL, 49 | `token` text NOT NULL, 50 | `expires` integer NOT NULL, 51 | PRIMARY KEY(`identifier`, `token`) 52 | ); 53 | -------------------------------------------------------------------------------- /apps/api/src/db/migrations/0002_short_thunderball.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 | CREATE TABLE `__new_tasks` ( 3 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | `name` text NOT NULL, 5 | `done` integer DEFAULT false NOT NULL, 6 | `createdAt` integer NOT NULL, 7 | `updatedAt` integer NOT NULL 8 | ); 9 | --> statement-breakpoint 10 | INSERT INTO `__new_tasks`("id", "name", "done", "createdAt", "updatedAt") SELECT "id", "name", "done", "createdAt", "updatedAt" FROM `tasks`;--> statement-breakpoint 11 | DROP TABLE `tasks`;--> statement-breakpoint 12 | ALTER TABLE `__new_tasks` RENAME TO `tasks`;--> statement-breakpoint 13 | PRAGMA foreign_keys=ON; -------------------------------------------------------------------------------- /apps/api/src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "13a52c6e-b245-4f99-ae64-f78e13b12062", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "createdAt": { 33 | "name": "createdAt", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "updatedAt": { 40 | "name": "updatedAt", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {}, 51 | "checkConstraints": {} 52 | } 53 | }, 54 | "views": {}, 55 | "enums": {}, 56 | "_meta": { 57 | "schemas": {}, 58 | "tables": {}, 59 | "columns": {} 60 | }, 61 | "internal": { 62 | "indexes": {} 63 | } 64 | } -------------------------------------------------------------------------------- /apps/api/src/db/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "072d000f-94da-4903-ac87-ae4f6de52b26", 5 | "prevId": "13a52c6e-b245-4f99-ae64-f78e13b12062", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "createdAt": { 33 | "name": "createdAt", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "updatedAt": { 40 | "name": "updatedAt", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {}, 51 | "checkConstraints": {} 52 | }, 53 | "account": { 54 | "name": "account", 55 | "columns": { 56 | "userId": { 57 | "name": "userId", 58 | "type": "text", 59 | "primaryKey": false, 60 | "notNull": true, 61 | "autoincrement": false 62 | }, 63 | "type": { 64 | "name": "type", 65 | "type": "text", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "autoincrement": false 69 | }, 70 | "provider": { 71 | "name": "provider", 72 | "type": "text", 73 | "primaryKey": false, 74 | "notNull": true, 75 | "autoincrement": false 76 | }, 77 | "providerAccountId": { 78 | "name": "providerAccountId", 79 | "type": "text", 80 | "primaryKey": false, 81 | "notNull": true, 82 | "autoincrement": false 83 | }, 84 | "refresh_token": { 85 | "name": "refresh_token", 86 | "type": "text", 87 | "primaryKey": false, 88 | "notNull": false, 89 | "autoincrement": false 90 | }, 91 | "access_token": { 92 | "name": "access_token", 93 | "type": "text", 94 | "primaryKey": false, 95 | "notNull": false, 96 | "autoincrement": false 97 | }, 98 | "expires_at": { 99 | "name": "expires_at", 100 | "type": "integer", 101 | "primaryKey": false, 102 | "notNull": false, 103 | "autoincrement": false 104 | }, 105 | "token_type": { 106 | "name": "token_type", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": false, 110 | "autoincrement": false 111 | }, 112 | "scope": { 113 | "name": "scope", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": false, 117 | "autoincrement": false 118 | }, 119 | "id_token": { 120 | "name": "id_token", 121 | "type": "text", 122 | "primaryKey": false, 123 | "notNull": false, 124 | "autoincrement": false 125 | }, 126 | "session_state": { 127 | "name": "session_state", 128 | "type": "text", 129 | "primaryKey": false, 130 | "notNull": false, 131 | "autoincrement": false 132 | } 133 | }, 134 | "indexes": {}, 135 | "foreignKeys": { 136 | "account_userId_user_id_fk": { 137 | "name": "account_userId_user_id_fk", 138 | "tableFrom": "account", 139 | "tableTo": "user", 140 | "columnsFrom": [ 141 | "userId" 142 | ], 143 | "columnsTo": [ 144 | "id" 145 | ], 146 | "onDelete": "cascade", 147 | "onUpdate": "no action" 148 | } 149 | }, 150 | "compositePrimaryKeys": { 151 | "account_provider_providerAccountId_pk": { 152 | "columns": [ 153 | "provider", 154 | "providerAccountId" 155 | ], 156 | "name": "account_provider_providerAccountId_pk" 157 | } 158 | }, 159 | "uniqueConstraints": {}, 160 | "checkConstraints": {} 161 | }, 162 | "authenticator": { 163 | "name": "authenticator", 164 | "columns": { 165 | "credentialID": { 166 | "name": "credentialID", 167 | "type": "text", 168 | "primaryKey": false, 169 | "notNull": true, 170 | "autoincrement": false 171 | }, 172 | "userId": { 173 | "name": "userId", 174 | "type": "text", 175 | "primaryKey": false, 176 | "notNull": true, 177 | "autoincrement": false 178 | }, 179 | "providerAccountId": { 180 | "name": "providerAccountId", 181 | "type": "text", 182 | "primaryKey": false, 183 | "notNull": true, 184 | "autoincrement": false 185 | }, 186 | "credentialPublicKey": { 187 | "name": "credentialPublicKey", 188 | "type": "text", 189 | "primaryKey": false, 190 | "notNull": true, 191 | "autoincrement": false 192 | }, 193 | "counter": { 194 | "name": "counter", 195 | "type": "integer", 196 | "primaryKey": false, 197 | "notNull": true, 198 | "autoincrement": false 199 | }, 200 | "credentialDeviceType": { 201 | "name": "credentialDeviceType", 202 | "type": "text", 203 | "primaryKey": false, 204 | "notNull": true, 205 | "autoincrement": false 206 | }, 207 | "credentialBackedUp": { 208 | "name": "credentialBackedUp", 209 | "type": "integer", 210 | "primaryKey": false, 211 | "notNull": true, 212 | "autoincrement": false 213 | }, 214 | "transports": { 215 | "name": "transports", 216 | "type": "text", 217 | "primaryKey": false, 218 | "notNull": false, 219 | "autoincrement": false 220 | } 221 | }, 222 | "indexes": { 223 | "authenticator_credentialID_unique": { 224 | "name": "authenticator_credentialID_unique", 225 | "columns": [ 226 | "credentialID" 227 | ], 228 | "isUnique": true 229 | } 230 | }, 231 | "foreignKeys": { 232 | "authenticator_userId_user_id_fk": { 233 | "name": "authenticator_userId_user_id_fk", 234 | "tableFrom": "authenticator", 235 | "tableTo": "user", 236 | "columnsFrom": [ 237 | "userId" 238 | ], 239 | "columnsTo": [ 240 | "id" 241 | ], 242 | "onDelete": "cascade", 243 | "onUpdate": "no action" 244 | } 245 | }, 246 | "compositePrimaryKeys": { 247 | "authenticator_userId_credentialID_pk": { 248 | "columns": [ 249 | "userId", 250 | "credentialID" 251 | ], 252 | "name": "authenticator_userId_credentialID_pk" 253 | } 254 | }, 255 | "uniqueConstraints": {}, 256 | "checkConstraints": {} 257 | }, 258 | "session": { 259 | "name": "session", 260 | "columns": { 261 | "sessionToken": { 262 | "name": "sessionToken", 263 | "type": "text", 264 | "primaryKey": true, 265 | "notNull": true, 266 | "autoincrement": false 267 | }, 268 | "userId": { 269 | "name": "userId", 270 | "type": "text", 271 | "primaryKey": false, 272 | "notNull": true, 273 | "autoincrement": false 274 | }, 275 | "expires": { 276 | "name": "expires", 277 | "type": "integer", 278 | "primaryKey": false, 279 | "notNull": true, 280 | "autoincrement": false 281 | } 282 | }, 283 | "indexes": {}, 284 | "foreignKeys": { 285 | "session_userId_user_id_fk": { 286 | "name": "session_userId_user_id_fk", 287 | "tableFrom": "session", 288 | "tableTo": "user", 289 | "columnsFrom": [ 290 | "userId" 291 | ], 292 | "columnsTo": [ 293 | "id" 294 | ], 295 | "onDelete": "cascade", 296 | "onUpdate": "no action" 297 | } 298 | }, 299 | "compositePrimaryKeys": {}, 300 | "uniqueConstraints": {}, 301 | "checkConstraints": {} 302 | }, 303 | "user": { 304 | "name": "user", 305 | "columns": { 306 | "id": { 307 | "name": "id", 308 | "type": "text", 309 | "primaryKey": true, 310 | "notNull": true, 311 | "autoincrement": false 312 | }, 313 | "name": { 314 | "name": "name", 315 | "type": "text", 316 | "primaryKey": false, 317 | "notNull": false, 318 | "autoincrement": false 319 | }, 320 | "email": { 321 | "name": "email", 322 | "type": "text", 323 | "primaryKey": false, 324 | "notNull": false, 325 | "autoincrement": false 326 | }, 327 | "emailVerified": { 328 | "name": "emailVerified", 329 | "type": "integer", 330 | "primaryKey": false, 331 | "notNull": false, 332 | "autoincrement": false 333 | }, 334 | "image": { 335 | "name": "image", 336 | "type": "text", 337 | "primaryKey": false, 338 | "notNull": false, 339 | "autoincrement": false 340 | } 341 | }, 342 | "indexes": { 343 | "user_email_unique": { 344 | "name": "user_email_unique", 345 | "columns": [ 346 | "email" 347 | ], 348 | "isUnique": true 349 | } 350 | }, 351 | "foreignKeys": {}, 352 | "compositePrimaryKeys": {}, 353 | "uniqueConstraints": {}, 354 | "checkConstraints": {} 355 | }, 356 | "verificationToken": { 357 | "name": "verificationToken", 358 | "columns": { 359 | "identifier": { 360 | "name": "identifier", 361 | "type": "text", 362 | "primaryKey": false, 363 | "notNull": true, 364 | "autoincrement": false 365 | }, 366 | "token": { 367 | "name": "token", 368 | "type": "text", 369 | "primaryKey": false, 370 | "notNull": true, 371 | "autoincrement": false 372 | }, 373 | "expires": { 374 | "name": "expires", 375 | "type": "integer", 376 | "primaryKey": false, 377 | "notNull": true, 378 | "autoincrement": false 379 | } 380 | }, 381 | "indexes": {}, 382 | "foreignKeys": {}, 383 | "compositePrimaryKeys": { 384 | "verificationToken_identifier_token_pk": { 385 | "columns": [ 386 | "identifier", 387 | "token" 388 | ], 389 | "name": "verificationToken_identifier_token_pk" 390 | } 391 | }, 392 | "uniqueConstraints": {}, 393 | "checkConstraints": {} 394 | } 395 | }, 396 | "views": {}, 397 | "enums": {}, 398 | "_meta": { 399 | "schemas": {}, 400 | "tables": {}, 401 | "columns": {} 402 | }, 403 | "internal": { 404 | "indexes": {} 405 | } 406 | } -------------------------------------------------------------------------------- /apps/api/src/db/migrations/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "0665b220-1036-4d3b-9081-ce84cb5753fa", 5 | "prevId": "072d000f-94da-4903-ac87-ae4f6de52b26", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "createdAt": { 33 | "name": "createdAt", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": true, 37 | "autoincrement": false 38 | }, 39 | "updatedAt": { 40 | "name": "updatedAt", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": true, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {}, 51 | "checkConstraints": {} 52 | }, 53 | "account": { 54 | "name": "account", 55 | "columns": { 56 | "userId": { 57 | "name": "userId", 58 | "type": "text", 59 | "primaryKey": false, 60 | "notNull": true, 61 | "autoincrement": false 62 | }, 63 | "type": { 64 | "name": "type", 65 | "type": "text", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "autoincrement": false 69 | }, 70 | "provider": { 71 | "name": "provider", 72 | "type": "text", 73 | "primaryKey": false, 74 | "notNull": true, 75 | "autoincrement": false 76 | }, 77 | "providerAccountId": { 78 | "name": "providerAccountId", 79 | "type": "text", 80 | "primaryKey": false, 81 | "notNull": true, 82 | "autoincrement": false 83 | }, 84 | "refresh_token": { 85 | "name": "refresh_token", 86 | "type": "text", 87 | "primaryKey": false, 88 | "notNull": false, 89 | "autoincrement": false 90 | }, 91 | "access_token": { 92 | "name": "access_token", 93 | "type": "text", 94 | "primaryKey": false, 95 | "notNull": false, 96 | "autoincrement": false 97 | }, 98 | "expires_at": { 99 | "name": "expires_at", 100 | "type": "integer", 101 | "primaryKey": false, 102 | "notNull": false, 103 | "autoincrement": false 104 | }, 105 | "token_type": { 106 | "name": "token_type", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": false, 110 | "autoincrement": false 111 | }, 112 | "scope": { 113 | "name": "scope", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": false, 117 | "autoincrement": false 118 | }, 119 | "id_token": { 120 | "name": "id_token", 121 | "type": "text", 122 | "primaryKey": false, 123 | "notNull": false, 124 | "autoincrement": false 125 | }, 126 | "session_state": { 127 | "name": "session_state", 128 | "type": "text", 129 | "primaryKey": false, 130 | "notNull": false, 131 | "autoincrement": false 132 | } 133 | }, 134 | "indexes": {}, 135 | "foreignKeys": { 136 | "account_userId_user_id_fk": { 137 | "name": "account_userId_user_id_fk", 138 | "tableFrom": "account", 139 | "tableTo": "user", 140 | "columnsFrom": [ 141 | "userId" 142 | ], 143 | "columnsTo": [ 144 | "id" 145 | ], 146 | "onDelete": "cascade", 147 | "onUpdate": "no action" 148 | } 149 | }, 150 | "compositePrimaryKeys": { 151 | "account_provider_providerAccountId_pk": { 152 | "columns": [ 153 | "provider", 154 | "providerAccountId" 155 | ], 156 | "name": "account_provider_providerAccountId_pk" 157 | } 158 | }, 159 | "uniqueConstraints": {}, 160 | "checkConstraints": {} 161 | }, 162 | "authenticator": { 163 | "name": "authenticator", 164 | "columns": { 165 | "credentialID": { 166 | "name": "credentialID", 167 | "type": "text", 168 | "primaryKey": false, 169 | "notNull": true, 170 | "autoincrement": false 171 | }, 172 | "userId": { 173 | "name": "userId", 174 | "type": "text", 175 | "primaryKey": false, 176 | "notNull": true, 177 | "autoincrement": false 178 | }, 179 | "providerAccountId": { 180 | "name": "providerAccountId", 181 | "type": "text", 182 | "primaryKey": false, 183 | "notNull": true, 184 | "autoincrement": false 185 | }, 186 | "credentialPublicKey": { 187 | "name": "credentialPublicKey", 188 | "type": "text", 189 | "primaryKey": false, 190 | "notNull": true, 191 | "autoincrement": false 192 | }, 193 | "counter": { 194 | "name": "counter", 195 | "type": "integer", 196 | "primaryKey": false, 197 | "notNull": true, 198 | "autoincrement": false 199 | }, 200 | "credentialDeviceType": { 201 | "name": "credentialDeviceType", 202 | "type": "text", 203 | "primaryKey": false, 204 | "notNull": true, 205 | "autoincrement": false 206 | }, 207 | "credentialBackedUp": { 208 | "name": "credentialBackedUp", 209 | "type": "integer", 210 | "primaryKey": false, 211 | "notNull": true, 212 | "autoincrement": false 213 | }, 214 | "transports": { 215 | "name": "transports", 216 | "type": "text", 217 | "primaryKey": false, 218 | "notNull": false, 219 | "autoincrement": false 220 | } 221 | }, 222 | "indexes": { 223 | "authenticator_credentialID_unique": { 224 | "name": "authenticator_credentialID_unique", 225 | "columns": [ 226 | "credentialID" 227 | ], 228 | "isUnique": true 229 | } 230 | }, 231 | "foreignKeys": { 232 | "authenticator_userId_user_id_fk": { 233 | "name": "authenticator_userId_user_id_fk", 234 | "tableFrom": "authenticator", 235 | "tableTo": "user", 236 | "columnsFrom": [ 237 | "userId" 238 | ], 239 | "columnsTo": [ 240 | "id" 241 | ], 242 | "onDelete": "cascade", 243 | "onUpdate": "no action" 244 | } 245 | }, 246 | "compositePrimaryKeys": { 247 | "authenticator_userId_credentialID_pk": { 248 | "columns": [ 249 | "userId", 250 | "credentialID" 251 | ], 252 | "name": "authenticator_userId_credentialID_pk" 253 | } 254 | }, 255 | "uniqueConstraints": {}, 256 | "checkConstraints": {} 257 | }, 258 | "session": { 259 | "name": "session", 260 | "columns": { 261 | "sessionToken": { 262 | "name": "sessionToken", 263 | "type": "text", 264 | "primaryKey": true, 265 | "notNull": true, 266 | "autoincrement": false 267 | }, 268 | "userId": { 269 | "name": "userId", 270 | "type": "text", 271 | "primaryKey": false, 272 | "notNull": true, 273 | "autoincrement": false 274 | }, 275 | "expires": { 276 | "name": "expires", 277 | "type": "integer", 278 | "primaryKey": false, 279 | "notNull": true, 280 | "autoincrement": false 281 | } 282 | }, 283 | "indexes": {}, 284 | "foreignKeys": { 285 | "session_userId_user_id_fk": { 286 | "name": "session_userId_user_id_fk", 287 | "tableFrom": "session", 288 | "tableTo": "user", 289 | "columnsFrom": [ 290 | "userId" 291 | ], 292 | "columnsTo": [ 293 | "id" 294 | ], 295 | "onDelete": "cascade", 296 | "onUpdate": "no action" 297 | } 298 | }, 299 | "compositePrimaryKeys": {}, 300 | "uniqueConstraints": {}, 301 | "checkConstraints": {} 302 | }, 303 | "user": { 304 | "name": "user", 305 | "columns": { 306 | "id": { 307 | "name": "id", 308 | "type": "text", 309 | "primaryKey": true, 310 | "notNull": true, 311 | "autoincrement": false 312 | }, 313 | "name": { 314 | "name": "name", 315 | "type": "text", 316 | "primaryKey": false, 317 | "notNull": false, 318 | "autoincrement": false 319 | }, 320 | "email": { 321 | "name": "email", 322 | "type": "text", 323 | "primaryKey": false, 324 | "notNull": false, 325 | "autoincrement": false 326 | }, 327 | "emailVerified": { 328 | "name": "emailVerified", 329 | "type": "integer", 330 | "primaryKey": false, 331 | "notNull": false, 332 | "autoincrement": false 333 | }, 334 | "image": { 335 | "name": "image", 336 | "type": "text", 337 | "primaryKey": false, 338 | "notNull": false, 339 | "autoincrement": false 340 | } 341 | }, 342 | "indexes": { 343 | "user_email_unique": { 344 | "name": "user_email_unique", 345 | "columns": [ 346 | "email" 347 | ], 348 | "isUnique": true 349 | } 350 | }, 351 | "foreignKeys": {}, 352 | "compositePrimaryKeys": {}, 353 | "uniqueConstraints": {}, 354 | "checkConstraints": {} 355 | }, 356 | "verificationToken": { 357 | "name": "verificationToken", 358 | "columns": { 359 | "identifier": { 360 | "name": "identifier", 361 | "type": "text", 362 | "primaryKey": false, 363 | "notNull": true, 364 | "autoincrement": false 365 | }, 366 | "token": { 367 | "name": "token", 368 | "type": "text", 369 | "primaryKey": false, 370 | "notNull": true, 371 | "autoincrement": false 372 | }, 373 | "expires": { 374 | "name": "expires", 375 | "type": "integer", 376 | "primaryKey": false, 377 | "notNull": true, 378 | "autoincrement": false 379 | } 380 | }, 381 | "indexes": {}, 382 | "foreignKeys": {}, 383 | "compositePrimaryKeys": { 384 | "verificationToken_identifier_token_pk": { 385 | "columns": [ 386 | "identifier", 387 | "token" 388 | ], 389 | "name": "verificationToken_identifier_token_pk" 390 | } 391 | }, 392 | "uniqueConstraints": {}, 393 | "checkConstraints": {} 394 | } 395 | }, 396 | "views": {}, 397 | "enums": {}, 398 | "_meta": { 399 | "schemas": {}, 400 | "tables": {}, 401 | "columns": {} 402 | }, 403 | "internal": { 404 | "indexes": {} 405 | } 406 | } -------------------------------------------------------------------------------- /apps/api/src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1734144838050, 9 | "tag": "0000_aromatic_master_mold", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1734212594123, 16 | "tag": "0001_melodic_micromax", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1734386826416, 23 | "tag": "0002_short_thunderball", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /apps/api/src/db/schema/auth.ts: -------------------------------------------------------------------------------- 1 | import type { AdapterAccountType } from "@auth/core/adapters"; 2 | 3 | import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; 4 | 5 | export const users = sqliteTable("user", { 6 | id: text() 7 | .primaryKey() 8 | .$defaultFn(() => crypto.randomUUID()), 9 | name: text(), 10 | email: text().unique(), 11 | emailVerified: integer({ mode: "timestamp_ms" }), 12 | image: text(), 13 | }); 14 | 15 | export const accounts = sqliteTable( 16 | "account", 17 | { 18 | userId: text("userId") 19 | .notNull() 20 | .references(() => users.id, { onDelete: "cascade" }), 21 | type: text("type").$type().notNull(), 22 | provider: text("provider").notNull(), 23 | providerAccountId: text("providerAccountId").notNull(), 24 | refresh_token: text("refresh_token"), 25 | access_token: text("access_token"), 26 | expires_at: integer("expires_at"), 27 | token_type: text("token_type"), 28 | scope: text("scope"), 29 | id_token: text("id_token"), 30 | session_state: text("session_state"), 31 | }, 32 | account => ({ 33 | compoundKey: primaryKey({ 34 | columns: [account.provider, account.providerAccountId], 35 | }), 36 | }), 37 | ); 38 | 39 | export const sessions = sqliteTable("session", { 40 | sessionToken: text("sessionToken").primaryKey(), 41 | userId: text("userId") 42 | .notNull() 43 | .references(() => users.id, { onDelete: "cascade" }), 44 | expires: integer("expires", { mode: "timestamp_ms" }).notNull(), 45 | }); 46 | 47 | export const verificationTokens = sqliteTable( 48 | "verificationToken", 49 | { 50 | identifier: text("identifier").notNull(), 51 | token: text("token").notNull(), 52 | expires: integer("expires", { mode: "timestamp_ms" }).notNull(), 53 | }, 54 | verificationToken => ({ 55 | compositePk: primaryKey({ 56 | columns: [verificationToken.identifier, verificationToken.token], 57 | }), 58 | }), 59 | ); 60 | 61 | export const authenticators = sqliteTable( 62 | "authenticator", 63 | { 64 | credentialID: text("credentialID").notNull().unique(), 65 | userId: text("userId") 66 | .notNull() 67 | .references(() => users.id, { onDelete: "cascade" }), 68 | providerAccountId: text("providerAccountId").notNull(), 69 | credentialPublicKey: text("credentialPublicKey").notNull(), 70 | counter: integer("counter").notNull(), 71 | credentialDeviceType: text("credentialDeviceType").notNull(), 72 | credentialBackedUp: integer("credentialBackedUp", { 73 | mode: "boolean", 74 | }).notNull(), 75 | transports: text("transports"), 76 | }, 77 | authenticator => ({ 78 | compositePK: primaryKey({ 79 | columns: [authenticator.userId, authenticator.credentialID], 80 | }), 81 | }), 82 | ); 83 | -------------------------------------------------------------------------------- /apps/api/src/db/schema/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/no-redeclare */ 2 | import type { z } from "zod"; 3 | 4 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 5 | import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 6 | 7 | export * from "./auth"; 8 | 9 | export const tasks = sqliteTable("tasks", { 10 | id: integer({ mode: "number" }) 11 | .primaryKey({ autoIncrement: true }), 12 | name: text() 13 | .notNull(), 14 | done: integer({ mode: "boolean" }) 15 | .notNull() 16 | .default(false), 17 | createdAt: integer() 18 | .notNull() 19 | .$defaultFn(() => Date.now()), 20 | updatedAt: integer() 21 | .notNull() 22 | .$defaultFn(() => Date.now()) 23 | .$onUpdate(() => Date.now()), 24 | }); 25 | 26 | export const selectTasksSchema = createSelectSchema(tasks); 27 | export type selectTasksSchema = z.infer; 28 | 29 | export const insertTasksSchema = createInsertSchema( 30 | tasks, 31 | { 32 | name: schema => schema.min(1).max(500), 33 | }, 34 | ).required({ 35 | done: true, 36 | }).omit({ 37 | id: true, 38 | createdAt: true, 39 | updatedAt: true, 40 | }); 41 | export type insertTasksSchema = z.infer; 42 | 43 | export const patchTasksSchema = insertTasksSchema.partial(); 44 | export type patchTasksSchema = z.infer; 45 | -------------------------------------------------------------------------------- /apps/api/src/lib/configure-open-api.ts: -------------------------------------------------------------------------------- 1 | import { apiReference } from "@scalar/hono-api-reference"; 2 | 3 | import type { AppOpenAPI } from "./types"; 4 | 5 | import packageJSON from "../../package.json"; 6 | import { BASE_PATH } from "./constants"; 7 | 8 | export default function configureOpenAPI(app: AppOpenAPI) { 9 | app.doc("/doc", { 10 | openapi: "3.0.0", 11 | info: { 12 | version: packageJSON.version, 13 | title: "Tasks API", 14 | }, 15 | }); 16 | 17 | app.get( 18 | "/reference", 19 | apiReference({ 20 | theme: "kepler", 21 | layout: "classic", 22 | defaultHttpClient: { 23 | targetKey: "javascript", 24 | clientKey: "fetch", 25 | }, 26 | spec: { 27 | url: `${BASE_PATH}/doc`, 28 | }, 29 | }), 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 2 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 3 | 4 | export const BASE_PATH = "/api" as const; 5 | 6 | export const ZOD_ERROR_MESSAGES = { 7 | REQUIRED: "Required", 8 | EXPECTED_NUMBER: "Expected number, received nan", 9 | NO_UPDATES: "No updates provided", 10 | }; 11 | 12 | export const ZOD_ERROR_CODES = { 13 | INVALID_UPDATES: "invalid_updates", 14 | }; 15 | 16 | export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND); 17 | -------------------------------------------------------------------------------- /apps/api/src/lib/create-app.ts: -------------------------------------------------------------------------------- 1 | import { authHandler } from "@hono/auth-js"; 2 | import { notFound, onError } from "stoker/middlewares"; 3 | 4 | import type { AppOpenAPI } from "./types"; 5 | 6 | import { BASE_PATH } from "./constants"; 7 | import createAuthConfig from "./create-auth-config"; 8 | import createRouter from "./create-router"; 9 | 10 | export default function createApp() { 11 | const app = createRouter() 12 | .use("*", (c, next) => { 13 | if (c.req.path.startsWith(BASE_PATH)) { 14 | return next(); 15 | } 16 | // SPA redirect to /index.html 17 | const requestUrl = new URL(c.req.raw.url); 18 | return c.env.ASSETS.fetch(new URL("/index.html", requestUrl.origin)); 19 | }) 20 | .basePath(BASE_PATH) as AppOpenAPI; 21 | 22 | app 23 | .use( 24 | "*", 25 | async (c, next) => { 26 | c.set("authConfig", createAuthConfig(c.env)); 27 | return next(); 28 | }, 29 | ) 30 | .use("/auth/*", authHandler()) 31 | .notFound(notFound) 32 | .onError(onError); 33 | 34 | return app; 35 | } 36 | 37 | export function createTestApp(router: R) { 38 | return createApp().route("/", router); 39 | } 40 | -------------------------------------------------------------------------------- /apps/api/src/lib/create-auth-config.ts: -------------------------------------------------------------------------------- 1 | import type { AuthConfig } from "@hono/auth-js"; 2 | 3 | import GitHub from "@auth/core/providers/github"; 4 | import { DrizzleAdapter } from "@auth/drizzle-adapter"; 5 | import { drizzle } from "drizzle-orm/d1"; 6 | 7 | import type { AppEnv } from "./types"; 8 | 9 | export default function createAuthConfig(env: AppEnv["Bindings"]): AuthConfig { 10 | return { 11 | adapter: DrizzleAdapter(drizzle(env.DB)), 12 | secret: env.AUTH_SECRET, 13 | providers: [ 14 | GitHub({ 15 | clientId: env.GITHUB_CLIENT_ID, 16 | clientSecret: env.GITHUB_CLIENT_SECRET, 17 | }), 18 | ], 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /apps/api/src/lib/create-router.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { defaultHook } from "stoker/openapi"; 3 | 4 | import type { AppEnv } from "./types"; 5 | 6 | export default function createRouter() { 7 | return new OpenAPIHono({ 8 | strict: false, 9 | defaultHook, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"; 2 | 3 | import type { BASE_PATH } from "./constants"; 4 | 5 | export type AppEnv = { 6 | Bindings: { 7 | AUTH_SECRET: string; 8 | GITHUB_CLIENT_ID: string; 9 | GITHUB_CLIENT_SECRET: string; 10 | ASSETS: Fetcher; 11 | DB: D1Database; 12 | }; 13 | }; 14 | 15 | // eslint-disable-next-line ts/no-empty-object-type 16 | export type AppOpenAPI = OpenAPIHono; 17 | 18 | export type AppRouteHandler = RouteHandler; 19 | -------------------------------------------------------------------------------- /apps/api/src/routes/index.route.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent } from "stoker/openapi/helpers"; 4 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 5 | 6 | import createRouter from "@/api/lib/create-router"; 7 | 8 | const router = createRouter() 9 | .openapi( 10 | createRoute({ 11 | tags: ["Index"], 12 | method: "get", 13 | path: "/", 14 | responses: { 15 | [HttpStatusCodes.OK]: jsonContent( 16 | createMessageObjectSchema("Tasks API"), 17 | "Tasks API", 18 | ), 19 | }, 20 | }), 21 | (c) => { 22 | return c.json({ 23 | message: "Tasks API", 24 | }, HttpStatusCodes.OK); 25 | }, 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /apps/api/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/no-redeclare */ 2 | import createRouter from "@/api/lib/create-router"; 3 | 4 | import type { AppOpenAPI } from "../lib/types"; 5 | 6 | import { BASE_PATH } from "../lib/constants"; 7 | import index from "./index.route"; 8 | import tasks from "./tasks/tasks.index"; 9 | 10 | export function registerRoutes(app: AppOpenAPI) { 11 | return app 12 | .route("/", index) 13 | .route("/", tasks); 14 | } 15 | 16 | // stand alone router type used for api client 17 | export const router = registerRoutes( 18 | createRouter().basePath(BASE_PATH), 19 | ); 20 | export type router = typeof router; 21 | -------------------------------------------------------------------------------- /apps/api/src/routes/tasks/tasks.handlers.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 4 | 5 | import type { AppRouteHandler } from "@/api/lib/types"; 6 | 7 | import { createDb } from "@/api/db"; 8 | import { tasks } from "@/api/db/schema"; 9 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/api/lib/constants"; 10 | 11 | import type { CreateRoute, GetOneRoute, ListRoute, PatchRoute, RemoveRoute } from "./tasks.routes"; 12 | 13 | export const list: AppRouteHandler = async (c) => { 14 | const db = createDb(c.env); 15 | const tasks = await db.query.tasks.findMany({ 16 | orderBy(fields, operators) { 17 | return operators.desc(fields.createdAt); 18 | }, 19 | }); 20 | return c.json(tasks); 21 | }; 22 | 23 | export const create: AppRouteHandler = async (c) => { 24 | const db = createDb(c.env); 25 | const task = c.req.valid("json"); 26 | const [inserted] = await db.insert(tasks).values(task).returning(); 27 | return c.json(inserted, HttpStatusCodes.OK); 28 | }; 29 | 30 | export const getOne: AppRouteHandler = async (c) => { 31 | const db = createDb(c.env); 32 | const { id } = c.req.valid("param"); 33 | const task = await db.query.tasks.findFirst({ 34 | where(fields, operators) { 35 | return operators.eq(fields.id, id); 36 | }, 37 | }); 38 | 39 | if (!task) { 40 | return c.json( 41 | { 42 | message: HttpStatusPhrases.NOT_FOUND, 43 | }, 44 | HttpStatusCodes.NOT_FOUND, 45 | ); 46 | } 47 | 48 | return c.json(task, HttpStatusCodes.OK); 49 | }; 50 | 51 | export const patch: AppRouteHandler = async (c) => { 52 | const db = createDb(c.env); 53 | const { id } = c.req.valid("param"); 54 | const updates = c.req.valid("json"); 55 | 56 | if (Object.keys(updates).length === 0) { 57 | return c.json( 58 | { 59 | success: false, 60 | error: { 61 | issues: [ 62 | { 63 | code: ZOD_ERROR_CODES.INVALID_UPDATES, 64 | path: [], 65 | message: ZOD_ERROR_MESSAGES.NO_UPDATES, 66 | }, 67 | ], 68 | name: "ZodError", 69 | }, 70 | }, 71 | HttpStatusCodes.UNPROCESSABLE_ENTITY, 72 | ); 73 | } 74 | 75 | const [task] = await db.update(tasks) 76 | .set(updates) 77 | .where(eq(tasks.id, id)) 78 | .returning(); 79 | 80 | if (!task) { 81 | return c.json( 82 | { 83 | message: HttpStatusPhrases.NOT_FOUND, 84 | }, 85 | HttpStatusCodes.NOT_FOUND, 86 | ); 87 | } 88 | 89 | return c.json(task, HttpStatusCodes.OK); 90 | }; 91 | 92 | export const remove: AppRouteHandler = async (c) => { 93 | const db = createDb(c.env); 94 | const { id } = c.req.valid("param"); 95 | const result: D1Response = await db.delete(tasks) 96 | .where(eq(tasks.id, id)); 97 | 98 | if (result.meta.changes === 0) { 99 | return c.json( 100 | { 101 | message: HttpStatusPhrases.NOT_FOUND, 102 | }, 103 | HttpStatusCodes.NOT_FOUND, 104 | ); 105 | } 106 | 107 | return c.body(null, HttpStatusCodes.NO_CONTENT); 108 | }; 109 | -------------------------------------------------------------------------------- /apps/api/src/routes/tasks/tasks.index.ts: -------------------------------------------------------------------------------- 1 | import createRouter from "@/api/lib/create-router"; 2 | 3 | import * as handlers from "./tasks.handlers"; 4 | import * as routes from "./tasks.routes"; 5 | 6 | const router = createRouter() 7 | .openapi(routes.list, handlers.list) 8 | .openapi(routes.create, handlers.create) 9 | .openapi(routes.getOne, handlers.getOne) 10 | .openapi(routes.patch, handlers.patch) 11 | .openapi(routes.remove, handlers.remove); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /apps/api/src/routes/tasks/tasks.routes.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent, jsonContentRequired } from "stoker/openapi/helpers"; 4 | import { createErrorSchema, IdParamsSchema } from "stoker/openapi/schemas"; 5 | 6 | import { insertTasksSchema, patchTasksSchema, selectTasksSchema } from "@/api/db/schema"; 7 | import { notFoundSchema } from "@/api/lib/constants"; 8 | 9 | const tags = ["Tasks"]; 10 | 11 | export const list = createRoute({ 12 | path: "/tasks", 13 | method: "get", 14 | tags, 15 | responses: { 16 | [HttpStatusCodes.OK]: jsonContent( 17 | z.array(selectTasksSchema), 18 | "The list of tasks", 19 | ), 20 | }, 21 | }); 22 | 23 | export const create = createRoute({ 24 | path: "/tasks", 25 | method: "post", 26 | request: { 27 | body: jsonContentRequired( 28 | insertTasksSchema, 29 | "The task to create", 30 | ), 31 | }, 32 | tags, 33 | responses: { 34 | [HttpStatusCodes.OK]: jsonContent( 35 | selectTasksSchema, 36 | "The created task", 37 | ), 38 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 39 | createErrorSchema(insertTasksSchema), 40 | "The validation error(s)", 41 | ), 42 | }, 43 | }); 44 | 45 | export const getOne = createRoute({ 46 | path: "/tasks/{id}", 47 | method: "get", 48 | request: { 49 | params: IdParamsSchema, 50 | }, 51 | tags, 52 | responses: { 53 | [HttpStatusCodes.OK]: jsonContent( 54 | selectTasksSchema, 55 | "The requested task", 56 | ), 57 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 58 | notFoundSchema, 59 | "Task not found", 60 | ), 61 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 62 | createErrorSchema(IdParamsSchema), 63 | "Invalid id error", 64 | ), 65 | }, 66 | }); 67 | 68 | export const patch = createRoute({ 69 | path: "/tasks/{id}", 70 | method: "patch", 71 | request: { 72 | params: IdParamsSchema, 73 | body: jsonContentRequired( 74 | patchTasksSchema, 75 | "The task updates", 76 | ), 77 | }, 78 | tags, 79 | responses: { 80 | [HttpStatusCodes.OK]: jsonContent( 81 | selectTasksSchema, 82 | "The updated task", 83 | ), 84 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 85 | notFoundSchema, 86 | "Task not found", 87 | ), 88 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 89 | createErrorSchema(patchTasksSchema) 90 | .or(createErrorSchema(IdParamsSchema)), 91 | "The validation error(s)", 92 | ), 93 | }, 94 | }); 95 | 96 | export const remove = createRoute({ 97 | path: "/tasks/{id}", 98 | method: "delete", 99 | request: { 100 | params: IdParamsSchema, 101 | }, 102 | tags, 103 | responses: { 104 | [HttpStatusCodes.NO_CONTENT]: { 105 | description: "Task deleted", 106 | }, 107 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 108 | notFoundSchema, 109 | "Task not found", 110 | ), 111 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 112 | createErrorSchema(IdParamsSchema), 113 | "Invalid id error", 114 | ), 115 | }, 116 | }); 117 | 118 | export type ListRoute = typeof list; 119 | export type CreateRoute = typeof create; 120 | export type GetOneRoute = typeof getOne; 121 | export type PatchRoute = typeof patch; 122 | export type RemoveRoute = typeof remove; 123 | -------------------------------------------------------------------------------- /apps/api/src/routes/tasks/tasks.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyD1Migrations, 3 | env, 4 | } from "cloudflare:test"; 5 | import { testClient } from "hono/testing"; 6 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 7 | import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; 8 | import { ZodIssueCode } from "zod"; 9 | 10 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/api/lib/constants"; 11 | import createApp from "@/api/lib/create-app"; 12 | 13 | import router from "./tasks.index"; 14 | 15 | const client = testClient(createApp().route("/", router), env); 16 | 17 | describe("tasks routes", async () => { 18 | beforeAll(async () => { 19 | // @ts-expect-error test 20 | await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); 21 | }); 22 | 23 | it("post /tasks validates the body when creating", async () => { 24 | const response = await client.api.tasks.$post({ 25 | // @ts-expect-error test 26 | json: { 27 | done: false, 28 | }, 29 | }); 30 | expect(response.status).toBe(422); 31 | if (response.status === 422) { 32 | const json = await response.json(); 33 | expect(json.error.issues[0].path[0]).toBe("name"); 34 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.REQUIRED); 35 | } 36 | }); 37 | 38 | const id = 1; 39 | const name = "Learn vitest"; 40 | 41 | it("post /tasks creates a task", async () => { 42 | const response = await client.api.tasks.$post({ 43 | json: { 44 | name, 45 | done: false, 46 | }, 47 | }); 48 | expect(response.status).toBe(200); 49 | if (response.status === 200) { 50 | const json = await response.json(); 51 | expect(json.name).toBe(name); 52 | expect(json.done).toBe(false); 53 | } 54 | }); 55 | 56 | it("get /tasks lists all tasks", async () => { 57 | const response = await client.api.tasks.$get(); 58 | expect(response.status).toBe(200); 59 | if (response.status === 200) { 60 | const json = await response.json(); 61 | expectTypeOf(json).toBeArray(); 62 | expect(json.length).toBe(1); 63 | } 64 | }); 65 | 66 | it("get /tasks/{id} validates the id param", async () => { 67 | const response = await client.api.tasks[":id"].$get({ 68 | param: { 69 | // @ts-expect-error test 70 | id: "wat", 71 | }, 72 | }); 73 | expect(response.status).toBe(422); 74 | if (response.status === 422) { 75 | const json = await response.json(); 76 | expect(json.error.issues[0].path[0]).toBe("id"); 77 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 78 | } 79 | }); 80 | 81 | it("get /tasks/{id} returns 404 when task not found", async () => { 82 | const response = await client.api.tasks[":id"].$get({ 83 | param: { 84 | id: 999, 85 | }, 86 | }); 87 | expect(response.status).toBe(404); 88 | if (response.status === 404) { 89 | const json = await response.json(); 90 | expect(json.message).toBe(HttpStatusPhrases.NOT_FOUND); 91 | } 92 | }); 93 | 94 | it("get /tasks/{id} gets a single task", async () => { 95 | const response = await client.api.tasks[":id"].$get({ 96 | param: { 97 | id, 98 | }, 99 | }); 100 | expect(response.status).toBe(200); 101 | if (response.status === 200) { 102 | const json = await response.json(); 103 | expect(json.name).toBe(name); 104 | expect(json.done).toBe(false); 105 | } 106 | }); 107 | 108 | it("patch /tasks/{id} validates the body when updating", async () => { 109 | const response = await client.api.tasks[":id"].$patch({ 110 | param: { 111 | id, 112 | }, 113 | json: { 114 | name: "", 115 | }, 116 | }); 117 | expect(response.status).toBe(422); 118 | if (response.status === 422) { 119 | const json = await response.json(); 120 | expect(json.error.issues[0].path[0]).toBe("name"); 121 | expect(json.error.issues[0].code).toBe(ZodIssueCode.too_small); 122 | } 123 | }); 124 | 125 | it("patch /tasks/{id} validates the id param", async () => { 126 | const response = await client.api.tasks[":id"].$patch({ 127 | param: { 128 | // @ts-expect-error test 129 | id: "wat", 130 | }, 131 | json: {}, 132 | }); 133 | expect(response.status).toBe(422); 134 | if (response.status === 422) { 135 | const json = await response.json(); 136 | expect(json.error.issues[0].path[0]).toBe("id"); 137 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 138 | } 139 | }); 140 | 141 | it("patch /tasks/{id} validates empty body", async () => { 142 | const response = await client.api.tasks[":id"].$patch({ 143 | param: { 144 | id, 145 | }, 146 | json: {}, 147 | }); 148 | expect(response.status).toBe(422); 149 | if (response.status === 422) { 150 | const json = await response.json(); 151 | expect(json.error.issues[0].code).toBe(ZOD_ERROR_CODES.INVALID_UPDATES); 152 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.NO_UPDATES); 153 | } 154 | }); 155 | 156 | it("patch /tasks/{id} updates a single property of a task", async () => { 157 | const response = await client.api.tasks[":id"].$patch({ 158 | param: { 159 | id, 160 | }, 161 | json: { 162 | done: true, 163 | }, 164 | }); 165 | expect(response.status).toBe(200); 166 | if (response.status === 200) { 167 | const json = await response.json(); 168 | expect(json.done).toBe(true); 169 | } 170 | }); 171 | 172 | it("delete /tasks/{id} validates the id when deleting", async () => { 173 | const response = await client.api.tasks[":id"].$delete({ 174 | param: { 175 | // @ts-expect-error test 176 | id: "wat", 177 | }, 178 | }); 179 | expect(response.status).toBe(422); 180 | if (response.status === 422) { 181 | const json = await response.json(); 182 | expect(json.error.issues[0].path[0]).toBe("id"); 183 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 184 | } 185 | }); 186 | 187 | it("delete /tasks/{id} removes a task", async () => { 188 | const response = await client.api.tasks[":id"].$delete({ 189 | param: { 190 | id, 191 | }, 192 | }); 193 | expect(response.status).toBe(204); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx", 6 | "baseUrl": "./", 7 | "paths": { 8 | "@/api/*": ["./src/*"] 9 | }, 10 | "typeRoots": ["./node_modules/@types", "./node_modules/@cloudflare"], 11 | "types": [ 12 | "workers-types/2023-07-01", 13 | "vitest-pool-workers" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig, readD1Migrations } from "@cloudflare/vitest-pool-workers/config"; 2 | import path from "node:path"; 3 | 4 | export default defineWorkersConfig(async () => { 5 | const migrationsPath = path.join(__dirname, "src", "db", "migrations"); 6 | const migrations = await readD1Migrations(migrationsPath); 7 | 8 | return { 9 | test: { 10 | poolOptions: { 11 | workers: { 12 | isolatedStorage: false, 13 | wrangler: { 14 | configPath: "./wrangler.toml", 15 | }, 16 | miniflare: { 17 | bindings: { TEST_MIGRATIONS: migrations }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | resolve: { 23 | alias: { 24 | "@/api": path.resolve(__dirname, "./src"), 25 | }, 26 | }, 27 | }; 28 | }); 29 | -------------------------------------------------------------------------------- /apps/api/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "tasks-app" 2 | main = "src/app.ts" 3 | compatibility_date = "2024-12-05" 4 | compatibility_flags = [ "nodejs_compat" ] 5 | 6 | [assets] 7 | directory = "public" 8 | binding = "ASSETS" 9 | 10 | [[d1_databases]] 11 | binding = "DB" 12 | database_name = "tasks-app" 13 | database_id = "000-000-000-000" 14 | migrations_dir = "src/db/migrations" 15 | 16 | [vars] 17 | LOG_LEVEL = "debug" 18 | GITHUB_CLIENT_ID = "your-client-id-here" 19 | GITHUB_CLIENT_SECRET = "your-client-secret-here" 20 | AUTH_SECRET = "abc123" 21 | AUTH_URL = "http://localhost:5173/api/auth" 22 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ["./tsconfig.node.json", "./tsconfig.app.json"], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }); 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from "eslint-plugin-react"; 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: "18.3" } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs["jsx-runtime"].rules, 48 | }, 49 | }); 50 | ``` 51 | -------------------------------------------------------------------------------- /apps/web/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginQuery from "@tanstack/eslint-plugin-query"; 2 | import createConfig from "@tasks-app/eslint-config/create-config"; 3 | 4 | export default createConfig({ 5 | react: true, 6 | }, { 7 | plugins: { 8 | "@tanstack/query": pluginQuery, 9 | }, 10 | rules: { 11 | "antfu/top-level-function": "off", 12 | "@tanstack/query/exhaustive-deps": "error", 13 | "unicorn/filename-case": ["error", { 14 | case: "kebabCase", 15 | ignore: ["README.md", "~__root.tsx"], 16 | }], 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tasks-app/web", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@auth/core": "^0.37.4", 15 | "@hono/auth-js": "^1.0.15", 16 | "@hookform/resolvers": "^3.9.1", 17 | "@tanstack/react-query": "^5.62.7", 18 | "@tanstack/react-router": "^1.90.0", 19 | "@tasks-app/api": "workspace:^", 20 | "@tasks-app/api-client": "workspace:^", 21 | "@tasks-app/eslint-config": "workspace:^", 22 | "@types/node": "^22.10.2", 23 | "hono": "^4.6.13", 24 | "react": "^19.0.0", 25 | "react-dom": "^19.0.0", 26 | "react-hook-form": "^7.54.1", 27 | "zod": "^3.24.1" 28 | }, 29 | "devDependencies": { 30 | "@eslint-react/eslint-plugin": "^1.19.0", 31 | "@eslint/js": "^9.17.0", 32 | "@tanstack/eslint-plugin-query": "^5.62.1", 33 | "@tanstack/router-devtools": "^1.90.0", 34 | "@tanstack/router-plugin": "^1.87.13", 35 | "@types/react": "^19.0.1", 36 | "@types/react-dom": "^19.0.2", 37 | "@vitejs/plugin-react": "^4.3.4", 38 | "eslint": "^9.17.0", 39 | "eslint-plugin-react-hooks": "^5.1.0", 40 | "eslint-plugin-react-refresh": "^0.4.16", 41 | "globals": "^15.13.0", 42 | "typescript": "~5.7.2", 43 | "typescript-eslint": "^8.18.0", 44 | "vite": "^6.0.3", 45 | "vite-tsconfig-paths": "^5.1.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/web/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3cj/monorepo-example-tasks-app/57aa9c1662132ce7073ccbf49e46139d26c00cec/apps/web/public/.gitkeep -------------------------------------------------------------------------------- /apps/web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "@hono/auth-js/react"; 2 | import { createRouter, RouterProvider } from "@tanstack/react-router"; 3 | 4 | import { routeTree } from "@/web/route-tree.gen"; 5 | 6 | const router = createRouter({ 7 | routeTree, 8 | context: { 9 | session: undefined, 10 | }, 11 | }); 12 | 13 | declare module "@tanstack/react-router" { 14 | // eslint-disable-next-line ts/consistent-type-definitions 15 | interface Register { 16 | router: typeof router; 17 | } 18 | } 19 | 20 | export default function App() { 21 | const session = useSession(); 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/components/app-navbar.tsx: -------------------------------------------------------------------------------- 1 | import { signOut, useSession } from "@hono/auth-js/react"; 2 | import { Link, useLocation } from "@tanstack/react-router"; 3 | 4 | export default function AppNavbar() { 5 | const location = useLocation(); 6 | const session = useSession(); 7 | return ( 8 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/components/route-error.tsx: -------------------------------------------------------------------------------- 1 | export default function RouteError({ error }: { error: Error }) { 2 | return ( 3 |
4 | {error.message} 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/src/components/route-pending.tsx: -------------------------------------------------------------------------------- 1 | export default function RoutePending() { 2 | return ( 3 |
4 | 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/src/index.css: -------------------------------------------------------------------------------- 1 | .error { 2 | color: red; 3 | } 4 | 5 | .buttons { 6 | display: flex; 7 | gap: 0.5rem; 8 | justify-content: flex-end; 9 | } 10 | 11 | a[role="button"] { 12 | margin-bottom: var(--pico-spacing); 13 | } 14 | 15 | .user-avatar { 16 | display: flex; 17 | gap: 0.5rem; 18 | align-items: center; 19 | 20 | img { 21 | border-radius: 50%; 22 | width: 50px; 23 | height: 50px; 24 | } 25 | 26 | p { 27 | display: inline-block; 28 | margin-bottom: 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/src/lib/api-client.ts: -------------------------------------------------------------------------------- 1 | import apiClient from "@tasks-app/api-client"; 2 | 3 | export default apiClient("/"); 4 | -------------------------------------------------------------------------------- /apps/web/src/lib/date-formatter.ts: -------------------------------------------------------------------------------- 1 | export default new Intl.DateTimeFormat(navigator.language, { 2 | month: "short", 3 | day: "numeric", 4 | year: "numeric", 5 | hour: "numeric", 6 | minute: "2-digit", 7 | }); 8 | -------------------------------------------------------------------------------- /apps/web/src/lib/format-api-error.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorSchema } from "@tasks-app/api-client"; 2 | 3 | export default function formatApiError(apiError: ErrorSchema) { 4 | return apiError 5 | .error 6 | .issues 7 | .reduce((all, issue) => `${all + issue.path.join(".")}: ${issue.message}\n`, ""); 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/src/lib/queries.ts: -------------------------------------------------------------------------------- 1 | import type { insertTasksSchema, patchTasksSchema } from "@tasks-app/api/schema"; 2 | 3 | import { queryOptions } from "@tanstack/react-query"; 4 | 5 | import apiClient from "./api-client"; 6 | import formatApiError from "./format-api-error"; 7 | 8 | export const queryKeys = { 9 | LIST_TASKS: { queryKey: ["list-tasks"] }, 10 | LIST_TASK: (id: string) => ({ queryKey: [`list-task-${id}`] }), 11 | }; 12 | 13 | export const tasksQueryOptions = queryOptions({ 14 | ...queryKeys.LIST_TASKS, 15 | queryFn: async () => { 16 | const response = await apiClient.api.tasks.$get(); 17 | return response.json(); 18 | }, 19 | }); 20 | 21 | export const createTaskQueryOptions = (id: string) => queryOptions({ 22 | ...queryKeys.LIST_TASK(id), 23 | queryFn: async () => { 24 | const response = await apiClient.api.tasks[":id"].$get({ 25 | param: { 26 | // @ts-expect-error allow strings for error messages 27 | id, 28 | }, 29 | }); 30 | const json = await response.json(); 31 | if ("message" in json) { 32 | throw new Error(json.message); 33 | } 34 | if ("success" in json) { 35 | const message = formatApiError(json); 36 | throw new Error(message); 37 | } 38 | return json; 39 | }, 40 | }); 41 | 42 | export const createTask = async (task: insertTasksSchema) => { 43 | await new Promise(resolve => setTimeout(resolve, 1000)); 44 | const response = await apiClient.api.tasks.$post({ 45 | json: task, 46 | }); 47 | const json = await response.json(); 48 | if ("success" in json) { 49 | const message = formatApiError(json); 50 | throw new Error(message); 51 | } 52 | return json; 53 | }; 54 | 55 | export const deleteTask = async (id: string) => { 56 | const response = await apiClient.api.tasks[":id"].$delete({ 57 | param: { 58 | // @ts-expect-error allow to show server error 59 | id, 60 | }, 61 | }); 62 | if (response.status !== 204) { 63 | const json = await response.json(); 64 | if ("message" in json) { 65 | throw new Error(json.message); 66 | } 67 | const message = formatApiError(json); 68 | throw new Error(message); 69 | } 70 | }; 71 | 72 | export const updateTask = async ({ id, task }: { id: string; task: patchTasksSchema }) => { 73 | const response = await apiClient.api.tasks[":id"].$patch({ 74 | param: { 75 | // @ts-expect-error allow to show server error 76 | id, 77 | }, 78 | json: task, 79 | }); 80 | if (response.status !== 200) { 81 | const json = await response.json(); 82 | if ("message" in json) { 83 | throw new Error(json.message); 84 | } 85 | const message = formatApiError(json); 86 | throw new Error(message); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /apps/web/src/lib/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export default new QueryClient(); 4 | -------------------------------------------------------------------------------- /apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { SessionProvider } from "@hono/auth-js/react"; 2 | import { QueryClientProvider } from "@tanstack/react-query"; 3 | import { StrictMode } from "react"; 4 | 5 | import "./index.css"; 6 | 7 | import { createRoot } from "react-dom/client"; 8 | 9 | import queryClient from "@/web/lib/query-client"; 10 | 11 | import App from "./app"; 12 | 13 | createRoot(document.getElementById("root")!).render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | , 21 | ); 22 | -------------------------------------------------------------------------------- /apps/web/src/route-tree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/no-unlimited-disable */ 2 | 3 | /* eslint-disable */ 4 | 5 | // This file was automatically generated by TanStack Router. 6 | // You should NOT make any changes in this file as it will be overwritten. 7 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 8 | 9 | // Import Routes 10 | 11 | import { Route as rootRoute } from './routes/~__root' 12 | import { Route as IndexImport } from './routes/~index' 13 | import { Route as TaskIdEditImport } from './routes/~task/~$id/~edit' 14 | import { Route as TaskIdIndexImport } from './routes/~task/~$id/~index' 15 | 16 | // Create/Update Routes 17 | 18 | const IndexRoute = IndexImport.update({ 19 | id: '/', 20 | path: '/', 21 | getParentRoute: () => rootRoute, 22 | } as any) 23 | 24 | const TaskIdEditRoute = TaskIdEditImport.update({ 25 | id: '/task/$id/edit', 26 | path: '/task/$id/edit', 27 | getParentRoute: () => rootRoute, 28 | } as any) 29 | 30 | const TaskIdIndexRoute = TaskIdIndexImport.update({ 31 | id: '/task/$id/', 32 | path: '/task/$id/', 33 | getParentRoute: () => rootRoute, 34 | } as any) 35 | 36 | // Populate the FileRoutesByPath interface 37 | 38 | declare module '@tanstack/react-router' { 39 | interface FileRoutesByPath { 40 | '/': { 41 | id: '/' 42 | path: '/' 43 | fullPath: '/' 44 | preLoaderRoute: typeof IndexImport 45 | parentRoute: typeof rootRoute 46 | } 47 | '/task/$id/': { 48 | id: '/task/$id/' 49 | path: '/task/$id' 50 | fullPath: '/task/$id' 51 | preLoaderRoute: typeof TaskIdIndexImport 52 | parentRoute: typeof rootRoute 53 | } 54 | '/task/$id/edit': { 55 | id: '/task/$id/edit' 56 | path: '/task/$id/edit' 57 | fullPath: '/task/$id/edit' 58 | preLoaderRoute: typeof TaskIdEditImport 59 | parentRoute: typeof rootRoute 60 | } 61 | } 62 | } 63 | 64 | // Create and export the route tree 65 | 66 | export interface FileRoutesByFullPath { 67 | '/': typeof IndexRoute 68 | '/task/$id': typeof TaskIdIndexRoute 69 | '/task/$id/edit': typeof TaskIdEditRoute 70 | } 71 | 72 | export interface FileRoutesByTo { 73 | '/': typeof IndexRoute 74 | '/task/$id': typeof TaskIdIndexRoute 75 | '/task/$id/edit': typeof TaskIdEditRoute 76 | } 77 | 78 | export interface FileRoutesById { 79 | __root__: typeof rootRoute 80 | '/': typeof IndexRoute 81 | '/task/$id/': typeof TaskIdIndexRoute 82 | '/task/$id/edit': typeof TaskIdEditRoute 83 | } 84 | 85 | export interface FileRouteTypes { 86 | fileRoutesByFullPath: FileRoutesByFullPath 87 | fullPaths: '/' | '/task/$id' | '/task/$id/edit' 88 | fileRoutesByTo: FileRoutesByTo 89 | to: '/' | '/task/$id' | '/task/$id/edit' 90 | id: '__root__' | '/' | '/task/$id/' | '/task/$id/edit' 91 | fileRoutesById: FileRoutesById 92 | } 93 | 94 | export interface RootRouteChildren { 95 | IndexRoute: typeof IndexRoute 96 | TaskIdIndexRoute: typeof TaskIdIndexRoute 97 | TaskIdEditRoute: typeof TaskIdEditRoute 98 | } 99 | 100 | const rootRouteChildren: RootRouteChildren = { 101 | IndexRoute: IndexRoute, 102 | TaskIdIndexRoute: TaskIdIndexRoute, 103 | TaskIdEditRoute: TaskIdEditRoute, 104 | } 105 | 106 | export const routeTree = rootRoute 107 | ._addFileChildren(rootRouteChildren) 108 | ._addFileTypes() 109 | 110 | /* ROUTE_MANIFEST_START 111 | { 112 | "routes": { 113 | "__root__": { 114 | "filePath": "~__root.tsx", 115 | "children": [ 116 | "/", 117 | "/task/$id/", 118 | "/task/$id/edit" 119 | ] 120 | }, 121 | "/": { 122 | "filePath": "~index.tsx" 123 | }, 124 | "/task/$id/": { 125 | "filePath": "~task/~$id/~index.tsx" 126 | }, 127 | "/task/$id/edit": { 128 | "filePath": "~task/~$id/~edit.tsx" 129 | } 130 | } 131 | } 132 | ROUTE_MANIFEST_END */ 133 | -------------------------------------------------------------------------------- /apps/web/src/routes/~__root.tsx: -------------------------------------------------------------------------------- 1 | import type { SessionContext } from "@hono/auth-js/react"; 2 | 3 | import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; 4 | import { TanStackRouterDevtools } from "@tanstack/router-devtools"; 5 | 6 | import AppNavbar from "../components/app-navbar"; 7 | 8 | type Session = Parameters[0]["value"]; 9 | 10 | export const Route = createRootRouteWithContext<{ 11 | session: Session; 12 | }>()({ 13 | component: () => ( 14 | <> 15 | 16 |
17 | 18 | 19 |
20 | 21 | ), 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web/src/routes/~index.tsx: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from "@tanstack/react-query"; 2 | import { createFileRoute } from "@tanstack/react-router"; 3 | 4 | import RoutePending from "@/web/components/route-pending"; 5 | import { tasksQueryOptions } from "@/web/lib/queries"; 6 | import queryClient from "@/web/lib/query-client"; 7 | import TaskForm from "@/web/routes/~task/components/form"; 8 | import TaskList from "@/web/routes/~task/components/list"; 9 | 10 | export const Route = createFileRoute("/")({ 11 | component: Index, 12 | loader: () => queryClient.ensureQueryData(tasksQueryOptions), 13 | pendingComponent: RoutePending, 14 | }); 15 | 16 | function Index() { 17 | const { 18 | data, 19 | } = useSuspenseQuery(tasksQueryOptions); 20 | return ( 21 |
22 | 23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/src/routes/~task/components/form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { insertTasksSchema } from "@tasks-app/api/schema"; 4 | import { useForm } from "react-hook-form"; 5 | 6 | import { createTask, queryKeys } from "@/web/lib/queries"; 7 | 8 | export default function TaskForm() { 9 | const queryClient = useQueryClient(); 10 | 11 | const { 12 | register, 13 | handleSubmit, 14 | reset, 15 | setFocus, 16 | formState: { errors }, 17 | } = useForm({ 18 | defaultValues: { 19 | name: "", 20 | done: false, 21 | }, 22 | resolver: zodResolver(insertTasksSchema), 23 | }); 24 | 25 | const createMutation = useMutation({ 26 | mutationFn: createTask, 27 | onSuccess: () => { 28 | reset(); 29 | queryClient.invalidateQueries(queryKeys.LIST_TASKS); 30 | }, 31 | onSettled: () => { 32 | setTimeout(() => { 33 | setFocus("name"); 34 | }); 35 | }, 36 | }); 37 | 38 | return ( 39 | <> 40 | {createMutation.error && ( 41 |
42 | {createMutation.error.message} 43 |
44 | )} 45 |
createMutation.mutate(data))} 47 | > 48 | 53 | 54 | 55 |
56 | {createMutation.isPending && } 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/web/src/routes/~task/components/list.tsx: -------------------------------------------------------------------------------- 1 | import type { selectTasksSchema } from "@tasks-app/api/schema"; 2 | 3 | import Task from "./task"; 4 | 5 | export default function TaskList({ tasks }: { tasks: selectTasksSchema[] }) { 6 | return tasks.map(task => ( 7 | 8 | )); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/src/routes/~task/components/task.tsx: -------------------------------------------------------------------------------- 1 | import type { selectTasksSchema } from "@tasks-app/api/schema"; 2 | 3 | import { Link } from "@tanstack/react-router"; 4 | 5 | export default function Task({ task }: { task: selectTasksSchema }) { 6 | return ( 7 |
8 |

11 | {task.name} 12 |

13 |
14 | 15 | View 16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/routes/~task/~$id/~edit.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; 3 | import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 | import { patchTasksSchema } from "@tasks-app/api/schema"; 5 | import { useForm } from "react-hook-form"; 6 | 7 | import RoutePending from "@/web/components/route-pending"; 8 | import { createTaskQueryOptions, deleteTask, queryKeys, updateTask } from "@/web/lib/queries"; 9 | import queryClient from "@/web/lib/query-client"; 10 | 11 | export const Route = createFileRoute("/task/$id/edit")({ 12 | loader: ({ params }) => 13 | queryClient.ensureQueryData(createTaskQueryOptions(params.id)), 14 | component: RouteComponent, 15 | pendingComponent: RoutePending, 16 | }); 17 | 18 | function RouteComponent() { 19 | const { id } = Route.useParams(); 20 | const navigate = useNavigate(); 21 | const { data } = useSuspenseQuery(createTaskQueryOptions(id)); 22 | 23 | const { 24 | register, 25 | handleSubmit, 26 | formState: { errors, isDirty }, 27 | } = useForm({ 28 | defaultValues: data, 29 | resolver: zodResolver(patchTasksSchema), 30 | }); 31 | 32 | const deleteMutation = useMutation({ 33 | mutationFn: deleteTask, 34 | onSuccess: async () => { 35 | await queryClient.invalidateQueries(queryKeys.LIST_TASKS); 36 | navigate({ to: "/" }); 37 | }, 38 | }); 39 | 40 | const updateMutation = useMutation({ 41 | mutationFn: updateTask, 42 | onSuccess: async () => { 43 | await queryClient.invalidateQueries({ 44 | queryKey: [ 45 | ...queryKeys.LIST_TASKS.queryKey, 46 | ...queryKeys.LIST_TASK(id).queryKey, 47 | ], 48 | }); 49 | navigate({ to: "/task/$id", params: { id } }); 50 | }, 51 | }); 52 | 53 | const pending = deleteMutation.isPending || updateMutation.isPending; 54 | const error = deleteMutation.error?.message || updateMutation.error?.message; 55 | 56 | return ( 57 |
58 | {pending && } 59 | {error &&
{error}
} 60 |
updateMutation.mutate({ id, task: data }))}> 61 | 65 |

{errors.name?.message}

66 | 67 |
68 | 72 |

{errors.done?.message}

73 |
74 | 81 |
82 |
83 | 91 | 98 | Cancel 99 | 100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /apps/web/src/routes/~task/~$id/~index.tsx: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from "@tanstack/react-query"; 2 | import { createFileRoute, Link } from "@tanstack/react-router"; 3 | 4 | import RoutePending from "@/web/components/route-pending"; 5 | import dateFormatter from "@/web/lib/date-formatter"; 6 | import { createTaskQueryOptions } from "@/web/lib/queries"; 7 | import queryClient from "@/web/lib/query-client"; 8 | 9 | export const Route = createFileRoute("/task/$id/")({ 10 | loader: ({ params }) => 11 | queryClient.ensureQueryData(createTaskQueryOptions(params.id)), 12 | component: RouteComponent, 13 | pendingComponent: RoutePending, 14 | }); 15 | 16 | function RouteComponent() { 17 | const { id } = Route.useParams(); 18 | const { data } = useSuspenseQuery(createTaskQueryOptions(id)); 19 | 20 | return ( 21 |
22 |

{data.name}

23 |

24 | Done: 25 | {" "} 26 | {data.done ? "✅" : "❌"} 27 |

28 |
29 | 30 | Updated: 31 | {" "} 32 | {dateFormatter.format(new Date(data.updatedAt))} 33 | 34 |
35 | 36 | Created: 37 | {" "} 38 | {dateFormatter.format(new Date(data.createdAt))} 39 | 40 |
41 | 47 | Edit 48 | 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 6 | "jsx": "react-jsx", 7 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 8 | "paths": { 9 | "@/web/*": ["./src/*"] 10 | }, 11 | "allowImportingTsExtensions": true, 12 | "noEmit": true 13 | }, 14 | "include": ["src"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/web/*": ["./src/*"] 5 | } 6 | }, 7 | "references": [ 8 | { "path": "./tsconfig.app.json" }, 9 | { "path": "./tsconfig.node.json" } 10 | ], 11 | "files": [] 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | build: { 9 | outDir: "../api/public", 10 | emptyOutDir: true, 11 | }, 12 | plugins: [ 13 | tsconfigPaths(), 14 | TanStackRouterVite({ 15 | routeFilePrefix: "~", 16 | routeTreeFileHeader: [ 17 | "/* eslint-disable eslint-comments/no-unlimited-disable */", 18 | "/* eslint-disable */", 19 | ], 20 | generatedRouteTree: "./src/route-tree.gen.ts", 21 | 22 | }), 23 | react(), 24 | ], 25 | server: { 26 | proxy: { 27 | "/api": "http://localhost:8787", 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import config from "./packages/eslint-config/eslint.config.js"; 2 | 3 | export default config; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tasks-app", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "pnpm run -r --parallel --aggregate-output dev", 7 | "build": "pnpm run -r build", 8 | "test": "pnpm run -r --parallel test", 9 | "lint": "pnpm run -r --parallel lint", 10 | "deploy": "pnpm build && pnpm run -r deploy" 11 | } 12 | } -------------------------------------------------------------------------------- /packages/api-client/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import config from "@tasks-app/eslint-config"; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /packages/api-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tasks-app/api-client", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "dev": "tsc --watch", 8 | "build": "rm -rf dist && tsc", 9 | "lint": "eslint .", 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@tasks-app/api": "workspace:^", 14 | "@tasks-app/eslint-config": "workspace:^", 15 | "@types/node": "^22.10.2", 16 | "hono": "^4.6.13" 17 | }, 18 | "devDependencies": { 19 | "@cloudflare/workers-types": "^4.20241205.0", 20 | "typescript": "~5.7.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/api-client/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { router } from "@tasks-app/api/routes"; 2 | 3 | import { hc } from "hono/client"; 4 | 5 | // create instance to inline type in build 6 | // https://hono.dev/docs/guides/rpc#compile-your-code-before-using-it-recommended 7 | // eslint-disable-next-line unused-imports/no-unused-vars 8 | const client = hc(""); 9 | export type Client = typeof client; 10 | 11 | export default (...args: Parameters): Client => 12 | hc(...args); 13 | 14 | export type ErrorSchema = { 15 | error: { 16 | issues: { 17 | code: string; 18 | path: (string | number)[]; 19 | message?: string | undefined; 20 | }[]; 21 | name: string; 22 | }; 23 | success: boolean; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/api-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 5 | "rootDir": "src", 6 | "paths": { 7 | "@/api/*": ["../../apps/api/src/*"] 8 | }, 9 | "typeRoots": ["./node_modules/@types", "./node_modules/@cloudflare"], 10 | "types": ["workers-types/2023-07-01", "node"], 11 | "declaration": true, 12 | "declarationMap": true, 13 | "outDir": "dist" 14 | }, 15 | "watchOptions": { 16 | "excludeDirectories": ["**/node_modules", "**/packages/web"] 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/eslint-config/create-config.d.ts: -------------------------------------------------------------------------------- 1 | import type antfu from "@antfu/eslint-config"; 2 | 3 | type AntfuParams = Parameters; 4 | type AntfuReturn = ReturnType; 5 | type Options = AntfuParams[0]; 6 | type UserConfigs = AntfuParams[1][]; 7 | export default function createConfig(options?: Options | undefined, ...userConfigs: UserConfigs): AntfuReturn; 8 | export {}; 9 | -------------------------------------------------------------------------------- /packages/eslint-config/create-config.js: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default function createConfig(options, ...userConfigs) { 4 | return antfu({ 5 | type: "app", 6 | typescript: true, 7 | formatters: true, 8 | stylistic: { 9 | indent: 2, 10 | semi: true, 11 | quotes: "double", 12 | }, 13 | ...options, 14 | }, { 15 | rules: { 16 | "ts/consistent-type-definitions": ["error", "type"], 17 | "no-console": ["warn"], 18 | "antfu/no-top-level-await": ["off"], 19 | "node/prefer-global/process": ["off"], 20 | "node/no-process-env": ["error"], 21 | "perfectionist/sort-imports": ["error", { 22 | tsconfigRootDir: ".", 23 | }], 24 | "unicorn/filename-case": ["error", { 25 | case: "kebabCase", 26 | ignore: ["README.md"], 27 | }], 28 | }, 29 | }, ...userConfigs); 30 | } 31 | -------------------------------------------------------------------------------- /packages/eslint-config/eslint.config.js: -------------------------------------------------------------------------------- 1 | import createConfig from "./create-config.js"; 2 | 3 | export default createConfig(); 4 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tasks-app/eslint-config", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "exports": { 6 | ".": "./eslint.config.js", 7 | "./create-config": "./create-config.js" 8 | }, 9 | "devDependencies": { 10 | "@antfu/eslint-config": "^3.12.0", 11 | "eslint": "^9.17.0", 12 | "eslint-plugin-format": "^0.1.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | - packages/* -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "moduleDetection": "force", 8 | "useDefineForClassFields": true, 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "strict": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "isolatedModules": true, 16 | "skipLibCheck": true, 17 | "noUncheckedSideEffectImports": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------