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 helpers —
findModifiedSince(),findReviewLogsSince() - Transaction —
transaction()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
idparameter on create inputs is optional — generate one if not provided getDescendantDeckIds()must be recursive (find children of children)countByState()should aggregate across all provideddeckIdstransaction()can be a no-op for in-memory storage