My App
RoomKitGuides

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):

InterfaceKey Methods
LocationStoragegetById, create, getTree, getAncestors, resolveAlias
RoomStoragecreate, findById, findByCompoundFilter, getPartitionTree, getWithEquipment
BookingStoragecreateWithConflictCheck, findById, updateWithVersion, transition, findByRoom, findByPerson
RecurrenceStoragecreateRule, getRuleById, getInstancesByRule, deleteInstancesByRule
BlackoutStoragecreate, findActiveForScope, getDescendantRooms
ConflictStoragecreate, findByBooking
ConfigStorageset, resolve, getAll
AuditStoragequeryByBooking, queryByTimeRange
PriorityStoragegetAll, 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);
    });
});

On this page