Repository Pattern
Overview
This skill helps implement the repository pattern used in this workout tracker application. The pattern provides a clean abstraction over Dexie (IndexedDB) with type-safe interfaces, consistent error handling, and standardized CRUD operations.
Architecture Overview
Layered Approach:
Interfaces → Implementations → Provider → Public API ↓ ↓ ↓ ↓ db/interfaces db/impl/dexie db/provider db/index
Flow:
-
Define repository interface in db/interfaces.ts
-
Implement with Dexie in db/implementations/dexie/[entity].ts
-
Register in factory provider (db/implementations/dexie/index.ts )
-
Export public getter (db/index.ts )
-
Use in features via getEntityRepository()
Core Workflow
Follow these 6 steps when creating a new repository:
Step 1: Define Interface
Location: src/db/interfaces.ts
Define the repository interface with standard CRUD methods:
export type EntityRepository = { getAll(): Promise<ReadonlyArray<DbEntity>> getById(id: string): Promise<DbEntity | undefined> create(entity: Omit<DbEntity, 'id' | 'createdAt'>): Promise<DbEntity> update(id: string, updates: Partial<Omit<DbEntity, 'id' | 'createdAt'>>): Promise<void> delete(id: string): Promise<void> // ... entity-specific queries }
Add to RepositoryProvider :
export type RepositoryProvider = { // ... existing entity: EntityRepository }
Guidelines:
-
Use ReadonlyArray<T> and Readonly<T> for arguments
-
getById returns undefined (not throw) when not found
-
create returns the created entity with generated ID
-
update and delete return void
-
Use Omit<> to exclude auto-generated fields (id , createdAt )
Step 2: Add Schema Type
Location: src/db/schema.ts
Define database type with Db prefix:
/**
- Entity stored in database.
- Uses null instead of undefined for explicit "no value" semantics. */ export type DbEntity = { id: string name: string value: string | null // Use null, not undefined createdAt: number updatedAt: number | null // null until first update }
Key Conventions:
-
Always use Db prefix for database types
-
Use null for "no value" (not undefined )
-
Store user input numbers as string (e.g., kg: string , reps: string )
-
Use discriminated unions with kind property for variants
-
Include type guards if needed: export function isDbEntity(x: unknown): x is DbEntity
Step 3: Update Database Class
Location: src/db/implementations/dexie/database.ts
Add table and indexes:
export class WorkoutTrackerDb extends Dexie { // ... existing tables entities!: Table<DbEntity, string>
constructor() { super('WorkoutTracker')
// Increment version number
this.version(3).stores({
// ... existing tables
entities: 'id, name, createdAt', // Index: primary + frequently queried fields
})
} }
Indexing Guidelines:
-
Always index primary key (automatic)
-
Index fields used in where() , orderBy() , equals()
-
Index foreign keys for joins
-
Compound indexes for junction tables: '[field1+field2], field1, field2'
Step 4: Implement Repository
Location: src/db/implementations/dexie/[entity].ts
Create factory function returning repository implementation:
import type { EntityRepository } from '@/db/interfaces' import type { DbEntity } from '@/db/schema' import { createDatabaseError, tryCatch } from '@/lib/tryCatch' import type { WorkoutTrackerDb } from './database' import { generateId } from './database'
/**
-
Dexie implementation of EntityRepository. */ export function createDexieEntityRepository(db: WorkoutTrackerDb): EntityRepository { return { async getAll(): Promise<ReadonlyArray<DbEntity>> { const [error, entities] = await tryCatch( db.entities.orderBy('createdAt').reverse().toArray(), ) if (error) { throw createDatabaseError('LOAD_FAILED', 'retrieve entities', error) } return entities },
async getById(id: string): Promise<DbEntity | undefined> { const [error, entity] = await tryCatch(db.entities.get(id)) if (error) { throw createDatabaseError('LOAD_FAILED',
retrieve entity with id ${id}, error) } return entity },async create( entity: Omit<DbEntity, 'id' | 'createdAt'>, ): Promise<DbEntity> { const newEntity: DbEntity = { ...entity, id: generateId(), createdAt: Date.now(), }
const [error] = await tryCatch(db.entities.add(newEntity)) if (error) { throw createDatabaseError('SAVE_FAILED', 'create entity', error) }
return newEntity },
async update( id: string, updates: Partial<Omit<DbEntity, 'id' | 'createdAt'>>, ): Promise<void> { const [error, updatedCount] = await tryCatch( db.entities.update(id, { ...updates, updatedAt: Date.now(), // Auto-inject timestamp }), )
if (error) { throw createDatabaseError('SAVE_FAILED',
update entity with id ${id}, error) }if (updatedCount === 0) { throw createDatabaseError('NOT_FOUND',
entity with id ${id} not found) } },async delete(id: string): Promise<void> { const [error] = await tryCatch(db.entities.delete(id)) if (error) { throw createDatabaseError('SAVE_FAILED',
delete entity with id ${id}, error) } // Soft delete: no NOT_FOUND check }, } }
Key Patterns:
-
Use tryCatch() wrapper for all operations (preferred pattern)
-
Two-phase error checking: operation failure + not found
-
Auto-inject timestamps: createdAt , updatedAt
-
Use generateId() for new IDs
-
Soft delete: no error if entity doesn't exist
Step 5: Register in Factory Provider
Location: src/db/implementations/dexie/index.ts
Import and add to provider:
import { createDexieEntityRepository } from './entity'
export function createDexieRepositoryProvider(): RepositoryProvider { return { activeWorkout: createDexieActiveWorkoutRepository(db), workouts: createDexieWorkoutsRepository(db), // ... existing repositories entity: createDexieEntityRepository(db), // ADD THIS } }
Step 6: Export Public Getter
Location: src/db/index.ts
Add getter function:
export function getEntityRepository(): EntityRepository { return getRepositoryProvider().entity }
Usage in Features
import { getEntityRepository } from '@/db' import type { DbEntity } from '@/db/schema'
export function useEntities() { const entities = ref<ReadonlyArray<DbEntity>>([]) const entityRepo = getEntityRepository()
async function loadEntities() { entities.value = await entityRepo.getAll() }
async function createEntity(name: string, value: string | null) { const newEntity = await entityRepo.create({ name, value, updatedAt: null }) entities.value = [...entities.value, newEntity] }
async function updateEntity(id: string, updates: Partial<DbEntity>) { await entityRepo.update(id, updates) await loadEntities() }
async function deleteEntity(id: string) { await entityRepo.delete(id) entities.value = entities.value.filter(e => e.id !== id) }
onMounted(() => loadEntities())
return { entities: readonly(entities), createEntity, updateEntity, deleteEntity, } }
Key Principles
- Error Handling
Preferred: tryCatch wrapper
const [error, result] = await tryCatch(operation) if (error) { throw createDatabaseError('ERROR_CODE', 'description', error) }
Error codes:
-
SAVE_FAILED
-
Create, update, delete operations
-
LOAD_FAILED
-
Read operations
-
NOT_FOUND
-
Entity doesn't exist
- Timestamp Management
Auto-inject timestamps in repository methods:
-
createdAt: Date.now() in create()
-
updatedAt: Date.now() in update()
-
lastUsedAt: Date.now() when accessing entity
- ID Generation
Always use generateId() from database.ts :
import { generateId } from './database'
const newEntity = { ...entity, id: generateId(), // crypto.randomUUID() }
- Soft Delete
Delete operations don't throw if entity doesn't exist:
async delete(id: string): Promise<void> { await tryCatch(db.entities.delete(id)) // No NOT_FOUND check - silent success }
- Type Safety
-
Use Readonly<T> and ReadonlyArray<T> for function parameters
-
Use Omit<> to exclude auto-generated fields
-
Use discriminated unions with exhaustive checking
-
Define type guards for runtime type checking
File Reference
Critical files when creating repository:
-
src/db/interfaces.ts
-
Interface definition + RepositoryProvider
-
src/db/schema.ts
-
Db-prefixed type definitions
-
src/db/implementations/dexie/database.ts
-
Table + indexes
-
src/db/implementations/dexie/[entity].ts
-
Implementation
-
src/db/implementations/dexie/index.ts
-
Factory registration
-
src/db/index.ts
-
Public getter export
Utility imports:
-
@/lib/tryCatch
-
Error handling utilities
-
@/db/implementations/dexie/database
-
generateId()
Detailed References
For complete examples and advanced patterns, see:
references/examples.md - Complete end-to-end examples:
-
Example 1: Simple CRUD repository (Notes)
-
Example 2: Complex transformations (Tags with many-to-many)
-
Example 3: Extending Settings with function overloads
references/patterns.md - Detailed pattern catalog:
-
Error handling patterns (direct throw vs tryCatch)
-
CRUD patterns (getAll, create, update, delete, timestamps)
-
Type transformation patterns (helper utilities, deep cloning)
-
Advanced patterns (function overloads, singleton, transactions, bulk ops)
-
Schema design patterns (discriminated unions, indexing, embedded vs referenced)
Common Scenarios
Scenario 1: Simple CRUD Repository
Need basic storage for an entity? See examples.md → Example 1 (Notes).
Quick checklist:
-
Define interface with getAll/getById/create/update/delete
-
Add DbEntity type with Db prefix
-
Add table with indexes
-
Implement using tryCatch pattern
-
Register and export
Scenario 2: Complex Relationships
Need many-to-many relationships or complex queries? See examples.md → Example 2 (Tags).
Pattern: Junction table + transaction handling + usage tracking.
Scenario 3: Extending Settings
Adding new setting? See examples.md → Example 3.
Pattern: Add discriminated union member + function overload + default value.
Scenario 4: Conversions Between Types
Need to convert between templates and workouts? See patterns.md → Type Transformation Patterns.
Pattern: Helper utilities with exhaustive switch statements.
Scenario 5: Bulk Operations
Import/export or batch delete? See patterns.md → Advanced Patterns → Bulk Operations.
Pattern: Transactions + Promise.all()
- bulkAdd() .
Testing Support
Mock repositories for unit tests:
import { createMockRepositories } from '@/tests/helpers/mockRepositories'
const mockRepos = createMockRepositories() mockRepos.entity.getAll.mockResolvedValue([...])
Integration tests with fake-indexeddb are automatically set up via test helpers.
Migration Strategy
When updating schema version:
-
Increment version number in database.ts
-
Add new table/indexes in .stores({})
-
Dexie handles migrations automatically
-
For data migrations, use .upgrade() callback
this.version(3) .stores({ entities: 'id, name, createdAt', }) .upgrade(tx => { // Optional data migration logic return tx.table('entities').toCollection().modify(entity => { entity.newField = 'default' }) })
Project-Specific Repositories
Db* Types vs Domain Types
Aspect Database (Db* ) Domain
File src/db/schema.ts
src/types/
Prefix DbWorkout , DbSet
Workout , Set
No value null
undefined
Optimized for Storage App logic
Available Repositories
SettingsRepository - Key-value store with defaults:
const repo = getSettingsRepository() await repo.get('theme') // 'light' | 'dark' | 'system' await repo.get('defaultRestTimer') // number await repo.set({ key: 'theme', value: 'dark' }) await repo.getAll() // All settings merged with defaults await repo.reset('theme')
CustomExercisesRepository - Exercise CRUD:
const repo = getCustomExercisesRepository() await repo.getAll() await repo.getById(id) await repo.add({ id: generateId(), name: 'Squat', ... }) await repo.update(id, { name: 'Back Squat' }) await repo.delete(id)
WorkoutsRepository - Completed workouts:
const repo = getWorkoutsRepository() await repo.getAll() await repo.getById(id) await repo.create(convertWorkoutToDb(workout)) await repo.delete(id)
ActiveWorkoutRepository - Singleton active workout:
const repo = getActiveWorkoutRepository() await repo.load() await repo.save(dbActiveWorkout) await repo.delete() await repo.exists()
BenchmarksRepository - Benchmark workouts:
const repo = getBenchmarksRepository() await repo.getAll() await repo.getById(id) await repo.create({ id: generateId(), name: 'Fran', ... }) await repo.update(id, { name: 'Fran (Scaled)' }) await repo.delete(id)
TemplatesRepository - Workout templates:
const repo = getTemplatesRepository() await repo.getAll() await repo.getById(id) await repo.create(template) await repo.update(id, changes) await repo.delete(id)
Using Converters
Always convert when crossing domain/database boundary:
import { convertWorkoutToDb, convertDbToWorkout } from '@/db/converters'
// Domain → Database const dbWorkout = convertWorkoutToDb(workout) await getWorkoutsRepository().create(dbWorkout)
// Database → Domain const dbWorkout = await getWorkoutsRepository().getById(id) const workout = convertDbToWorkout(dbWorkout)
Partial Updates with buildPartialUpdate
Dexie's update() overwrites all keys in the object. Use buildPartialUpdate to only modify provided fields:
import { buildPartialUpdate } from '@/db/partialUpdate'
const NULLABLE_FIELDS = ['equipment', 'muscle', 'image']
// Only includes keys present in updates // Converts undefined → null for nullable fields const dbUpdates = buildPartialUpdate(updates, NULLABLE_FIELDS) await repo.update(id, dbUpdates)
Why: Without filtering, { name: 'Squat', equipment: undefined } would set equipment to null even if you only meant to update the name.
Project-Specific Gotchas
- Use null in Database, undefined in Domain
IndexedDB doesn't support undefined :
// Database types type DbExercise = { equipment: Equipment | null // Use null }
// Domain types type Exercise = { equipment?: Equipment // Use undefined }
- Always Reset Database in Tests
import { resetDatabase } from '@/tests/setup'
beforeEach(async () => { await resetDatabase() })
- Convert Types at Boundaries
// BAD - Type mismatch await getWorkoutsRepository().create(workout)
// GOOD - Convert first const dbWorkout = convertWorkoutToDb(workout) await getWorkoutsRepository().create(dbWorkout)