DeesseJS RPC

Internal Procedures

Server-only operations

Internal procedures (internalQuery and internalMutation) are server-only operations that are NOT exposed via HTTP. They're ideal for operations that should never be callable from clients.

Why Internal Procedures?

TypeExposed via HTTPUse Case
queryYesClient read operations
mutationYesClient write operations
internalQueryNoServer-only reads
internalMutationNoServer-only writes

Internal Query

To create a server-only read operation, use t.internalQuery(). This demonstrates how you can define a procedure that is inaccessible from client-side code.

// src/api.ts
import { defineContext } from '@deessejs/server'
import { ok } from '@deessejs/fp'

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

// Server-only: not accessible from client
const getSecretData = t.internalQuery({
  handler: async (ctx) => {
    const secrets = await ctx.db.secrets.findAll()
    return ok(secrets)
  },
})

Internal Mutation

To create a server-only write operation, use t.internalMutation(). This shows how you can safely perform privileged operations that should never be exposed to clients.

// src/api.ts
import { ok } from '@deessejs/fp'

// Server-only: not accessible from client
const processPayment = t.internalMutation({
  args: z.object({
    userId: z.number(),
    amount: z.number().positive(),
    currency: z.string().default('USD'),
  }),
  handler: async (ctx, args) => {
    const result = await ctx.payment.process({
      userId: args.userId,
      amount: args.amount,
      currency: args.currency,
    })
    return ok(result)
  },
})

Use Cases

1. Secret Operations

When handling sensitive information like API keys, you should use internalQuery to keep secrets server-only. This prevents accidentally exposing privileged data to clients.

// Don't - secret exposed to client
const getApiKeys = t.query({
  handler: async (ctx) => {
    return ok(await ctx.db.apiKeys.findAll())
  },
})

// Do - internal keeps secrets server-only
const getApiKeys = t.internalQuery({
  handler: async (ctx) => {
    return ok(await ctx.db.apiKeys.findAll())
  },
})

2. Admin Operations

When you need to perform administrative tasks like deleting users, use internalMutation to ensure these privileged operations are only callable from trusted server-side code.

const deleteUser = t.internalMutation({
  args: z.object({ userId: z.number() }),
  handler: async (ctx, args) => {
    // Admin-only logic
    await ctx.db.users.delete(args.userId)
    return ok({ deleted: true })
  },
})

3. Cross-Service Communication

When other services need to trigger internal operations, use internalMutation to expose a secure entry point that clients cannot access.

// Called internally by other services
const syncExternalData = t.internalMutation({
  args: z.object({ source: z.string() }),
  handler: async (ctx, args) => {
    const data = await fetchExternalData(args.source)
    await ctx.db.externalData.upsert(data)
    return ok({ synced: true })
  },
})

4. Database Transactions

When you need atomic operations across multiple tables, use internalMutation with transaction support. This lets you safely perform complex writes that must succeed or fail as a single unit.

const transferFunds = t.internalMutation({
  args: z.object({
    fromUserId: z.number(),
    toUserId: z.number(),
    amount: z.number().positive(),
  }),
  handler: async (ctx, args) => {
    await ctx.db.transaction(async (trx) => {
      await trx.users.decrementBalance(args.fromUserId, args.amount)
      await trx.users.incrementBalance(args.toUserId, args.amount)
    })
    return ok({ success: true })
  },
})

Security Model

Internal procedures are excluded from the public API by design. This example shows how createPublicAPI only exports regular queries and mutations, keeping internal procedures server-only.

// Public API (createPublicAPI) only exports query and mutation
// internalQuery and internalMutation are excluded

export const api = createAPI({
  router: appRouter,
})
// Only users.get, users.create, etc. are exposed
// internal procedures are NOT accessible

Calling Internal Procedures

When you need to call internal procedures from other procedures, you can invoke them directly through the context. This example shows how a public query can securely call internal operations.

// src/api.ts
const getUserStats = t.query({
  args: z.object({ userId: z.number() }),
  handler: async (ctx, args) => {
    // Call internal procedure internally
    const user = await ctx.db.internal.getUser(args.userId)
    const orders = await ctx.db.internal.getOrders(args.userId)
    return ok({ user, orders })
  },
})

Anti-Patterns

Don't Expose Sensitive Data via Query

When you accidentally expose sensitive fields like passwords or API keys in a public query, clients can access privileged information. Use internalQuery for sensitive data instead.

// Don't - sensitive data exposed
const getUser = t.query({
  handler: async (ctx) => {
    return ok({
      password: ctx.user.password,
      apiKeys: await ctx.db.apiKeys.findAll(),
    })
  },
})

Don't Use internalQuery for Client Data

When a query result is needed by clients, use query instead of internalQuery. Internal queries are only for server-side operations, so using them for client-required data creates an accessibility problem.

// Don't - client needs this data
const getUserProfile = t.internalQuery({
  handler: async (ctx) => {
    return ok(await ctx.db.users.find(ctx.user.id))
  },
})

// Do - use public query
const getUserProfile = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    return ok(await ctx.db.users.find(args.id))
  },
})

Next Steps

On this page