diff --git a/.github/workflows/api-deployment.yaml b/.github/workflows/api-deployment.yaml index b758132..21aaa31 100644 --- a/.github/workflows/api-deployment.yaml +++ b/.github/workflows/api-deployment.yaml @@ -14,7 +14,7 @@ on: - "!**/README*" concurrency: - group: preview-${{ github.event.pull_request.number }}-${{ github.sha }} + group: api-${{ github.event.pull_request.number }}-${{ github.sha }} cancel-in-progress: false env: diff --git a/.github/workflows/dashboard-deployment.yaml b/.github/workflows/dashboard-deployment.yaml new file mode 100644 index 0000000..2d9ac1f --- /dev/null +++ b/.github/workflows/dashboard-deployment.yaml @@ -0,0 +1,117 @@ +name: Dashboard Deployment + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - main + paths: + - "apps/dashboard/**" + - "packages/**" + - "package.json" + - "pnpm-lock.yaml" + - "!**/*.md" + - "!**/README*" + +concurrency: + group: dashboard-${{ github.event.pull_request.number }}-${{ github.sha }} + cancel-in-progress: false + +env: + STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }} + +jobs: + deploy: + if: ${{ github.event.action != 'closed' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: "10.14.0" + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Deploy + working-directory: apps/dashboard + run: pnpm dlx alchemy deploy --stage ${{ env.STAGE }} + env: + ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} + ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + PULL_REQUEST: ${{ github.event.number }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_ENV: production + + cleanup: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + permissions: + id-token: write + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: "10.14.0" + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Safety Check + run: |- + if [ "${{ env.STAGE }}" = "prod" ]; then + echo "ERROR: Cannot destroy prod environment in cleanup job" + exit 1 + fi + + - name: Destroy Preview Environment + working-directory: apps/dashboard + run: pnpm dlx alchemy destroy --stage ${{ env.STAGE }} + env: + ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} + ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + PULL_REQUEST: ${{ github.event.number }} + NODE_ENV: production diff --git a/.github/workflows/preview-www-deployment.yaml b/.github/workflows/preview-www-deployment.yaml index e4818b6..2c40324 100644 --- a/.github/workflows/preview-www-deployment.yaml +++ b/.github/workflows/preview-www-deployment.yaml @@ -14,7 +14,7 @@ on: - '!**/README*' concurrency: - group: preview-${{ github.event.pull_request.number }}-${{ github.sha }} + group: preview-www-${{ github.event.pull_request.number }}-${{ github.sha }} cancel-in-progress: false jobs: diff --git a/.github/workflows/production-www-deployment.yaml b/.github/workflows/production-www-deployment.yaml index 38956d7..1736b85 100644 --- a/.github/workflows/production-www-deployment.yaml +++ b/.github/workflows/production-www-deployment.yaml @@ -14,7 +14,7 @@ on: - '!**/README*' concurrency: - group: production-main + group: production-www cancel-in-progress: true jobs: diff --git a/.gitignore b/.gitignore index 6113370..b6be739 100644 --- a/.gitignore +++ b/.gitignore @@ -266,3 +266,6 @@ $RECYCLE.BIN/ # Alchemy *.alchemy + +# Tanstack +*.tanstack diff --git a/.vscode/settings.json b/.vscode/settings.json index d301124..44d9f9c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,5 +34,14 @@ }, "[graphql]": { "editor.defaultFormatter": "biomejs.biome" + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + }, + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true } } diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index a595337..5db7d4f 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -1,5 +1,5 @@ import alchemy from "alchemy"; -import { Worker } from "alchemy/cloudflare"; +import { D1Database, KVNamespace, Worker } from "alchemy/cloudflare"; import { GitHubComment } from "alchemy/github"; import { CloudflareStateStore } from "alchemy/state"; @@ -14,12 +14,27 @@ const app = await alchemy("realm-api", { password: process.env.ALCHEMY_PASSWORD, }); +const db = await D1Database("db", { + name: `realm-db-${app.stage}`, + migrationsDir: "./node_modules/@realm/db/migrations", +}); + +const kv = await KVNamespace("kv", { + title: `realm-kv-${app.stage}`, +}); + export const worker = await Worker("api", { - name: "realm-api", + name: `realm-api-${app.stage}`, entrypoint: "./src/index.ts", - url: true, - adopt: true, - bindings: {}, + compatibilityDate: "2026-04-01", + compatibilityFlags: ["nodejs_compat"], + bindings: { + DB: db, + KV: kv, + }, + bundle: { + external: ["bun:sqlite", "@libsql/client"], + }, observability: { enabled: true, logs: { @@ -34,7 +49,7 @@ export const worker = await Worker("api", { persist: true, }, }, - domains: ["api.ahargunyllib.dev"], + domains: app.stage === "prod" ? ["api.ahargunyllib.dev"] : undefined, dev: { port: 3000, }, diff --git a/apps/api/package.json b/apps/api/package.json index 966b3ae..75df209 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,12 @@ "dev": "alchemy dev --app realm-api" }, "dependencies": { + "@hono/trpc-server": "^0.4.2", + "@realm/api": "workspace:*", + "@realm/db": "workspace:*", + "@realm/kv": "workspace:*", + "@realm/logger": "workspace:*", + "@realm/utils": "workspace:*", "alchemy": "catalog:", "hono": "^4.7.9" }, diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts deleted file mode 100644 index 8e49288..0000000 --- a/apps/api/src/env.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { worker } from "../alchemy.run"; - -export type Env = typeof worker.bindings; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ffefdbd..41abb05 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,68 @@ +import { trpcServer } from "@hono/trpc-server"; +import { createContext, trpcRouter } from "@realm/api"; +import type { D1Database } from "@realm/db"; +import type { KVNamespaceType } from "@realm/kv"; +import { createLogger } from "@realm/logger"; +import { createNanoId } from "@realm/utils"; import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; -const app = new Hono(); +const app = new Hono<{ + Bindings: { + DB: D1Database; + KV: KVNamespaceType; + }; +}>(); + +app.use(logger()); +app.use( + "/*", + cors({ + origin: (origin) => { + // TODO: This is a temporary solution to allow CORS for localhost and our deployed domains. We should have a better solution for this in the future. + const allowedOrigins = [ + "localhost", + "ahargunyllib.dev", + "ahargunyllib.workers.dev", + ]; + if ( + allowedOrigins.some((allowedOrigin) => origin.includes(allowedOrigin)) + ) { + return origin; + } + }, + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization", "trpc-accept"], + credentials: true, + }) +); app.get("/", (c) => c.text("Hello World")); app.get("/health", (c) => c.json({ status: "ok" })); -export default app; +app.use( + "/trpc/*", + trpcServer({ + router: trpcRouter, + createContext: (opts, c) => { + const requestId = createNanoId(); + const customLogger = createLogger({ requestId }); + + return createContext({ + env: { + db: c.env.DB, + kv: c.env.KV, + }, + fetchCreateContextFnOptions: opts, + logger: customLogger, + requestId, + waitUntil: c.executionCtx.waitUntil, + }); + }, + }) +); + +export default { + fetch: app.fetch, +}; diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example new file mode 100644 index 0000000..e08e069 --- /dev/null +++ b/apps/dashboard/.env.example @@ -0,0 +1,3 @@ +# openssl rand -base64 32 +ALCHEMY_STATE_TOKEN=your-generated-token-here +ALCHEMY_PASSWORD=your-generated-password-here diff --git a/apps/dashboard/alchemy.run.ts b/apps/dashboard/alchemy.run.ts new file mode 100644 index 0000000..d1a2768 --- /dev/null +++ b/apps/dashboard/alchemy.run.ts @@ -0,0 +1,42 @@ +import alchemy from "alchemy"; +import { Vite } from "alchemy/cloudflare"; +import { GitHubComment } from "alchemy/github"; +import { CloudflareStateStore } from "alchemy/state"; + +const app = await alchemy("realm-dashboard", { + stateStore: + process.env.NODE_ENV === "production" + ? (scope) => + new CloudflareStateStore(scope, { + scriptName: "realm-api-state-store", + }) + : undefined, // Uses default FileSystemStateStore + password: process.env.ALCHEMY_PASSWORD, +}); + +const worker = await Vite("dashboard", { + name: `realm-dashboard-${app.stage}`, + bindings: { + VITE_PUBLIC_API_URL: process.env.API_URL || "http://localhost:3000", + }, + domains: app.stage === "prod" ? ["dash.ahargunyllib.dev"] : undefined, +}); + +if (process.env.PULL_REQUEST) { + // if this is a PR, add a comment to the PR with the preview URL + // it will auto-update with each push + await GitHubComment("preview-comment", { + owner: "ahargunyllib", + repository: "realm", + issueNumber: Number(process.env.PULL_REQUEST), + body: `### Preview Deployment + +**Commit:** \`${process.env.GITHUB_SHA}\` +**Preview URL:** ${worker.url} +**Deployed at:** ${new Date().toUTCString()}`, + }); +} + +console.log(`Dashboard deployed at: ${worker.url}`); + +await app.finalize(); diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 0000000..cb6db35 --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + Realm Dashboard + + + +
+ + + diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..7279a98 --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,40 @@ +{ + "name": "dashboard", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "alchemy dev --app realm-dashboard", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@realm/api": "workspace:*", + "@realm/ui": "workspace:*", + "@realm/utils": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-form": "^1.28.6", + "@tanstack/react-query": "^5.96.1", + "@tanstack/react-router": "^1.168.10", + "@tanstack/react-router-devtools": "^1.166.11", + "@trpc/client": "catalog:", + "@trpc/tanstack-react-query": "^11.16.0", + "alchemy": "catalog:", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + "superjson": "^2.2.6", + "tailwindcss": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "@tanstack/devtools-vite": "^0.6.0", + "@tanstack/router-plugin": "^1.167.12", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "catalog:", + "vite": "^8.0.3", + "vite-tsconfig-paths": "^6.1.1" + } +} diff --git a/apps/dashboard/public/robots.txt b/apps/dashboard/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/apps/dashboard/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/apps/dashboard/src/features/todo/components/create-todo-form.tsx b/apps/dashboard/src/features/todo/components/create-todo-form.tsx new file mode 100644 index 0000000..2edaf7d --- /dev/null +++ b/apps/dashboard/src/features/todo/components/create-todo-form.tsx @@ -0,0 +1,61 @@ +import { Button } from "@realm/ui/components/button"; +import { Field, FieldError, FieldGroup } from "@realm/ui/components/field"; +import { Input } from "@realm/ui/components/input"; +import { LoaderCircleIcon, PlusIcon } from "lucide-react"; +import { useCreateTodoForm } from "../hooks/use-create-todo-form"; + +export function CreateTodoForm() { + const { form } = useCreateTodoForm(); + + return ( +
{ + e.preventDefault(); + form.handleSubmit(e); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + field.handleChange(e.target.value)} + placeholder="New todo" + value={field.state.value} + /> + {isInvalid && } + + ); + }} + + + + {({ isSubmitting }) => ( + + )} + +
+ ); +} diff --git a/apps/dashboard/src/features/todo/components/todo-card.tsx b/apps/dashboard/src/features/todo/components/todo-card.tsx new file mode 100644 index 0000000..b0147e8 --- /dev/null +++ b/apps/dashboard/src/features/todo/components/todo-card.tsx @@ -0,0 +1,87 @@ +import { Button } from "@realm/ui/components/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@realm/ui/components/card"; +import { Checkbox } from "@realm/ui/components/checkbox"; +import { Label } from "@realm/ui/components/label"; +import { LoaderCircleIcon, TrashIcon } from "lucide-react"; +import { useDeleteTodoForm } from "../hooks/use-delete-todo-form"; +import { useIsCompleteToggle } from "../hooks/use-is-complete-toggle"; + +type Props = { + todo: { + id: string; + title: string; + isCompleted: boolean; + createdAt: string; + updatedAt: string; + }; +}; + +export function TodoCard({ todo }: Props) { + const { toggle, isCompleted } = useIsCompleteToggle({ todo }); + const { onDelete, isDeleting } = useDeleteTodoForm({ id: todo.id }); + + const formattedDate = new Date(todo.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + + + + {todo.title} + + + + +
+
+ { + toggle(); + }} + /> + +
+

+ Created: {formattedDate} +

+
+
+ + + +
+ ); +} diff --git a/apps/dashboard/src/features/todo/components/todo-container.tsx b/apps/dashboard/src/features/todo/components/todo-container.tsx new file mode 100644 index 0000000..4d2e025 --- /dev/null +++ b/apps/dashboard/src/features/todo/components/todo-container.tsx @@ -0,0 +1,45 @@ +import { trpc } from "@/shared/lib/trpc"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@realm/ui/components/alert"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@realm/ui/components/empty"; +import { useQuery } from "@tanstack/react-query"; +import { CreateTodoForm } from "./create-todo-form"; +import { TodoCard } from "./todo-card"; + +export function TodoContainer() { + const { data, error } = useQuery(trpc.todoRouter.getAllTodos.queryOptions()); + + return ( +
+

Todo List

+ + {error && ( + + Error + {error.message} + + )} + {data?.length === 0 && ( + + + No todos yet + + Get started by adding a new todo. + + + + )} + {data?.map((todo) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/features/todo/hooks/use-create-todo-form.ts b/apps/dashboard/src/features/todo/hooks/use-create-todo-form.ts new file mode 100644 index 0000000..1726e63 --- /dev/null +++ b/apps/dashboard/src/features/todo/hooks/use-create-todo-form.ts @@ -0,0 +1,45 @@ +import { queryClient } from "@/shared/lib/query-client"; +import { trpc } from "@/shared/lib/trpc"; +import { useForm } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { z } from "zod"; + +const schema = z.object({ + title: z.string().min(1, "Title is required"), +}); + +export const useCreateTodoForm = () => { + const mutation = useMutation(trpc.todoRouter.createTodo.mutationOptions()); + + const form = useForm({ + defaultValues: { + title: "", + }, + validators: { + onSubmit: schema, + onBlur: schema, + onChange: schema, + }, + onSubmit: async ({ value }) => { + await mutation.mutateAsync(value, { + onSuccess: () => { + form.reset(); + queryClient.invalidateQueries({ + queryKey: trpc.todoRouter.getAllTodos.queryKey(), + }); + toast.success("Todo created successfully"); + }, + onError: (error) => { + toast.error("Error creating todo", { + description: error.message, + }); + }, + }); + }, + }); + + return { + form, + }; +}; diff --git a/apps/dashboard/src/features/todo/hooks/use-delete-todo-form.ts b/apps/dashboard/src/features/todo/hooks/use-delete-todo-form.ts new file mode 100644 index 0000000..26c4b12 --- /dev/null +++ b/apps/dashboard/src/features/todo/hooks/use-delete-todo-form.ts @@ -0,0 +1,32 @@ +import { queryClient } from "@/shared/lib/query-client"; +import { trpc } from "@/shared/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +export const useDeleteTodoForm = ({ id }: { id: string }) => { + const mutation = useMutation(trpc.todoRouter.deleteTodo.mutationOptions()); + + const onDelete = () => { + mutation.mutate( + { id }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.todoRouter.getAllTodos.queryKey(), + }); + toast.success("Todo deleted successfully"); + }, + onError: (error) => { + toast.error("Error deleting todo", { + description: error.message, + }); + }, + } + ); + }; + + return { + onDelete, + isDeleting: mutation.isPending, + }; +}; diff --git a/apps/dashboard/src/features/todo/hooks/use-is-complete-toggle.ts b/apps/dashboard/src/features/todo/hooks/use-is-complete-toggle.ts new file mode 100644 index 0000000..0bc2ba0 --- /dev/null +++ b/apps/dashboard/src/features/todo/hooks/use-is-complete-toggle.ts @@ -0,0 +1,72 @@ +import { trpc } from "@/shared/lib/trpc"; +import { tryCatch } from "@realm/utils"; +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { queryClient } from "@/shared/lib/query-client"; + +export const useIsCompleteToggle = ({ + todo: { id, isCompleted: initialIsCompleted }, +}: { + todo: { + id: string; + isCompleted: boolean; + }; +}) => { + const [isCompleted, setIsCompleted] = useState(initialIsCompleted); + const timeoutRef = useRef(null); + const latestIsCompletedRef = useRef(initialIsCompleted); + const lastIsCompleted = useRef(initialIsCompleted); + + const mutation = useMutation(trpc.todoRouter.updateTodo.mutationOptions()); + + const toggle = () => { + setIsCompleted((prev) => { + const newValue = !prev; + latestIsCompletedRef.current = newValue; + return newValue; + }); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(async () => { + const valueToSync = latestIsCompletedRef.current; + + if (valueToSync === lastIsCompleted.current) { + return; + } + + const { error } = await tryCatch( + mutation.mutateAsync({ id, isCompleted: valueToSync }) + ); + if (error) { + setIsCompleted(lastIsCompleted.current); + toast.error("Error updating todo", { + description: "Please try again.", + }); + return; + } + + lastIsCompleted.current = valueToSync; + queryClient.invalidateQueries({ + queryKey: trpc.todoRouter.getAllTodos.queryKey(), + }); + }, 500); + }; + + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, + [] + ); + + return { + isCompleted, + toggle, + }; +}; diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx new file mode 100644 index 0000000..e9104f2 --- /dev/null +++ b/apps/dashboard/src/main.tsx @@ -0,0 +1,44 @@ +import { Toaster } from "@realm/ui/components/sonner"; +import { ThemeProvider } from "@realm/ui/components/theme-provider"; +import { TooltipProvider } from "@realm/ui/components/tooltip"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import ReactDOM from "react-dom/client"; +import { routeTree } from "./routeTree.gen"; +import { queryClient } from "./shared/lib/query-client"; + +const router = createRouter({ + routeTree, + defaultPreload: "intent", + defaultPendingComponent: () =>
Loading...
, + defaultNotFoundComponent: () =>
Not Found
, + Wrap({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + + ); + }, +}); + +declare module "@tanstack/react-router" { + // biome-ignore lint/style/useConsistentTypeDefinitions: false positive + interface Register { + router: typeof router; + } +} + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render(); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts new file mode 100644 index 0000000..c981169 --- /dev/null +++ b/apps/dashboard/src/routeTree.gen.ts @@ -0,0 +1,59 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as TodoRouteImport } from './routes/todo' + +const TodoRoute = TodoRouteImport.update({ + id: '/todo', + path: '/todo', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/todo': typeof TodoRoute +} +export interface FileRoutesByTo { + '/todo': typeof TodoRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/todo': typeof TodoRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/todo' + fileRoutesByTo: FileRoutesByTo + to: '/todo' + id: '__root__' | '/todo' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + TodoRoute: typeof TodoRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/todo': { + id: '/todo' + path: '/todo' + fullPath: '/todo' + preLoaderRoute: typeof TodoRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + TodoRoute: TodoRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/dashboard/src/routes/__root.tsx b/apps/dashboard/src/routes/__root.tsx new file mode 100644 index 0000000..7756da7 --- /dev/null +++ b/apps/dashboard/src/routes/__root.tsx @@ -0,0 +1,39 @@ +import "@realm/ui/globals.css"; +import { + HeadContent, + Outlet, + createRootRouteWithContext, +} from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; + +// biome-ignore lint/complexity/noBannedTypes: TODO +export type RouterAppContext = {}; + +export const Route = createRootRouteWithContext()({ + component: RootComponent, + head: () => ({ + meta: [ + { + title: "Dashboard", + }, + { + name: "description", + content: "The dashboard for managing your Realm application.", + }, + { + name: "robots", + content: "noindex, nofollow", + }, + ], + }), +}); + +function RootComponent() { + return ( + <> + + + + + ); +} diff --git a/apps/dashboard/src/routes/todo.tsx b/apps/dashboard/src/routes/todo.tsx new file mode 100644 index 0000000..2dea9ee --- /dev/null +++ b/apps/dashboard/src/routes/todo.tsx @@ -0,0 +1,16 @@ +import { TodoContainer } from "@/features/todo/components/todo-container"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/todo")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/shared/lib/query-client.ts b/apps/dashboard/src/shared/lib/query-client.ts new file mode 100644 index 0000000..e1d4cf5 --- /dev/null +++ b/apps/dashboard/src/shared/lib/query-client.ts @@ -0,0 +1,22 @@ +import { QueryClient } from "@tanstack/react-query"; +import SuperJSON from "superjson"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30, // 30 minutes + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + dehydrate: { + serializeData: SuperJSON.serialize, + }, + hydrate: { + deserializeData: SuperJSON.deserialize, + }, + }, +}); diff --git a/apps/dashboard/src/shared/lib/trpc.ts b/apps/dashboard/src/shared/lib/trpc.ts new file mode 100644 index 0000000..f991522 --- /dev/null +++ b/apps/dashboard/src/shared/lib/trpc.ts @@ -0,0 +1,25 @@ +import type { TRPCRouter } from "@realm/api"; +import { createTRPCClient, httpBatchStreamLink } from "@trpc/client"; +import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import SuperJSON from "superjson"; +import { queryClient } from "./query-client"; + +export const trpcClient = createTRPCClient({ + links: [ + httpBatchStreamLink({ + url: `${import.meta.env.VITE_PUBLIC_API_URL || "http://localhost:3000"}/trpc`, + transformer: SuperJSON, + fetch(_url, options) { + return fetch(_url, { + ...options, + credentials: "include", + }); + }, + }), + ], +}); + +export const trpc = createTRPCOptionsProxy({ + client: trpcClient, + queryClient, +}); diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json new file mode 100644 index 0000000..f0c0c9d --- /dev/null +++ b/apps/dashboard/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@realm/tsconfig/react.json", + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vite/client"] + } +} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts new file mode 100644 index 0000000..9546bc9 --- /dev/null +++ b/apps/dashboard/vite.config.ts @@ -0,0 +1,21 @@ +import tailwindcss from "@tailwindcss/vite"; +import { devtools } from "@tanstack/devtools-vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import alchemy from "alchemy/cloudflare/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + devtools(), + tsconfigPaths({ projects: ["./tsconfig.json"] }), + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + }), + tailwindcss(), + viteReact(), + alchemy(), + ], +}); diff --git a/package.json b/package.json index c426111..febe31b 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,18 @@ "packages/*" ], "catalog": { + "@tailwindcss/vite": "^4.1.18", + "@trpc/client": "^11.16.0", + "@trpc/server": "^11.16.0", + "tailwindcss": "^4.1.18", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "alchemy": "^0.90.1", - "typescript": "^5.9.3" + "hono": "^4.10.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.9.3", + "zod": "^4.3.6" } }, "devDependencies": { @@ -30,6 +40,7 @@ "check": "pnpm dlx ultracite check", "fix": "pnpm dlx ultracite fix", "typecheck": "pnpm --filter \"*\" typecheck", - "test": "pnpm --filter \"*\" test" + "test": "pnpm --filter \"*\" test", + "dev:api": "pnpm --filter api dev" } } diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..9a0d1d1 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@realm/api", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "typescript": "catalog:" + }, + "dependencies": { + "@realm/core": "workspace:*", + "@realm/db": "workspace:*", + "@realm/kv": "workspace:*", + "@realm/logger": "workspace:*", + "@realm/utils": "workspace:*", + "@trpc/server": "catalog:", + "hono": "catalog:", + "superjson": "^2.2.6", + "zod": "catalog:" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts new file mode 100644 index 0000000..17bf308 --- /dev/null +++ b/packages/api/src/context.ts @@ -0,0 +1,72 @@ +import { createTodoService, type TodoService } from "@realm/core"; +import type { D1Database } from "@realm/db"; +import { createDB, createTodoQueries } from "@realm/db"; +import { + createKV, + createTodoOperations, + type KVNamespaceType, + type TodoOperations, +} from "@realm/kv"; +import type { LoggerType } from "@realm/logger"; +import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; + +type CreateContextOptions = { + env: { + db: D1Database; + kv: KVNamespaceType; + }; + fetchCreateContextFnOptions: FetchCreateContextFnOptions; + logger: LoggerType; + requestId: string; + waitUntil: (promise: Promise) => void; +}; + +export const createContext = ({ + env, + logger, + requestId, + waitUntil, +}: CreateContextOptions): Context => { + const baseContext = { + requestId, + waitUntil, + }; + + const db = createDB(env.db); + const kv = createKV(env.kv); + + const todoOperations = createTodoOperations(kv); + + const todoQueries = createTodoQueries(db); + + const todoServices = createTodoService({ + ...baseContext, + logger: logger.child({ service: "todo" }), + todoQueries, + todoOperations, + }); + + return { + requestId, + logger, + services: { + todo: todoServices, + }, + operations: { + todo: todoOperations, + }, + waitUntil, + }; +}; + +export type Context = { + requestId: string; + logger: LoggerType; + services: { + todo: TodoService; + }; + operations: { + todo: TodoOperations; + }; + waitUntil: (promise: Promise) => void; +}; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..336a34f --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,2 @@ +export { createContext } from "./context"; +export { trpcRouter, type TRPCRouter } from "./routers"; diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts new file mode 100644 index 0000000..6ba36d3 --- /dev/null +++ b/packages/api/src/routers/index.ts @@ -0,0 +1,11 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import { createTRPCRouter } from "../trpc"; +import { todoRouter } from "./todo"; + +export const trpcRouter = createTRPCRouter({ + todoRouter, +}); + +export type TRPCRouter = typeof trpcRouter; +export type RouterInputs = inferRouterInputs; +export type RouterOutputs = inferRouterOutputs; diff --git a/packages/api/src/routers/todo.ts b/packages/api/src/routers/todo.ts new file mode 100644 index 0000000..4c15e30 --- /dev/null +++ b/packages/api/src/routers/todo.ts @@ -0,0 +1,48 @@ +import z from "zod"; +import { createTRPCRouter, publicProcedure } from "../trpc"; + +const getAllTodos = publicProcedure.query(async ({ ctx }) => { + const todos = await ctx.services.todo.getAllTodos(); + + return todos; +}); + +const createTodo = publicProcedure + .input( + z.object({ + title: z.string().min(1, "Title is required"), + }) + ) + .mutation(async ({ ctx, input }) => { + await ctx.services.todo.createTodo({ + title: input.title, + }); + }); + +const updateTodo = publicProcedure + .input( + z.object({ + id: z.string().min(1, "ID is required"), + title: z.string().optional(), + isCompleted: z.boolean().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + await ctx.services.todo.updateTodo(input.id, { + title: input.title, + isCompleted: input.isCompleted, + }); + }); + +const deleteTodo = publicProcedure + .input(z.object({ id: z.string().min(1, "ID is required") })) + .mutation(async ({ ctx, input }) => { + await ctx.services.todo.deleteTodo(input.id); + }); + +export const todoRouter = createTRPCRouter({ + getAllTodos, + createTodo, + updateTodo, + deleteTodo, +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts new file mode 100644 index 0000000..9ae11ed --- /dev/null +++ b/packages/api/src/trpc.ts @@ -0,0 +1,49 @@ +import { AppError } from "@realm/core"; +import { initTRPC, TRPCError } from "@trpc/server"; +import SuperJSON from "superjson"; +import type { Context } from "./context"; + +const t = initTRPC.context().create({ + transformer: SuperJSON, +}); + +const baseProcedure = t.procedure.use(async (opts) => { + const { ctx, next, path, type } = opts; + const startTime = Date.now(); + + const response = await next(); + const durationMs = Date.now() - startTime; + + if (response.ok) { + ctx.logger.info("trpc request completed", { + path, + type, + durationMs, + status: "success", + }); + return response; + } + + const { error } = response; + ctx.logger.error("trpc request failed", { + path, + type, + durationMs, + status: "error", + error, + }); + + if (error.cause instanceof AppError) { + const appError = error.cause; + throw new TRPCError({ + code: appError.code, + message: appError.message, + cause: appError, + }); + } + + throw error; +}); + +export const createTRPCRouter = t.router; +export const publicProcedure = baseProcedure; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..4b6cab9 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@realm/tsconfig/base.json", + "include": ["src/**/*"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true, + "rootDir": "src" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..e61bc48 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@realm/core", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "dependencies": { + "@realm/db": "workspace:*", + "@realm/kv": "workspace:*", + "@realm/logger": "workspace:*", + "@realm/utils": "workspace:*" + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "@realm/types": "workspace:*", + "typescript": "catalog:" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/core/src/errors/app-error.ts b/packages/core/src/errors/app-error.ts new file mode 100644 index 0000000..117b28b --- /dev/null +++ b/packages/core/src/errors/app-error.ts @@ -0,0 +1,41 @@ +export const ErrorCode = { + NOT_FOUND: "NOT_FOUND", + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", + CONFLICT: "CONFLICT", + INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR", + PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE", +} as const; + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +export type ErrorMetadata = { + requestId?: string; + userId?: string; + timestamp: string; +}; + +export class AppError extends Error { + readonly code: ErrorCode; + readonly details?: Record; + readonly metadata: ErrorMetadata; + + constructor( + code: ErrorCode, + message: string, + options?: { + details?: Record; + metadata?: Partial; + cause?: Error; + } + ) { + super(message, { cause: options?.cause }); + + this.code = code; + this.details = options?.details; + this.metadata = { + timestamp: new Date().toISOString(), + ...options?.metadata, + }; + } +} diff --git a/packages/core/src/errors/index.ts b/packages/core/src/errors/index.ts new file mode 100644 index 0000000..2ff1343 --- /dev/null +++ b/packages/core/src/errors/index.ts @@ -0,0 +1,6 @@ +export { + AppError, + ErrorCode, + type ErrorCode as ErrorCodeType, + type ErrorMetadata, +} from "./app-error"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..0a2698f --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,7 @@ +export { + AppError, + ErrorCode, + type ErrorCodeType, + type ErrorMetadata, +} from "./errors"; +export { createTodoService, type TodoService } from "./services"; diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts new file mode 100644 index 0000000..609b158 --- /dev/null +++ b/packages/core/src/services/index.ts @@ -0,0 +1 @@ +export { createTodoService, type TodoService } from "./todo"; diff --git a/packages/core/src/services/todo.ts b/packages/core/src/services/todo.ts new file mode 100644 index 0000000..f6fb498 --- /dev/null +++ b/packages/core/src/services/todo.ts @@ -0,0 +1,125 @@ +import type { TodoQueries } from "@realm/db"; +import type { TodoOperations } from "@realm/kv"; +import type { Todo } from "@realm/types"; +import { createNanoId, tryCatch } from "@realm/utils"; +import { AppError, ErrorCode } from "../errors"; +import type { BaseContext } from "../types"; + +export type TodoService = { + getAllTodos: () => Promise; + createTodo: ( + todo: Omit + ) => Promise; + updateTodo: ( + id: string, + todo: Partial> + ) => Promise; + deleteTodo: (id: string) => Promise; +}; + +type CreateTodoServiceCtx = { + todoQueries: TodoQueries; + todoOperations: TodoOperations; +} & BaseContext; + +export const createTodoService = (ctx: CreateTodoServiceCtx): TodoService => ({ + getAllTodos: async () => { + const { data: cachedTodos } = await tryCatch( + ctx.todoOperations.getAllTodo() + ); + if (cachedTodos) { + return cachedTodos; + } + + const { data: todos, error } = await tryCatch( + ctx.todoQueries.getAllTodos() + ); + if (error) { + throw new AppError( + ErrorCode.INTERNAL_SERVER_ERROR, + "Failed to fetch todos", + { + cause: error, + } + ); + } + + ctx.todoOperations.setAllTodo(todos); + + return todos; + }, + createTodo: async (todo) => { + const newTodo: Todo = { + ...todo, + id: createNanoId(), + isCompleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const { error } = await tryCatch(ctx.todoQueries.createTodo(newTodo)); + if (error) { + throw new AppError( + ErrorCode.INTERNAL_SERVER_ERROR, + "Failed to create todo", + { + cause: error, + details: { todo: newTodo }, + } + ); + } + + ctx.waitUntil(ctx.todoOperations.deleteAllTodo()); + }, + updateTodo: async (id, todo) => { + const updatedFields: Partial = { + ...todo, + updatedAt: new Date().toISOString(), + }; + const { data, error } = await tryCatch( + ctx.todoQueries.updateTodo(id, updatedFields) + ); + if (data === null) { + throw new AppError(ErrorCode.NOT_FOUND, "Todo not found", { + details: { id }, + }); + } + + if (error) { + throw new AppError( + ErrorCode.INTERNAL_SERVER_ERROR, + "Failed to update todo", + { + cause: error, + details: { id, updatedFields }, + } + ); + } + + ctx.waitUntil( + Promise.all([ + ctx.todoOperations.deleteAllTodo(), + ctx.todoOperations.setTodo(id, data), + ]) + ); + }, + deleteTodo: async (id) => { + const { error } = await tryCatch(ctx.todoQueries.deleteTodo(id)); + if (error) { + throw new AppError( + ErrorCode.INTERNAL_SERVER_ERROR, + "Failed to delete todo", + { + cause: error, + details: { id }, + } + ); + } + + ctx.waitUntil( + Promise.all([ + ctx.todoOperations.deleteAllTodo(), + ctx.todoOperations.deleteTodo(id), + ]) + ); + }, +}); diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts new file mode 100644 index 0000000..e60cd84 --- /dev/null +++ b/packages/core/src/types/context.ts @@ -0,0 +1,13 @@ +import type { LoggerType } from "@realm/logger"; + +export type BaseContext = { + /** + * This will be extends the lifetime of your Worker, allowing you to perform + * work without blocking returning a response, and that may continue after a response is returned. + * It accepts a Promise, which the Workers runtime will continue executing, even after a response has + * been returned by the Worker's handler. + */ + waitUntil: (promise: Promise) => void; + logger: LoggerType; + requestId: string; +}; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts new file mode 100644 index 0000000..840a404 --- /dev/null +++ b/packages/core/src/types/index.ts @@ -0,0 +1 @@ +export type { BaseContext } from "./context"; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..4b6cab9 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@realm/tsconfig/base.json", + "include": ["src/**/*"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true, + "rootDir": "src" + } +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..b61f292 --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/schemas", + out: "./migrations", + dialect: "sqlite", + casing: "snake_case", + driver: "d1-http", +}); diff --git a/packages/db/migrations/0000_futuristic_sentinels.sql b/packages/db/migrations/0000_futuristic_sentinels.sql new file mode 100644 index 0000000..0d06487 --- /dev/null +++ b/packages/db/migrations/0000_futuristic_sentinels.sql @@ -0,0 +1,7 @@ +CREATE TABLE `todos` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `is_completed` integer DEFAULT false NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) NOT NULL +); diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..c1500e8 --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,66 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c9e2bd81-1328-4fc2-b228-6b90782013e3", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "todos": { + "name": "todos", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_completed": { + "name": "is_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..da353d3 --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1774955175093, + "tag": "0000_futuristic_sentinels", + "breakpoints": true + } + ] +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..b3d5ea0 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,26 @@ +{ + "name": "@realm/db", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "db:generate": "drizzle-kit generate", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "drizzle-kit": "^0.31.10", + "typescript": "catalog:" + }, + "dependencies": { + "drizzle-orm": "^0.45.2" + } +} diff --git a/packages/db/src/db.ts b/packages/db/src/db.ts new file mode 100644 index 0000000..3240ea2 --- /dev/null +++ b/packages/db/src/db.ts @@ -0,0 +1,11 @@ +import { drizzle, type AnyD1Database } from "drizzle-orm/d1"; +import { schema } from "./schemas"; + +export const createDB = (d1: AnyD1Database) => + drizzle(d1, { + schema, + casing: "snake_case", + }); + +export type DB = ReturnType; +export type D1Database = AnyD1Database; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..4a6bde9 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,3 @@ +export { createDB, type D1Database, type DB } from "./db"; +export { createTodoQueries, type TodoQueries } from "./queries"; +export { schema } from "./schemas"; diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts new file mode 100644 index 0000000..cf086cd --- /dev/null +++ b/packages/db/src/queries/index.ts @@ -0,0 +1 @@ +export { createTodoQueries, type TodoQueries } from "./todo"; diff --git a/packages/db/src/queries/todo.ts b/packages/db/src/queries/todo.ts new file mode 100644 index 0000000..6f9f342 --- /dev/null +++ b/packages/db/src/queries/todo.ts @@ -0,0 +1,36 @@ +import { eq } from "drizzle-orm"; +import type { DB } from "../db"; +import { schema } from "../schemas"; +import type { InsertTodo, SelectTodo } from "../schemas/todo"; + +export type TodoQueries = { + getAllTodos: () => Promise; + createTodo: (todo: InsertTodo) => Promise; + updateTodo: ( + id: string, + todo: Partial + ) => Promise; + deleteTodo: (id: string) => Promise; +}; + +export const createTodoQueries = (db: DB): TodoQueries => ({ + getAllTodos: async () => await db.select().from(schema.todoTable), + createTodo: async (todo) => { + await db.insert(schema.todoTable).values(todo); + }, + updateTodo: async (id, todo) => { + const [record] = await db + .update(schema.todoTable) + .set(todo) + .where(eq(schema.todoTable.id, id)) + .returning(); + if (!record) { + return null; + } + + return record; + }, + deleteTodo: async (id) => { + await db.delete(schema.todoTable).where(eq(schema.todoTable.id, id)); + }, +}); diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts new file mode 100644 index 0000000..6643408 --- /dev/null +++ b/packages/db/src/schemas/index.ts @@ -0,0 +1,3 @@ +import { todoTable } from "./todo"; + +export const schema = { todoTable }; diff --git a/packages/db/src/schemas/todo.ts b/packages/db/src/schemas/todo.ts new file mode 100644 index 0000000..c57bcfe --- /dev/null +++ b/packages/db/src/schemas/todo.ts @@ -0,0 +1,19 @@ +import { sql } from "drizzle-orm"; +import { sqliteTable } from "drizzle-orm/sqlite-core"; + +export const todoTable = sqliteTable("todos", (t) => ({ + id: t.text().primaryKey(), + title: t.text().notNull(), + isCompleted: t.integer({ mode: "boolean" }).notNull().default(false), + createdAt: t + .text() + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))`), // ISO 8601 format in UTC + updatedAt: t + .text() + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))`), // ISO 8601 format in UTC +})); + +export type SelectTodo = typeof todoTable.$inferSelect; +export type InsertTodo = typeof todoTable.$inferInsert; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..4b6cab9 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@realm/tsconfig/base.json", + "include": ["src/**/*"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true, + "rootDir": "src" + } +} diff --git a/packages/kv/package.json b/packages/kv/package.json new file mode 100644 index 0000000..aa4866d --- /dev/null +++ b/packages/kv/package.json @@ -0,0 +1,23 @@ +{ + "name": "@realm/kv", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260403.1", + "@realm/tsconfig": "workspace:*", + "@realm/types": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/packages/kv/src/index.ts b/packages/kv/src/index.ts new file mode 100644 index 0000000..920a35e --- /dev/null +++ b/packages/kv/src/index.ts @@ -0,0 +1,6 @@ +export { + createKV, + type KV, + type KVNamespaceType, +} from "./kv"; +export { createTodoOperations, type TodoOperations } from "./operations"; diff --git a/packages/kv/src/keys.ts b/packages/kv/src/keys.ts new file mode 100644 index 0000000..2c182d7 --- /dev/null +++ b/packages/kv/src/keys.ts @@ -0,0 +1,8 @@ +const todoKeys = { + all: "todo", + byId: (id: string) => `todo:${id}`, +}; + +export const KEYS = { + todo: todoKeys, +}; diff --git a/packages/kv/src/kv.ts b/packages/kv/src/kv.ts new file mode 100644 index 0000000..d87e0ce --- /dev/null +++ b/packages/kv/src/kv.ts @@ -0,0 +1,26 @@ +import type { KVNamespace } from "@cloudflare/workers-types"; + +type KVSetOptions = { + /** + * The time to live (TTL) for the key, in seconds. After this time, the key will be automatically deleted from the KV store. + * @remarks Expiration targets that are less than 60 seconds into the future are not supported. This is true for both expiration methods. + */ + expirationTtl?: number; +}; + +export type KV = { + get(key: string): Promise; + set(key: string, value: string, options?: KVSetOptions): Promise; + delete(key: string): Promise; +}; + +export const createKV = (kv: KVNamespace): KV => ({ + get: async (key) => await kv.get(key), + set: async (key, value, options = {}) => + await kv.put(key, value, { + expirationTtl: options.expirationTtl, + }), + delete: async (key) => await kv.delete(key), +}); + +export type KVNamespaceType = KVNamespace; diff --git a/packages/kv/src/operations/index.ts b/packages/kv/src/operations/index.ts new file mode 100644 index 0000000..d43f656 --- /dev/null +++ b/packages/kv/src/operations/index.ts @@ -0,0 +1 @@ +export { createTodoOperations, type TodoOperations } from "./todo"; diff --git a/packages/kv/src/operations/todo.ts b/packages/kv/src/operations/todo.ts new file mode 100644 index 0000000..f4ac19f --- /dev/null +++ b/packages/kv/src/operations/todo.ts @@ -0,0 +1,43 @@ +import type { Todo } from "@realm/types"; +import { KEYS } from "../keys"; +import type { KV } from "../kv"; + +export type TodoOperations = { + getAllTodo: () => Promise; + getTodo: (id: string) => Promise; + setAllTodo: (todos: Todo[]) => Promise; + setTodo: (id: string, todo: Todo) => Promise; + deleteAllTodo: () => Promise; + deleteTodo: (id: string) => Promise; +}; + +export const createTodoOperations = (kv: KV): TodoOperations => ({ + getAllTodo: async () => { + const todos = await kv.get(KEYS.todo.all); + if (!todos) { + return null; + } + + return JSON.parse(todos); + }, + getTodo: async (id) => { + const todoString = await kv.get(KEYS.todo.byId(id)); + if (!todoString) { + return null; + } + + return await JSON.parse(todoString); + }, + setAllTodo: async (todos) => { + await kv.set(KEYS.todo.all, JSON.stringify(todos)); + }, + setTodo: async (id, todo) => { + await kv.set(KEYS.todo.byId(id), JSON.stringify({ ...todo, id })); + }, + deleteAllTodo: async () => { + await kv.delete(KEYS.todo.all); + }, + deleteTodo: async (id) => { + await kv.delete(KEYS.todo.byId(id)); + }, +}); diff --git a/packages/kv/tsconfig.json b/packages/kv/tsconfig.json new file mode 100644 index 0000000..4b6cab9 --- /dev/null +++ b/packages/kv/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@realm/tsconfig/base.json", + "include": ["src/**/*"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true, + "rootDir": "src" + } +} diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 0000000..9956daa --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,21 @@ +{ + "name": "@realm/logger", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 0000000..5d30514 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1 @@ +export { createLogger, Logger, type LoggerType } from "./logger"; diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts new file mode 100644 index 0000000..4128032 --- /dev/null +++ b/packages/logger/src/logger.ts @@ -0,0 +1,110 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export type LogFields = Record; + +export type LoggerType = { + debug: (message: string, fields?: LogFields) => void; + info: (message: string, fields?: LogFields) => void; + warn: (message: string, fields?: LogFields) => void; + error: (message: string, fields?: LogFields) => void; + child: (fields: LogFields) => Logger; +}; + +export class Logger implements LoggerType { + private readonly boundFields: LogFields; + + constructor(boundFields: LogFields = {}) { + this.boundFields = boundFields; + } + + debug(message: string, fields?: LogFields): void { + this.log("debug", message, fields); + } + + info(message: string, fields?: LogFields): void { + this.log("info", message, fields); + } + + warn(message: string, fields?: LogFields): void { + this.log("warn", message, fields); + } + + error(message: string, fields?: LogFields): void { + this.log("error", message, fields); + } + + child(fields: LogFields): Logger { + return new Logger({ + ...this.boundFields, + ...fields, + }); + } + + private log(level: LogLevel, message: string, fields?: LogFields): void { + const logEntry = { + level, + message, + timestamp: new Date().toISOString(), + ...normalizeLogFields(this.boundFields), + ...normalizeLogFields(fields), + }; + + const logString = JSON.stringify(logEntry); + + switch (level) { + case "debug": + console.debug(logString); + break; + case "info": + console.info(logString); + break; + case "warn": + console.warn(logString); + break; + case "error": + console.error(logString); + break; + default: + console.log(logString); + } + } +} + +export const createLogger = (fields?: LogFields): Logger => new Logger(fields); + +const SENSITIVE_KEY = [ + "password", + "apiKey", + "token", + "secret", + "credential", + "auth", + "key", + "api_key", + "access_token", + "refresh_token", + "client_id", + "client_secret", + "Auth", + "authorization", +]; + +function normalizeLogFields(fields?: LogFields): LogFields { + const normalizedFields: LogFields = {}; + if (!fields) { + return normalizedFields; + } + + for (const key in fields) { + if ( + SENSITIVE_KEY.some((sensitiveKey) => key.includes(sensitiveKey)) && + typeof fields[key] === "string" + ) { + normalizedFields[key] = "REDACTED"; + } else { + normalizedFields[key] = fields[key]; + } + } + + return normalizedFields; +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 0000000..4b6cab9 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@realm/tsconfig/base.json", + "include": ["src/**/*"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true, + "rootDir": "src" + } +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index b3d9c5f..a9f3982 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -1,10 +1,10 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "ESNext", - "module": "ESNext", + "target": "es2022", + "module": "es2022", "moduleResolution": "bundler", - "lib": ["ESNext"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "verbatimModuleSyntax": true, "strict": true, "skipLibCheck": true, diff --git a/packages/tsconfig/node.json b/packages/tsconfig/node.json deleted file mode 100644 index 1cfcb58..0000000 --- a/packages/tsconfig/node.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "verbatimModuleSyntax": true, - "strict": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - } -} diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index b8b54ee..2b52de3 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -4,6 +4,6 @@ "private": true, "exports": { "./base.json": "./base.json", - "./node.json": "./node.json" + "./react.json": "./react.json" } } diff --git a/packages/tsconfig/react.json b/packages/tsconfig/react.json new file mode 100644 index 0000000..c3a1b26 --- /dev/null +++ b/packages/tsconfig/react.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..3011acc --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,21 @@ +{ + "name": "@realm/types", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..0e826b7 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1 @@ +export type * from "./todo"; diff --git a/packages/types/src/todo.ts b/packages/types/src/todo.ts new file mode 100644 index 0000000..61d655b --- /dev/null +++ b/packages/types/src/todo.ts @@ -0,0 +1,7 @@ +export type Todo = { + id: string; + title: string; + isCompleted: boolean; + createdAt: string; // ISO 8601 format in UTC + updatedAt: string; // ISO 8601 format in UTC +}; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..4b6cab9 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@realm/tsconfig/base.json", + "include": ["src/**/*"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true, + "rootDir": "src" + } +} diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..cae9dae --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-lyra", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "hooks": "@/hooks", + "lib": "@/lib", + "ui": "@/components" + }, + "rtl": false, + "menuColor": "default-translucent", + "menuAccent": "subtle" +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..9cfe268 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,40 @@ +{ + "name": "@realm/ui", + "type": "module", + "exports": { + "./globals.css": "./src/styles/globals.css", + "./lib/*": "./src/lib/*.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts" + }, + "devDependencies": { + "@realm/tsconfig": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "@fontsource-variable/instrument-sans": "^5.2.8", + "@fontsource-variable/noto-serif": "^5.2.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-day-picker": "^9.14.0", + "react-dom": "catalog:", + "recharts": "3.8.0", + "shadcn": "^4.1.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx new file mode 100644 index 0000000..2f87483 --- /dev/null +++ b/packages/ui/src/components/alert-dialog.tsx @@ -0,0 +1,185 @@ +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import type * as React from "react"; + +import { Button } from "../components/button"; +import { cn } from "../lib/utils"; + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return ; +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ); +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +