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 (
+
+ {({ 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 (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx
new file mode 100644
index 0000000..6be889f
--- /dev/null
+++ b/packages/ui/src/components/alert.tsx
@@ -0,0 +1,79 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "../lib/utils";
+
+const alertVariants = cva(
+ "group/alert relative grid w-full gap-0.5 rounded-none border px-2.5 py-2 text-left text-xs has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-4 *:[svg]:row-span-2 *:[svg]:translate-y-0 *:[svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ data-slot="alert-title"
+ {...props}
+ />
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertAction, AlertDescription, AlertTitle };
diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx
new file mode 100644
index 0000000..3082f21
--- /dev/null
+++ b/packages/ui/src/components/avatar.tsx
@@ -0,0 +1,107 @@
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import type * as React from "react";
+
+import { cn } from "../lib/utils";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ data-slot="avatar-badge"
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ data-slot="avatar-group-count"
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarBadge,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarImage,
+};
diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx
new file mode 100644
index 0000000..d1672f2
--- /dev/null
+++ b/packages/ui/src/components/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../lib/utils";
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-none border border-transparent px-2 py-0.5 font-medium text-xs transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps
) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ });
+}
+
+export { Badge, badgeVariants };
diff --git a/packages/ui/src/components/button-group.tsx b/packages/ui/src/components/button-group.tsx
new file mode 100644
index 0000000..60c372d
--- /dev/null
+++ b/packages/ui/src/components/button-group.tsx
@@ -0,0 +1,87 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { Separator } from "../components/separator";
+import { cn } from "../lib/utils";
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch rounded-none *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-none [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "*:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+ vertical:
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"fieldset"> &
+ VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex items-center gap-2 rounded-none border bg-muted px-2.5 font-medium text-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "button-group-text",
+ },
+ });
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+};
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
new file mode 100644
index 0000000..cdc7ae5
--- /dev/null
+++ b/packages/ui/src/components/button.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../lib/utils";
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-none border border-transparent bg-clip-padding font-medium text-xs outline-none transition-all focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 dark:hover:bg-destructive/30",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-7 rounded-none",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx
new file mode 100644
index 0000000..89241ab
--- /dev/null
+++ b/packages/ui/src/components/calendar.tsx
@@ -0,0 +1,229 @@
+/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: TODO */
+/** biome-ignore-all lint/nursery/noShadow: TODO */
+"use client";
+
+import type * as React from "react";
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale,
+} from "react-day-picker";
+
+import { Button, buttonVariants } from "../components/button";
+import { cn } from "../lib/utils";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react";
+import { useEffect, useRef } from "react";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative rounded-(--cell-radius)",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute inset-0 bg-popover opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "flex-1 select-none rounded-(--cell-radius) font-normal text-[0.8rem] text-muted-foreground",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "select-none text-[0.8rem] text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full select-none rounded-(--cell-radius) p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end
+ ),
+ today: cn(
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => (
+
+ ),
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ );
+ }
+
+ if (orientation === "right") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: ({ ...props }) => (
+
+ ),
+ WeekNumber: ({ children, ...props }) => (
+
+
+ {children}
+
+ |
+ ),
+ ...components,
+ }}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters,
+ }}
+ locale={locale}
+ showOutsideDays={showOutsideDays}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = useRef(null);
+ useEffect(() => {
+ if (modifiers.focused) {
+ ref.current?.focus();
+ }
+ }, [modifiers.focused]);
+
+ return (
+