My App
LoopKitGuides

Custom Storage Adapter

Build a custom storage adapter for any database

Overview

LoopKit uses the abstract LoopKitStorage class as its storage interface. You can implement this class to use any database — SQL, NoSQL, in-memory, or even a remote API.

Step 1: Extend LoopKitStorage

import { LoopKitStorage } from '@loopkit/nestjs';
import type {
    CardBase, NoteBase, NoteType, DeckBase, DeckPreset,
    ReviewLog, DeckCounts,
} from '@loopkit/nestjs';
import type {
    CreateCardInput, UpdateCardInput,
    CreateNoteInput, UpdateNoteInput, NoteFilters,
    CreateDeckInput, UpdateDeckInput, DeckFilters,
    CreatePresetInput, UpdatePresetInput,
    CreateNoteTypeInput, UpdateNoteTypeInput,
    CreateReviewLogInput, ReviewLogFilters,
} from '@loopkit/nestjs';

export class InMemoryStorage extends LoopKitStorage {
    private cards = new Map<string, CardBase>();
    private notes = new Map<string, NoteBase>();
    private noteTypes = new Map<string, NoteType>();
    private decks = new Map<string, DeckBase>();
    private presets = new Map<string, DeckPreset>();
    private reviewLogs = new Map<string, ReviewLog>();

    // Implement all abstract methods...
}

Step 2: Implement Card Operations

These are the most critical methods — they power the review session:

async createCard(input: CreateCardInput): Promise<CardBase> {
    const card: CardBase = {
        id: crypto.randomUUID(),
        noteId: input.noteId,
        templateId: input.templateId,
        deckId: input.deckId,
        state: input.state ?? 'new',
        easeFactor: input.easeFactor ?? 2.5,
        interval: input.interval ?? 0,
        dueDate: input.dueDate ?? new Date(),
        currentStep: input.currentStep ?? 0,
        lapseCount: input.lapseCount ?? 0,
        reviewCount: input.reviewCount ?? 0,
        createdAt: new Date(),
        updatedAt: new Date(),
    };
    this.cards.set(card.id, card);
    return card;
}

async findDueCards(deckIds: string[], now: Date, limit?: number): Promise<CardBase[]> {
    const due = [...this.cards.values()]
        .filter((c) => deckIds.includes(c.deckId) && c.state === 'review' && c.dueDate <= now)
        .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime());
    return limit ? due.slice(0, limit) : due;
}

async findNewCards(deckIds: string[], limit?: number): Promise<CardBase[]> {
    const newCards = [...this.cards.values()]
        .filter((c) => deckIds.includes(c.deckId) && c.state === 'new')
        .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
    return limit ? newCards.slice(0, limit) : newCards;
}

async findLearningCards(deckIds: string[], now: Date): Promise<CardBase[]> {
    return [...this.cards.values()]
        .filter((c) =>
            deckIds.includes(c.deckId) &&
            (c.state === 'learning' || c.state === 'relearning'),
        )
        .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime());
}

Step 3: Implement Remaining Methods

See the Storage Interface reference for the full list of methods to implement. Key groups:

  • Note operations — CRUD for notes
  • Deck operations — CRUD for decks + getDescendantDeckIds()
  • Preset operations — CRUD for deck presets
  • NoteType operations — CRUD for note types
  • ReviewLog operations — CRUD + daily count queries
  • Sync helpersfindModifiedSince(), findReviewLogsSince()
  • Transactiontransaction() for atomic operations

Step 4: Register with LoopKit

import { LoopKitModule } from '@loopkit/nestjs';

@Module({
    imports: [
        LoopKitModule.register({
            storage: new InMemoryStorage(),
        }),
    ],
})
export class AppModule {}

Error Mapping

Your adapter should throw LoopKit errors for standard conditions:

import {
    EntityNotFoundError,
    DuplicateEntityError,
    StorageConnectionError,
    ConcurrencyConflictError,
} from '@loopkit/nestjs';

async getCard(id: string): Promise<CardBase> {
    const card = this.cards.get(id);
    if (!card) throw new EntityNotFoundError('Card', id);
    return card;
}

Tips

  • Use crypto.randomUUID() for ID generation
  • The id parameter on create inputs is optional — generate one if not provided
  • getDescendantDeckIds() must be recursive (find children of children)
  • countByState() should aggregate across all provided deckIds
  • transaction() can be a no-op for in-memory storage

On this page