Migrate from server actions & REST APIs to oRPC for type-safe RPC (#5)

* Replace server actions with API routes and migrate SWR to TanStack Query

- Add 33 new REST API routes covering titles, episodes, seasons, people,
  dashboard, explore, integrations, admin, backups, and account operations
- Replace SWR with @tanstack/react-query: add QueryProvider, api-client
  helper, query-client singleton, and TanStack Query hooks for titles
- Migrate all 26 server actions to fetch calls against the new API routes
  in 10 client components (use-title-actions, title-card, command-palette,
  hero-banner, integration-card, account/backup/registration/update-check
  settings sections)
- Delete lib/actions/ directory (titles.ts, watchlist.ts, people.ts,
  settings.ts) and lib/swr/fetcher.ts

https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb

* Convert pages to TanStack Query client-side data fetching

Migrate dashboard, explore, settings, and people pages from server
component data fetching to client-side TanStack Query hooks. Create
query hook files for each domain (dashboard, explore, people, admin,
integrations). Add GET routes for admin/registration and admin/update-check.
Update CLAUDE.md architecture docs to reflect TanStack Query patterns.

https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb

* Migrate API layer from REST routes to oRPC with TanStack Query

Replace ~35 hand-written REST API routes and manual TanStack Query hooks
with a contract-first oRPC setup that provides end-to-end type safety
from contract → server procedures → client → TanStack Query.

- Add oRPC foundation: contract, context, auth/admin middleware, RPCHandler,
  client (browser + SSR via globalThis), TanStack Query utils
- Implement ~30 procedures across 14 files (titles, episodes, seasons,
  people, dashboard, explore, search, discover, stats, status,
  integrations, admin, account, watchlist)
- Migrate all client components from api()/lib/queries/ to
  orpc.*.queryOptions() and client.*.method() calls
- Delete all replaced REST API routes, lib/queries/, lib/api-client.ts,
  and old hooks (use-discover, use-search, use-stats, use-system-health)
- Wire up SSR optimization via instrumentation.ts
- Keep non-RPC routes: auth, images, avatars, backup restore/download,
  webhooks, lists, health

https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb

* Add strict Zod output schemas to all oRPC contract procedures

Replaces the permissive z.any() output schemas with precise Zod schemas
for every procedure that returns data. This makes the contract fully
self-documenting and enables runtime output validation.

- Define ~30 output schemas in schemas.ts covering titles, people,
  dashboard, explore, search, discover, stats, integrations, admin, etc.
- Wire all output schemas into contract.ts
- Fix procedure return type mismatches (nullable fields, enum narrowing)
- Align consumer types (SearchResult, IntegrationConnection) with contract

https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb

* Delete lib/types.ts — infer all types from Zod schemas

The oRPC Zod schemas are now the single source of truth for domain types.
All 17 consumer files (services, components, atoms, utils) now import
inferred types (Episode, Season, ResolvedTitle, CastMember, etc.) from
lib/orpc/schemas instead of hand-written interfaces in lib/types.ts.

https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb

* Migrate all mutations to TanStack Query useMutation

Replace raw oRPC client calls with useMutation/mutateAsync via
orpc.*.mutationOptions() for consistent mutation tracking across
all components: title-card, command-palette, account, system-health,
integrations, backups, backup-schedule, and title-actions.

https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb

* Fix bugs in settings/explore and bump dependencies

- Deduplicate backup list on create (filter by filename before prepend)
- Add isError state to backup-schedule section with fallback UI
- Add error check for avatar DELETE response before clearing state
- Strengthen backup restore file guard with `instanceof File`
- Change explore trending default type from "movie" to "all"
- Pin all oRPC and TanStack Query packages to exact versions
- Bump jotai 2.18.0→2.18.1, motion 12.35.1→12.35.2,
  shadcn 4.0.0→4.0.2, @types/node 25.3.5→25.4.0,
  semver 6.3.1→7.7.4

* Migrate title recommendations to client-side TanStack Query

- Convert TitleRecommendations from server component with `"use cache"`
  to a client component using `orpc.titles.recommendations.queryOptions()`
  with an inline skeleton loading state
- Delete recommendations-grid.tsx — merged into title-recommendations.tsx
- Remove RecommendationsSkeleton from skeletons.tsx (now inline)
- Drop Suspense wrapper in title detail page (client component handles
  its own loading state)
- Include userStatuses in recommendations procedure output so the
  component no longer needs a separate session/tracking lookup
- Remove `revalidate` option and all `updateTag`/`cacheTag`/`cacheLife`
  calls from refreshCredits and refreshRecommendations — no longer
  needed without "use cache" server components
- Remove `cacheComponents: true` from next.config.ts

* Replace Jotai title atoms with React Context + TanStack Query cache

- Add title-context.tsx with TitleContext and useTitleContext /
  useTitleUserInfo hooks; useTitleUserInfo reads from
  orpc.titles.userInfo cache instead of atoms
- Rewrite TitleProvider to seed the userInfo cache via
  queryClient.setQueryData on mount (unconditional overwrite
  prevents stale data on revisit / account switch) and provide
  titleId, titleType, titleName, seasons, and watchingEp via
  context
- Rewrite use-title-actions to read/write userInfo via
  queryClient.getQueryData / setQueryData helpers instead of
  useStore + individual atoms; optimistic updates and rollbacks
  now operate on a single UserInfo object in the query cache
- Migrate title-actions, title-keyboard-shortcuts, and
  title-seasons off useAtomValue / useSetAtom to the new
  context hooks
- Delete per-title atoms from lib/atoms/title.ts

* Migrate avatar and backup restore routes to oRPC procedures

- Add `account.uploadAvatar` and `account.removeAvatar` procedures
  (FormData file input, auth middleware) replacing `PUT`/`DELETE`
  `/api/account/avatar` route
- Add `admin.backups.restore` procedure (File input, admin middleware)
  replacing `POST /api/admin/backups/restore` route
- Delete both REST route files
- Migrate account-section and backup-restore-section off
  `useTransition` + raw `fetch` to `useMutation` via
  `orpc.*.mutationOptions()`
- Rename `lib/utils/title-theme.ts` → `lib/theme.ts` and
  `getTitleThemeStyle` → `getThemeCssProperties`; update title
  detail page import accordingly

* Add OpenAPI v1 REST API endpoint via @orpc/openapi

- Add `@orpc/openapi` and `@orpc/json-schema` dependencies
- Add `lib/orpc/openapi-handler.ts` with OpenAPIHandler instance
- Add `app/api/v1/[[...rest]]/route.ts` catch-all serving GET/POST/PUT/DELETE
  at `/api/v1` with request-headers context
- Annotate contract procedures with OpenAPI metadata (tags, summaries,
  descriptions, and successStatus codes) across all ~30 procedures

* Fix 8 bugs: memory safety, data races, and correctness issues

- Move queryClient.setQueryData from useState initializer to useEffect
  to avoid mutating the query cache during React render phase
- Add SQL LIMIT to per-integration event queries instead of loading all
  events and trimming in memory
- Lower backup restore upload limit from 500 MB to 100 MB and stream
  upload to disk instead of buffering entirely in memory
- Reorder avatar upload to write new file before deleting old one so a
  failed upload doesn't remove the current avatar
- Make optimistic rollback field-specific so a failed rating update
  doesn't erase concurrent status or episode-watch changes
- Gate titles.userInfo query behind session state to prevent failing
  RPC calls for anonymous users
- Derive OpenAPI session cookie name from BETTER_AUTH_URL so HTTPS
  deployments use the correct __Secure- prefixed cookie

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add explicit z.void() output schemas to all 17 void procedures

Ensures the OpenAPI spec correctly generates 204 No Content responses
for mutation endpoints that don't return data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2026-03-10 10:26:26 -04:00
committed by GitHub
co-authored by Claude
parent dfda2da827
commit 26558b29e4
102 changed files with 3603 additions and 2320 deletions
+69 -9
View File
@@ -48,7 +48,8 @@ bun run test
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
- **State**: Jotai (client), SWR (data fetching)
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
- **Linting**: Biome (2-space indent, organized imports, React/Next.js recommended rules)
- **External API**: TMDB (The Movie Database) with Bearer token auth
@@ -67,10 +68,24 @@ bun run test
- `lib/logger.ts` — Structured logger with `LOG_LEVEL` support
- `lib/types/` — Shared TypeScript types (e.g. `title.ts`)
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
- `app/api/` — Next.js route handlers
- `lib/query-client.ts` — Singleton `QueryClient` with default options (30s stale time)
- `lib/orpc/` — oRPC API layer (contract-first, type-safe RPC):
- `contract.ts` — Full API contract definition (~30 procedures)
- `schemas.ts` — Shared Zod input schemas used by contract
- `context.ts` — Context type & `os` implementer
- `middleware.ts` — Auth (`authed`) and admin (`admin`) middleware
- `router.ts` — Assembled router implementing the contract
- `handler.ts` — RPCHandler instance with error logging
- `client.ts` — Client-side oRPC client (RPCLink)
- `client.server.ts` — Server-side oRPC client (zero HTTP overhead for SSR)
- `tanstack.ts``orpc` utils for TanStack Query (`orpc.*.queryOptions()`)
- `procedures/` — Procedure implementations by domain (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist)
- `app/rpc/[[...rest]]/route.ts` — Next.js catch-all route handler for oRPC
- `app/api/` — Non-RPC routes (auth, images, avatars, backups, webhooks, lists, health)
- `app/(pages)/` — Authenticated pages (dashboard, explore, titles/[id], people/[id], settings)
- `app/(auth)/` — Auth pages (login, register, setup)
- `components/` — App components + `components/ui/` for shadcn primitives
- `components/query-provider.tsx``QueryClientProvider` wrapper used in root layout
### Auth pattern
@@ -81,17 +96,62 @@ import { getSession } from "@/lib/auth/session";
const session = await getSession();
```
Route handlers call Better Auth directly:
oRPC procedures use auth middleware that calls Better Auth:
```typescript
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
const session = await auth.api.getSession({ headers: await headers() });
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// use session.user.id
// lib/orpc/middleware.ts — applied to procedures
export const authed = base.middleware(async ({ context, next }) => {
const session = await auth.api.getSession({ headers: context.headers });
if (!session) throw new ORPCError("UNAUTHORIZED");
return next({ context: { user: session.user, session: session.session } });
});
```
### oRPC API layer
All API procedures use oRPC with a contract-first approach. The contract defines the API shape in `lib/orpc/contract.ts`, procedures implement it in `lib/orpc/procedures/`, and the client consumes it with full type safety.
**Pattern — query in component:**
```typescript
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/tanstack";
const { data } = useQuery(orpc.dashboard.stats.queryOptions());
```
**Pattern — mutation:**
```typescript
import { client } from "@/lib/orpc/client";
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
```
**Pattern — procedure definition:**
```typescript
// lib/orpc/procedures/dashboard.ts
export const stats = os.dashboard.stats.func(async (input, context, meta) => {
const { user } = context;
return getUserStats(user.id);
}, authed);
```
**Page patterns:**
- **Thin server shell** (titles, people): Server component fetches initial data via service layer, passes as `initialData` prop to client component that uses TanStack Query via `orpc` for refetching/mutations.
- **Full client-side** (dashboard, explore, settings): Server component handles auth only; client components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
### Non-RPC routes
Only file-serving, auth, and webhook routes remain as traditional Next.js API routes:
- `/api/auth/[...all]` — Better Auth catch-all
- `/api/images/[...path]` — Image proxy/cache serving
- `/api/avatars/[userId]` — Avatar file serving
- `/api/account/avatar` — Avatar upload (FormData)
- `/api/admin/backups/restore` — Backup restore (FormData upload)
- `/api/backup/[filename]` — Backup file download
- `/api/webhooks/[token]` — Plex/Jellyfin/Emby webhooks
- `/api/lists/[token]` — External list feeds
- `/api/health` — Simple health check (no auth)
### Database schema
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships: