Custom Storage Adapters
Implement custom database adapters for RoomKit
Overview
RoomKit separates domain logic from database access through abstract storage classes. While the included PrismaRoomKitAdapter covers PostgreSQL via Prisma, you can implement custom adapters for any database (TypeORM, Drizzle, Knex, MongoDB, etc.).
Storage Interfaces
You need to implement 9 required storage interfaces (plus 2 optional ones):
| Interface | Key Methods |
|---|---|
LocationStorage | getById, create, getTree, getAncestors, resolveAlias |
RoomStorage | create, findById, findByCompoundFilter, getPartitionTree, getWithEquipment |
BookingStorage | createWithConflictCheck, findById, updateWithVersion, transition, findByRoom, findByPerson |
RecurrenceStorage | createRule, getRuleById, getInstancesByRule, deleteInstancesByRule |
BlackoutStorage | create, findActiveForScope, getDescendantRooms |
ConflictStorage | create, findByBooking |
ConfigStorage | set, resolve, getAll |
AuditStorage | queryByBooking, queryByTimeRange |
PriorityStorage | getAll, findByName, upsert |
ExamStorage (optional) | createSession, findConflictingCohort, findByBooking |
BulkOperationStorage (optional) | create, updateProgress |
Critical Implementation Contracts
Atomic Conflict Check
BookingStorage.createWithConflictCheck() must atomically create a booking AND verify there are no overlapping bookings. Use a database transaction to prevent race conditions:
class TypeORMBookingStorage extends BookingStorage {
async createWithConflictCheck(
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt'>,
options: { checkPartitions: boolean; checkBuffers: boolean },
): Promise<Booking> {
return this.dataSource.transaction(async (manager) => {
// 1. Check for overlapping bookings (with row lock)
const conflicts = await manager.query(
`SELECT id FROM bookings
WHERE room_id = $1
AND starts_at < $3
AND ends_at > $2
AND status NOT IN ('cancelled', 'completed')
FOR UPDATE`,
[data.roomId, data.startsAt, data.endsAt],
);
if (conflicts.length > 0) {
throw new BookingConflictError({
roomId: data.roomId,
startsAt: data.startsAt.toISOString(),
endsAt: data.endsAt.toISOString(),
conflictingBookingId: conflicts[0].id,
});
}
// 2. Optionally check partition rooms
if (options.checkPartitions) {
// Check parent and child rooms...
}
// 3. Create the booking
return manager.save(BookingEntity, { ...data, version: 1 });
});
}
}Optimistic Concurrency
BookingStorage.updateWithVersion() must only update if the version matches and increment the version. Throw StaleVersionError on mismatch:
async updateWithVersion(
id: string,
changes: Partial<Booking>,
expectedVersion: number,
): Promise<Booking> {
const result = await this.repo.update(
{ id, version: expectedVersion },
{ ...changes, version: expectedVersion + 1 },
);
if (result.affected === 0) {
const current = await this.repo.findOneBy({ id });
throw new StaleVersionError({
bookingId: id,
expectedVersion,
actualVersion: current?.version ?? -1,
});
}
return this.repo.findOneByOrFail({ id });
}State Transition Recording
BookingStorage.transition() must validate the transition via the state machine and create an audit record:
async transition(
id: string,
toStatus: BookingStatus,
triggeredBy: string,
reason?: string,
): Promise<Booking> {
return this.dataSource.transaction(async (manager) => {
const booking = await manager.findOneByOrFail(BookingEntity, { id });
// State machine validates this transition
// (throws InvalidStateTransitionError if invalid)
booking.status = toStatus;
booking.version += 1;
await manager.save(booking);
await manager.save(BookingStateTransitionEntity, {
bookingId: id,
fromStatus: booking.status,
toStatus,
triggeredBy,
reason: reason ?? null,
timestamp: new Date(),
});
return booking;
});
}Registering Custom Storage
Pass individual storage instances instead of the composite adapter:
RoomKitModule.register({
storage: {
location: new TypeORMLocationStorage(dataSource),
room: new TypeORMRoomStorage(dataSource),
booking: new TypeORMBookingStorage(dataSource),
recurrence: new TypeORMRecurrenceStorage(dataSource),
blackout: new TypeORMBlackoutStorage(dataSource),
conflict: new TypeORMConflictStorage(dataSource),
config: new TypeORMConfigStorage(dataSource),
audit: new TypeORMAuditStorage(dataSource),
priority: new TypeORMPriorityStorage(dataSource),
},
});Testing Custom Adapters
Test your adapters against the storage contracts:
describe('TypeORMBookingStorage', () => {
it('should prevent double-booking atomically', async () => {
const storage = new TypeORMBookingStorage(testDataSource);
await storage.createWithConflictCheck(booking1, options);
await expect(
storage.createWithConflictCheck(overlappingBooking, options),
).rejects.toThrow(BookingConflictError);
});
it('should throw StaleVersionError on version mismatch', async () => {
const storage = new TypeORMBookingStorage(testDataSource);
const booking = await storage.createWithConflictCheck(data, options);
await expect(
storage.updateWithVersion(booking.id, changes, booking.version + 99),
).rejects.toThrow(StaleVersionError);
});
});