Skip to content

CRUD Functions

While irpc.declare() and irpc.construct() give you infinite flexibility, building standard database operations (Create, Read, Update, Delete) across dozens of entities is repetitive.

IRPC provides a specialized CRUD module that abstracts this boilerplate. You declare the entity once, and IRPC automatically generates the network stubs. On the server, an Adapter dynamically routes these stubs to a generic Driver that handles the database execution.

Declaring an Entity

Instead of declaring four separate functions, you use irpc.crud() to batch-declare an entire entity.

typescript
// rpc/users/index.ts
import { irpc } from '../../lib/module.js';

export type User = { id: string; name: string; email: string };

// irpc.crud<T>(name, initFactory, options?)
export const users = irpc.crud<User>('users', () => ({
  id: '', 
  name: 'Loading...', 
  email: '' 
}));

The API Signature

irpc.crud<T> takes three arguments:

  1. name (string): The wire identifier prefix. The generated stubs will be routed as users.get, users.create, etc.
  2. initFactory (() => T): A factory function that returns the initial state for the entity. Like the init option in standard functions, this seeds the IRPCReader.data before the server responds, allowing your UI to render synchronously without undefined checks.
  3. options (object, optional): Base IRPC configurations like caching and validation.

The Generated Stubs

The return value is an object containing four standard IRPCStub instances. Because they are standard stubs, the client consumes them using the exact same reactive bindings (.with(), .once()) used for standard functions:

tsx
import { setup, render } from '@anchorlib/react';
import { users } from './rpc/users/index.js';

export const UserProfile = setup((props: { id: string }) => {
  const user = users.get.with(() => [props.id]);
  return render(() => <div>{user.data?.name}</div>);
});
tsx
import { setup } from '@anchorlib/solid';
import { users } from './rpc/users/index.js';

export const UserProfile = setup((props: { id: string }) => {
  const user = users.get.with(() => [props.id]);
  return <div>{user.data?.name}</div>;
});

Configuration & Exclusions

You can pass standard IRPC options to the entity. These options are inherited by all four generated stubs:

typescript
export const users = irpc.crud<User>('users', () => ({ id: '', name: '', email: '' }), {
  maxAge: 60000, // Cache reads for 60 seconds
  coalesce: true, // Deduplicate concurrent calls
  schema: {
    create: { input: [UserSchema] },
    update: { input: [PartialUserSchema] }
  }
});

Excluding Operations

If an entity should be read-only, or if you simply do not need all four operations, wrap the declaration in irpc.exclude() to drop the unneeded stubs:

typescript
// rpc/audit/index.ts
export const auditLogs = irpc.exclude(
  irpc.crud<AuditLog>('auditLogs', () => ({ id: '', action: '', timestamp: 0 })),
  ['update', 'delete'] 
);

The resulting auditLogs object will only contain the get and create stubs.

Hooks

Spec hooks can be attached to CRUD stubs. Pass the entire group to irpc.hook() and the hook runs before every method:

typescript
const requireAuth = async () => {
  const user = getContext<User>('user');
  if (!user) throw new Error('Unauthorized');
};

// Hooks get, create, update, and delete in one call
irpc.hook(users, requireAuth);

This also works with excluded groups — only the remaining stubs are hooked. For per-method control, hook individual stubs:

typescript
irpc.hook(users.delete, requireAdmin);

The Driver Contract

A driver is a class that executes the database queries. You write the generic logic once, and the adapter dynamically routes all your CRUD entities to it.

To give the driver context about which entity it is currently executing, every method receives an IRPCCrudMeta payload. This allows your generic driver to identify the target table or collection dynamically at runtime.

typescript
// drivers/postgres.ts
import { IRPCCrudDriver, type IRPCCrudMeta } from '@irpclib/irpc';
import { db } from '../db.js';

export class PostgresCrudDriver extends IRPCCrudDriver {
  async get(meta: IRPCCrudMeta, id: string) {
    return db.query(`SELECT * FROM ${meta.name} WHERE ${meta.key} = $1`, [id]);
  }

  async create(meta: IRPCCrudMeta, data: any) {
    return db.insert(meta.name, data);
  }

  async update(meta: IRPCCrudMeta, id: string, data: any) {
    return db.update(meta.name, id, data);
  }

  async delete(meta: IRPCCrudMeta, id: string) {
    return db.delete(meta.name, id);
  }
}

The Metadata Payload

The meta object passed to every driver method contains:

PropertyTypeDescription
namestringThe entity name (e.g., 'users')
keystringThe primary key field (defaults to 'id')
schemaobjectThe validation schemas provided in options
maxAgenumberThe cache duration provided in options

Wiring the Adapter

To connect your declared stubs to your driver, use the IRPCCrudAdapter in your server constructor files:

