My App
CourseKitGuides

Custom Storage Adapter

Build a custom storage adapter for any database

Overview

CourseKit uses abstract storage classes as its storage interface. You can implement these classes to use any database — SQL, NoSQL, in-memory, or a remote API.

Step 1: Choose Your Storage Classes

CourseKit has 8 storage interfaces. You must implement at least 7 (all except LocationDistanceStorage, which is optional):

InterfaceManages
TimetableEventStorageEvents, exceptions, instructor/group associations
RoomStorageRooms
InstructorStorageInstructors
GroupStorageGroups and student memberships
AvailabilityStorageAvailability rules
AcademicPeriodStorageAcademic periods
CourseStorageCourses
LocationDistanceStorageCampus travel distances (optional)

Step 2: Implement TimetableEventStorage

This is the most complex adapter with 12 abstract methods:

import { TimetableEventStorage } from '@hfu.digital/coursekit-nestjs';
import type {
    TimetableEvent, EventException, EventInstructor, EventGroup,
    ScheduleQuery,
} from '@hfu.digital/coursekit-nestjs';

export class DrizzleTimetableEventAdapter extends TimetableEventStorage {
    constructor(private readonly db: DrizzleDB) {
        super();
    }

    async create(data: Omit<TimetableEvent, 'id' | 'createdAt' | 'updatedAt' | 'version'>) {
        const [event] = await this.db.insert(events).values({
            ...data,
            id: crypto.randomUUID(),
            version: 0,
        }).returning();
        return event;
    }

    async findById(id: string) {
        return this.db.query.events.findFirst({ where: eq(events.id, id) }) ?? null;
    }

    async findByQuery(query: ScheduleQuery) {
        // Build WHERE clauses from dateRange + filters
        // Return matching events
    }

    async update(id: string, data: Partial<TimetableEvent>, expectedVersion?: number) {
        if (expectedVersion !== undefined) {
            // Check version for optimistic concurrency
        }
        const [updated] = await this.db.update(events)
            .set({ ...data, updatedAt: new Date() })
            .where(eq(events.id, id))
            .returning();
        return updated;
    }

    async delete(id: string) {
        await this.db.delete(events).where(eq(events.id, id));
    }

    // Exception management
    async createException(data: Omit<EventException, 'id'>) { /* ... */ }
    async findExceptions(eventId: string) { /* ... */ }
    async deleteException(id: string) { /* ... */ }

    // Instructor associations
    async addInstructor(data: Omit<EventInstructor, 'id'>) { /* ... */ }
    async removeInstructor(eventId: string, instructorId: string) { /* ... */ }
    async findInstructors(eventId: string) { /* ... */ }

    // Group associations
    async addGroup(data: Omit<EventGroup, 'id'>) { /* ... */ }
    async removeGroup(eventId: string, groupId: string) { /* ... */ }
    async findGroups(eventId: string) { /* ... */ }
}

Step 3: Implement Simpler Adapters

The remaining adapters are straightforward CRUD. For example:

import { RoomStorage } from '@hfu.digital/coursekit-nestjs';
import type { Room } from '@hfu.digital/coursekit-nestjs';

export class DrizzleRoomAdapter extends RoomStorage {
    constructor(private readonly db: DrizzleDB) {
        super();
    }

    async create(data: Omit<Room, 'id'>) {
        const [room] = await this.db.insert(rooms)
            .values({ ...data, id: crypto.randomUUID() })
            .returning();
        return room;
    }

    async findById(id: string) {
        return this.db.query.rooms.findFirst({ where: eq(rooms.id, id) }) ?? null;
    }

    async findAll(filters?) {
        let query = this.db.select().from(rooms);
        if (filters?.building) query = query.where(eq(rooms.building, filters.building));
        if (filters?.minCapacity) query = query.where(gte(rooms.capacity, filters.minCapacity));
        return query;
    }

    async update(id: string, data: Partial<Room>) {
        const [updated] = await this.db.update(rooms)
            .set(data)
            .where(eq(rooms.id, id))
            .returning();
        return updated;
    }

    async delete(id: string) {
        await this.db.delete(rooms).where(eq(rooms.id, id));
    }
}

Step 4: Register with CourseKit

import { CourseKitModule } from '@hfu.digital/coursekit-nestjs';

@Module({
    imports: [
        CourseKitModule.register({
            eventStorage: new DrizzleTimetableEventAdapter(db),
            roomStorage: new DrizzleRoomAdapter(db),
            instructorStorage: new DrizzleInstructorAdapter(db),
            groupStorage: new DrizzleGroupAdapter(db),
            availabilityStorage: new DrizzleAvailabilityAdapter(db),
            periodStorage: new DrizzlePeriodAdapter(db),
            courseStorage: new DrizzleCourseAdapter(db),
        }),
    ],
})
export class AppModule {}

Tips

  • Use crypto.randomUUID() for ID generation
  • findByQuery() must filter by dateRange at minimum — the other filters are optional
  • AvailabilityStorage.findByEntityInRange() should return rules whose time windows overlap the given date range
  • GroupStorage.findStudents() returns StudentGroup[] records, not Student[] objects
  • The expectedVersion parameter on TimetableEventStorage.update() is optional — skip it if your database doesn't support optimistic concurrency

Testing Custom Adapters

Use the in-memory adapters from @hfu.digital/coursekit-nestjs/testing as a reference implementation. They show the expected behavior for every method.

On this page