How to implement a module
Quick step by step guide on how to implement a module
In this example, we're gonna recreate a simple counter module for the Postgres settlement layer.
Step 1: Create a declaration file
Each module has to have a declaration file that contains the extension of the SettlementLayer interface.
This part is settlement layer agnostic and is only defined once, even if you're gonna have different implementations for different settlement layers.
Create a new file in the _fundset/settlement-layer/modules directory with the name of the module and the .d.ts extension:
import { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query';
export type GlobalCounterIncrementEvent = {
amount: number;
timestamp: Date;
by: number;
id: string;
};
export interface CounterModule {
counter: {
isIncrementGlobalCounterReady: boolean;
incrementGlobalCounterMutationOptions: () => UseMutationOptions<void, Error, number>;
globalCounterValueQueryOptions: () => UseQueryOptions<
unknown,
Error,
number | undefined,
QueryKey
>;
globalCounterIncrementEventsQueryOptions: ({
limit,
offset,
}: {
limit: number;
offset: number;
}) => UseQueryOptions<unknown, Error, GlobalCounterIncrementEvent[], QueryKey>;
isIncrementPersonalCounterReady: boolean;
incrementPersonalCounterMutationOptions: () => UseMutationOptions<void, Error, number>;
personalCounterValueQueryOptions: () => UseQueryOptions<
unknown,
Error,
number | undefined,
QueryKey
>;
};
}
declare module 'fundset/settlement-layer' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface SettlementLayer extends CounterModule {}
}Step 2: Create a definition for database schema
If your module needs to store some data in the database, you need to create definitions for database tables. For our example, we're gonna create two tables, one for global counter and the other for personal counters.
import { integer, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
export const countersTable = pgTable('counters', {
userId: varchar({ length: 255 }).notNull().primaryKey(),
value: integer().notNull(),
});
export const globalCounterTable = pgTable('global_counter', {
id: serial('id').primaryKey(),
by: integer().notNull(),
currentGlobalValue: integer().notNull(),
createdAt: timestamp().defaultNow(),
});After that, reexport all the table definitions in the _fundset/settlement-layer/pg/db-schema.ts which is the entrypoint for Drizzle ORM:
export * from '../modules/counter/pg/db-schema';Step 3: Implement the module for every supported settlement layer
Inside the settlement-layer folder, there's a modules directory. In the directory called counter, we'll create a new directory called pg (for the settlement layer name we're implementing the module for). And we'll create two new files inside it, one for logic implementation with oRPC and the second one for assembling our new module.
In orpc client, we have to implement the functions to get value and increment the counter. There are two types of counters: global and personal. Global counter is a counter that is shared by all users. Personal counter is a counter that is unique for each user.
In order to implement the personal counter, we need to pass the user id to the function. We're gonna use the authenticatedMiddleware to get the user id from the session. In our case, we're using Better-auth but it can be any other auth provider.
import { os } from '@orpc/server';
import { z } from 'zod';
import { countersTable, globalCounterTable } from '../../../pg/db-schema';
import { desc, eq, sql } from 'drizzle-orm';
import { authenticatedMiddleware, dbProvider } from '../../../pg/orpc/common';
import { GlobalCounterIncrementEvent } from '@/_fundset/settlement-layer/modules/counter/counter';
export const getGlobalCounter = os
.use(dbProvider)
.handler(async ({ context }) => {
const counter = await context.db
.select()
.from(globalCounterTable)
.orderBy(desc(globalCounterTable.createdAt))
.limit(1);
return counter[0]?.currentGlobalValue ?? 0;
})
.callable();
export const getPersonalCounter = os
.use(dbProvider)
.input(z.object({ userId: z.string().optional() }))
.handler(async ({ input, context }) => {
if (!input.userId) {
return 0;
}
const counter = await context.db
.select()
.from(countersTable)
.where(eq(countersTable.userId, input.userId));
return counter[0]?.value ?? 0;
})
.callable();
export const incrementGlobalCounter = os
.use(dbProvider)
.input(z.number())
.handler(async ({ input, context }) => {
const currentGlobalValue = await getGlobalCounter({ context });
await context.db.insert(globalCounterTable).values({
by: input,
currentGlobalValue: currentGlobalValue + input,
});
});
export const incrementPersonalCounter = os
.$context<{ headers: Headers }>()
.use(authenticatedMiddleware)
.use(dbProvider)
.input(z.number())
.handler(async ({ input, context }) => {
await context.db
.insert(countersTable)
.values({ value: input, userId: context.session.user.id })
.onConflictDoUpdate({
target: [countersTable.userId],
set: { value: sql`${countersTable.value} + ${input}` },
});
});
export const getGlobalCounterEvents = os
.use(dbProvider)
.input(z.object({ offset: z.number().default(0), limit: z.number().default(10) }))
.handler(async ({ input, context }) => {
const events = await context.db
.select()
.from(globalCounterTable)
.orderBy(desc(globalCounterTable.createdAt))
.limit(input.limit)
.offset(input.offset);
return events.map(
event =>
({
amount: event.currentGlobalValue,
timestamp: event.createdAt ?? new Date(),
by: event.by,
id: event.id.toString(),
}) as GlobalCounterIncrementEvent,
);
});
export const counterModule = {
globalCounter: {
get: getGlobalCounter,
increment: incrementGlobalCounter,
getEvents: getGlobalCounterEvents,
},
personalCounter: { get: getPersonalCounter, increment: incrementPersonalCounter },
};Now we have to extend the oRPC router with our new functions:
import { counterModule } from '../../modules/counter/pg/orpc';
export const router = {
...counterModule,
};Now we need to assemble the module. We're gonna use the buildCounterModule function to build the module.
Normally, we'd have to construct mutation and query options for each function, but luckilly oRPC has oRPCQueryUtils helper to get the options:
import { CounterModule } from '@/_fundset/settlement-layer/modules/counter/counter';
import { oRPCQueryUtils } from '../../../pg/orpc/client';
import { authClient } from '@/lib/auth-client';
export const buildCounterModule = ({
session,
}: {
session: ReturnType<typeof authClient.useSession>['data'];
}) => {
return {
counter: {
isIncrementGlobalCounterReady: true,
incrementGlobalCounterMutationOptions: () =>
oRPCQueryUtils.globalCounter.increment.mutationOptions({
meta: {
invalidatesQueries: [oRPCQueryUtils.globalCounter.get.queryOptions().queryKey],
},
}),
globalCounterValueQueryOptions: () => oRPCQueryUtils.globalCounter.get.queryOptions(),
isIncrementPersonalCounterReady: !!session?.user.id,
incrementPersonalCounterMutationOptions: () =>
oRPCQueryUtils.personalCounter.increment.mutationOptions({
meta: {
invalidatesQueries: [
oRPCQueryUtils.personalCounter.get.queryOptions({
input: {
userId: session?.user.id,
},
}).queryKey,
],
},
}),
personalCounterValueQueryOptions: () =>
oRPCQueryUtils.personalCounter.get.queryOptions({
input: {
userId: session?.user.id,
},
}),
globalCounterIncrementEventsQueryOptions: ({ limit, offset }) =>
oRPCQueryUtils.globalCounter.getEvents.queryOptions({
input: {
limit,
offset,
},
}),
},
} satisfies CounterModule;
};Step 4: Inject the module into the settlement layer
Each settlement layer has its own builder function that is responsible for injecting implementation of the modules into the settlement layer object. We need to extend the SL object object with the module implementation:
import type { authClient } from '@/lib/auth-client';
import { buildCounterModule } from '../modules/counter/pg/build';
export const buildPgSettlementLayer = ({
session,
}: {
session: ReturnType<typeof authClient.useSession>['data'];
}) => {
const pgSettlementLayer = {
name: 'pg',
...buildCounterModule({ session }),
};
return { pgSettlementLayer };
};
export default buildPgSettlementLayer;Step 5: Use the module in your application
Congrats! Now you can use the module in your application code:
import { useQuery, useMutation } from '@tanstack/react-query';
import { useSettlementLayer } from '@/_fundset/settlement-layer';
export default function Home() {
const {
counter: { incrementGlobalCounterMutationOptions, globalCounterValueQueryOptions },
} = useSettlementLayer();
const { data: globalCounterValue } = useQuery(globalCounterValueQueryOptions());
const { mutate: incrementGlobalCounter } = useMutation(incrementGlobalCounterMutationOptions());
return (
<>
<p>Global counter value: {globalCounterValue}</p>
<button onClick={() => incrementGlobalCounter(1)}>Increment global counter</button>
</>
);
}