Savvi Studio

tRPC Procedure Architecture

Overview

This document describes the architectural pattern for tRPC procedures in this codebase and the ESLint rules that enforce it.

Core Principle

All tRPC procedures must be pure, isomorphic, and dependency-free. Business logic, I/O operations, database access, and external dependencies must flow through the ctx (context) parameter.

Why This Pattern?

  1. Testability: Procedures with minimal imports are easier to test. Dependencies are injected via ctx.
  2. Isomorphism: Procedures can potentially be analyzed, validated, or even run in different contexts.
  3. Security: Prevents procedures from directly accessing sensitive APIs or performing unauthorized operations.
  4. Clarity: Makes it obvious that all side effects happen through controlled channels (ctx).
  5. Dependency Injection: All external dependencies are provided through ctx, making it easy to mock in tests.

Allowed Imports

Procedure files (**/procedures/**/*.ts) may ONLY import:

✅ Always Allowed

  1. Zod - For schema definitions

    import { z } from 'zod';
    
  2. Type-only imports - For TypeScript types

    import type { SomeType } from '@/lib/types';
    
  3. StudioProcedure type - The procedure definition type

    import { StudioProcedure } from '@/lib/api/server/trpc';
    

✅ Permitted With Caution

  • Other isomorphic/safe utility libraries that don't perform I/O
  • Pure utility functions (consider if they should be available via ctx instead)

Blocked Imports

The following imports are explicitly forbidden in procedure files:

❌ Database Access

// ❌ WRONG
import { Pool } from 'pg';
import { query } from '@/lib/db';

// ✅ CORRECT - Use ctx
async execute({ ctx, input }) {
  const result = await ctx.pool.query('SELECT ...');
}

❌ Node.js Built-ins

// ❌ WRONG
import { readFile } from 'fs/promises';
import { randomBytes } from 'crypto';
import { resolve } from 'path';

// ✅ CORRECT - Provide via ctx
async execute({ ctx, input }) {
  const data = await ctx.fs.readFile(input.path);
  const key = await ctx.crypto.randomBytes(32);
}

❌ AWS SDK

// ❌ WRONG
import { KMSClient } from '@aws-sdk/client-kms';

// ✅ CORRECT - Use ctx
async execute({ ctx, input }) {
  const encrypted = await ctx.kms.encrypt(input.data);
}

❌ Other Module Imports

// ❌ WRONG
import { someUtility } from '@/lib/admin';
import something from '@/lib/graph/index';

// ✅ CORRECT - Either:
// 1. Import only types
import type { SomeType } from '@/lib/admin';

// 2. Access via ctx
async execute({ ctx, input }) {
  const result = await ctx.admin.someUtility(input);
}

ESLint Configuration

The import restrictions are enforced by ESLint rules in eslint.config.mjs:

{
  files: ['**/procedures/**/*.ts'],
  rules: {
    'no-restricted-imports': ['error', { /* ... */ }],
    '@typescript-eslint/no-import-type-side-effects': 'error',
  }
}

Adding to the Block List

To block additional imports, edit eslint.config.mjs and add to the paths or patterns array:

paths: [
  {
    name: 'some-package',
    message: 'This package should be provided through ctx.'
  }
]

Adding to the Allow List

If you need to allow a safe, isomorphic library:

  1. First, consider: Should this be provided via ctx instead?
  2. If it's truly safe (no I/O, no side effects), document it here and ensure it's not blocked in the ESLint config.

Example: Compliant Procedure

/**
 * Get Changes In Range Procedure
 */

import { StudioProcedure } from '@/lib/api/server/trpc';
import { z } from 'zod';

const InputSchema = z.object({
  startTime: z.date(),
  endTime: z.date(),
});
type Input = z.infer<typeof InputSchema>;

const OutputSchema = z.array(z.object({
  eventId: z.number(),
  eventTime: z.date(),
  // ...
}));
type Output = z.infer<typeof OutputSchema>;

export default {
  type: 'query' as const,
  input: InputSchema,
  output: OutputSchema,
  async execute({ ctx, input }) {
    // All business logic uses ctx
    const result = await ctx.pool.query(`
      SELECT * FROM audit.events
      WHERE event_time BETWEEN $1 AND $2
    `, [input.startTime, input.endTime]);

    return result.rows.map(row => ({
      eventId: row.event_id,
      eventTime: row.event_time,
      // ...
    }));
  },
} satisfies StudioProcedure<Input, Output>;

Example: Non-Compliant Procedure

/**
 * WRONG - DO NOT DO THIS
 */

import { StudioProcedure } from '@/lib/api/server/trpc';
import { z } from 'zod';
import { Pool } from 'pg'; // ❌ Direct database import
import { randomBytes } from 'crypto'; // ❌ Node.js built-in

const pool = new Pool({ /* ... */ }); // ❌ Global state

export default {
  type: 'mutation' as const,
  input: z.object({ /* ... */ }),
  output: z.object({ /* ... */ }),
  async execute({ ctx, input }) {
    // ❌ Using directly imported dependencies
    const key = randomBytes(32);
    const result = await pool.query('SELECT ...'); 
    
    return { /* ... */ };
  },
} satisfies StudioProcedure<any, any>;

Fixing Violations

When ESLint reports a violation in a procedure file:

  1. Remove the forbidden import
  2. Update the context type to provide the needed functionality
  3. Implement the functionality in the context provider (e.g., in src/lib/api/server/trpc.ts or a context factory)
  4. Use the functionality via ctx in your procedure

Example:

// Before (violation)
import { randomBytes } from 'crypto';

export default {
  async execute({ ctx, input }) {
    const key = randomBytes(32);
    return { key: key.toString('hex') };
  },
};

// After (compliant)
export default {
  async execute({ ctx, input }) {
    const key = await ctx.crypto.generateKey(32);
    return { key };
  },
};

Testing Procedures

With this architecture, procedures are easy to test by mocking the ctx:

import procedure from './myProcedure';

test('myProcedure', async () => {
  const mockCtx = {
    pool: {
      query: vi.fn().mockResolvedValue({ rows: [] }),
    },
    // ... other ctx mocks
  };

  const result = await procedure.execute({
    ctx: mockCtx,
    input: { /* ... */ },
  });

  expect(result).toEqual({ /* ... */ });
});

When to Expand Context

Add new services/utilities to ctx when:

  • Multiple procedures need the same functionality
  • The operation involves I/O or side effects
  • The operation requires configuration or credentials
  • You want centralized control over the operation

Questions?

If you're unsure whether an import is appropriate for a procedure file, ask yourself:

  1. Does this import perform I/O? → Should go through ctx
  2. Does this import maintain state? → Should go through ctx
  3. Does this import require credentials/config? → Should go through ctx
  4. Is this a pure, stateless utility? → May be okay, but consider ctx anyway

When in doubt, provide it through ctx. This maintains the architectural principle and keeps procedures clean and testable.