typescript
// rpc/constructors.ts
import { IRPCCrudAdapter } from '@irpclib/irpc';
import { irpc } from '../lib/module.js';
import { users } from './users/index.js';
import { PostgresCrudDriver } from '../drivers/postgres.js';

const adapter = new IRPCCrudAdapter(irpc);

adapter.use(new PostgresCrudDriver());
adapter.attach(users);

Granular Attachment

You can attach individual methods if you need specific routing. For example, routing get to a read-replica driver, and create to a primary-writer driver:

typescript
adapterRead.attach({ get: users.get });
adapterWrite.attach({ create: users.create, update: users.update });

Chain of Responsibility

The IRPCCrudAdapter allows you to register multiple drivers. When a call arrives, the adapter evaluates the drivers in order.

A driver can either fulfill the request by returning data, or it can explicitly throw IRPCCrudAdapter.next() to pass execution down the chain.

This is the standard pattern for building decoupled caching layers. You register the cache driver before the database driver:

typescript
const adapter = new IRPCCrudAdapter(irpc);

adapter.use(new RedisCacheDriver()); // Evaluated first
adapter.use(new PostgresCrudDriver());   // Fallback if CacheDriver throws next()

adapter.attach(users);

Partial Implementations

A driver does not need to implement every CRUD operation. You can write highly focused drivers that only intercept specific methods.

For example, a cache driver typically only needs to handle get. Instead of writing empty create, update, and delete boilerplate just to manually throw IRPCCrudAdapter.next(), you simply omit them:

typescript
// drivers/redis.ts
import { IRPCCrudAdapter, IRPCCrudDriver, type IRPCCrudMeta } from '@irpclib/irpc';
import { redis } from '../redis.js';

export class RedisCacheDriver extends IRPCCrudDriver {
  // Only the 'get' method is implemented.
  async get(meta: IRPCCrudMeta, id: string) {
    const cached = await redis.get(`${meta.name}:${id}`);
    
    // Cache Hit: Return data, chain stops
    if (cached) return JSON.parse(cached);
    
    // Cache Miss: Throw next(), chain continues to PostgresCrudDriver
    throw IRPCCrudAdapter.next(); 
  }
}

Extending the Adapter

The standard CRUD module provides four basic operations. However, you often need additional generic operations, such as a list method that accepts filters.

Instead of manually constructing list for every entity, you can extend the adapter and driver to add new methods directly to the dispatch chain.

Add the Method to your Driver

Simply add the new method directly to your existing database driver. The adapter's internal dispatch loop resolves methods dynamically at runtime, so no additional interfaces or base classes are required:

typescript
// drivers/postgres.ts
import { IRPCCrudDriver, type IRPCCrudMeta } from '@irpclib/irpc';
import { db } from '../db.js';

export class PostgresCrudDriver extends IRPCCrudDriver {
  async list(meta: IRPCCrudMeta, filters: { status: string }) {
    return db.query(`SELECT * FROM ${meta.name} WHERE status = $1`, [filters.status]);
  }
  
  // ... existing get, create, update, delete methods
}

Extend the Adapter

Next, extend the IRPCCrudAdapter to map the new method to the internal dispatch loop:

typescript
// rpc/adapter.ts
import { IRPCCrudAdapter, type IRPCCrudMeta } from '@irpclib/irpc';

export class ExtendedAdapter extends IRPCCrudAdapter {
  // Expose the list method to the chain
  async list(meta: IRPCCrudMeta, filters: any) {
    return this.dispatch('list', meta, filters);
  }
}

Attach the Custom Method

You can now declare a custom stub and attach it. By passing the method name as the second argument to attach(), the adapter knows to route the stub specifically to your new list method:

typescript
// rpc/users/index.ts
// Declare the custom stub alongside the standard CRUD
export const users = {
  ...irpc.crud<User>('users', () => ({ id: '', name: '', email: '' })),
  list: irpc.declare<(filters: { status: string }) => Promise<User[]>>('users.list', () => [])
};
typescript
// rpc/constructors.ts
const adapter = new ExtendedAdapter(irpc);
adapter.use(new PostgresCrudDriver());

// Attach the standard CRUD stubs (automatically maps get, create, update, delete)
adapter.attach(users);

// Attach the custom list stub (explicitly maps to the 'list' method)
adapter.attach(users.list, 'list');

Manual Construction

Because the stubs generated by irpc.crud() are standard IRPCStubs, you can choose to bypass the adapter entirely and implement them manually using irpc.construct():

typescript
irpc.construct(users.get, async (id) => {
  return db.query('SELECT * FROM users WHERE id = $1', [id]);
});

This is useful if a specific entity has highly specialized logic that doesn't fit neatly into your generic driver pattern.