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.
// 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:
name(string): The wire identifier prefix. The generated stubs will be routed asusers.get,users.create, etc.initFactory(() => T): A factory function that returns the initial state for the entity. Like theinitoption in standard functions, this seeds theIRPCReader.databefore the server responds, allowing your UI to render synchronously withoutundefinedchecks.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:
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>);
});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:
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:
// 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:
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:
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.
// 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:
| Property | Type | Description |
|---|---|---|
name | string | The entity name (e.g., 'users') |
key | string | The primary key field (defaults to 'id') |
schema | object | The validation schemas provided in options |
maxAge | number | The cache duration provided in options |
Wiring the Adapter
To connect your declared stubs to your driver, use the IRPCCrudAdapter in your server constructor files:
// 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:
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:
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:
// 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:
// 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:
// 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:
// 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', () => [])
};// 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():
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.