compassmock/docs/architecture/server-actions.md
Nicholai a7494397f2
docs(all): comprehensive documentation overhaul (#57)
Restructure docs/ into architecture/, modules/, and
development/ directories. Add thorough documentation
for Compass Core platform and HPS Compass modules.
Rewrite CLAUDE.md as a lean quick-reference that
points to the full docs. Rename files to lowercase,
consolidate old docs, add gotchas section.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-07 19:17:37 -07:00

7.8 KiB
Executable File

Server Actions

Every data mutation in Compass goes through a server action. Not an API route, not a fetch call, not a GraphQL resolver. Server actions. This document explains the pattern, lists all 25 action files, and covers why this was chosen over alternatives.

the pattern

Every server action file starts with "use server" and exports async functions that follow a consistent shape:

"use server"

import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { customers, type NewCustomer } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"

export async function createCustomer(
  data: Omit<NewCustomer, "id" | "createdAt" | "updatedAt">
) {
  try {
    const user = await getCurrentUser()
    requirePermission(user, "customer", "create")

    const { env } = await getCloudflareContext()
    const db = getDb(env.DB)

    const now = new Date().toISOString()
    const id = crypto.randomUUID()

    await db.insert(customers).values({
      id,
      ...data,
      createdAt: now,
      updatedAt: now,
    })

    revalidatePath("/dashboard/customers")
    return { success: true, id }
  } catch (err) {
    return {
      success: false,
      error:
        err instanceof Error ? err.message : "Failed to create customer",
    }
  }
}

The steps are always the same:

  1. Authenticate. Call getCurrentUser() to get the current user from the WorkOS session.
  2. Authorize. Call requirePermission(user, resource, action) to check RBAC. This throws if the user's role doesn't have the required permission.
  3. Get the database. Call getCloudflareContext() for the D1 binding, then getDb(env.DB) for the Drizzle instance.
  4. Do the work. Run the query/mutation.
  5. Revalidate. Call revalidatePath() to bust the Next.js cache for affected pages.
  6. Return a discriminated union. { success: true } or { success: false; error: string }.

The return type is the most important convention. Every mutation returns a discriminated union, never throws to the caller. This means the calling component always knows whether the operation succeeded and can show appropriate feedback without try/catch boilerplate.

Read-only actions (like getCustomers()) skip the try/catch wrapper and return data directly, since read failures are handled by the component's error boundary.

why server actions over API routes

Three reasons:

Type safety. Server actions are regular TypeScript functions. The parameter types and return types flow through the compiler. If you change the shape of NewCustomer, every call site that passes invalid data becomes a compile error. With API routes, you'd need to manually validate request bodies and keep client-side types in sync.

No fetch boilerplate. Calling a server action from a client component is a function call. No fetch(), no URL construction, no JSON serialization, no Content-Type headers. Next.js handles the RPC transport automatically.

Automatic revalidation. revalidatePath() inside a server action tells Next.js to refetch the data for that page. The client gets fresh data without explicitly re-querying. This is the mechanism that makes optimistic UI possible without a state management library.

The tradeoff: server actions are Next.js-specific. If you wanted to call these mutations from a mobile app or a third-party integration, you'd need to wrap them in API routes anyway. Compass handles this by having the Capacitor mobile app load the web UI directly (it's a WebView, so server actions work normally) and by exposing dedicated API routes only for external integrations (NetSuite callbacks, push notification registration).

accessing environment

Cloudflare Workers don't have process.env. Environment variables come from the Cloudflare runtime context:

const { env } = await getCloudflareContext()
const db = getDb(env.DB)

For non-D1 environment variables (API keys, secrets), the common pattern casts env to a string record:

const envRecord = env as unknown as Record<string, string>
const apiKey = envRecord.OPENROUTER_API_KEY

The double cast (as unknown as Record<string, string>) is necessary because the CloudflareEnv type is auto-generated and doesn't include manually-set secrets. This is the one place where the TypeScript discipline bends.

all action files

25 files in src/app/actions/, grouped by domain:

core platform

  • agent.ts -- AI chat persistence (save/load/delete conversations)
  • agent-items.ts -- agent-created items (todos, notes, checklists)
  • ai-config.ts -- model configuration (get/set global model, user preferences, usage stats)
  • dashboards.ts -- custom dashboard CRUD (save/load/delete/execute queries)
  • github.ts -- GitHub API proxy (repo stats, commits, PRs, issues, create issues)
  • memories.ts -- persistent memory CRUD (save, search, pin, delete)
  • plugins.ts -- skill/plugin management (install, uninstall, toggle, list)
  • profile.ts -- user profile updates
  • themes.ts -- theme preference management (get/set preference, save/delete custom themes)
  • users.ts -- user administration (list, update roles, deactivate)

domain: people and orgs

  • customers.ts -- customer CRUD (create, read, update, delete)
  • vendors.ts -- vendor CRUD
  • organizations.ts -- organization management
  • teams.ts -- team CRUD and membership
  • groups.ts -- group CRUD and membership

domain: projects and scheduling

  • projects.ts -- project listing (currently read-only, creation happens through the UI)
  • schedule.ts -- schedule task CRUD (create, update, delete, reorder, bulk update)
  • baselines.ts -- schedule baseline snapshots (save, load, delete)
  • workday-exceptions.ts -- calendar exceptions (holidays, non-working days)

domain: financials

  • invoices.ts -- invoice CRUD
  • vendor-bills.ts -- vendor bill CRUD
  • payments.ts -- payment recording
  • credit-memos.ts -- credit memo management

integrations

  • netsuite-sync.ts -- sync triggers, connection status, conflict resolution
  • google-drive.ts -- 17 actions covering connect, disconnect, list, search, create, rename, move, trash, restore, upload, and more

the revalidation pattern

After every mutation, the action calls revalidatePath() to tell Next.js which pages have stale data:

revalidatePath("/dashboard/customers")       // specific page
revalidatePath("/dashboard/customers", "page") // just the page, not the layout
revalidatePath("/", "layout")                 // the entire app (nuclear option)

This is what keeps the UI in sync without client-side state management. When createCustomer() returns { success: true }, the customer list page already has a pending revalidation. The next render will show the new customer.

The pattern avoids over-revalidation. Each action revalidates only the paths it affects. createCustomer() revalidates /dashboard/customers, not the entire dashboard. This keeps cache invalidation surgical.

the permission check

Most mutation actions call requirePermission() before doing anything:

const user = await getCurrentUser()
requirePermission(user, "customer", "create")

This throws an error if the user's role doesn't include the requested permission. The error is caught by the try/catch wrapper and returned as { success: false, error: "Permission denied: ..." }.

Read-only actions use requirePermission with the "read" action. Some actions (like getProjects()) skip the permission check entirely because they return minimal data (just IDs and names) that's needed for UI dropdowns regardless of role.

The permission system is documented in detail in auth-system.md.