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>andReadonly<T>for arguments - •
getByIdreturnsundefined(not throw) when not found - •
createreturns the created entity with generated ID - •
updateanddeletereturnvoid - •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
Dbprefix for database types - •Use
nullfor "no value" (notundefined) - •Store user input numbers as
string(e.g.,kg: string,reps: string) - •Use discriminated unions with
kindproperty 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
1. 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
2. Timestamp Management
Auto-inject timestamps in repository methods:
- •
createdAt: Date.now()increate() - •
updatedAt: Date.now()inupdate() - •
lastUsedAt: Date.now()when accessing entity
3. ID Generation
Always use generateId() from database.ts:
import { generateId } from './database'
const newEntity = {
...entity,
id: generateId(), // crypto.randomUUID()
}
4. 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
}
5. Type Safety
- •Use
Readonly<T>andReadonlyArray<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
1. 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
}
2. Always Reset Database in Tests
import { resetDatabase } from '@/__tests__/setup'
beforeEach(async () => {
await resetDatabase()
})
3. Convert Types at Boundaries
// BAD - Type mismatch await getWorkoutsRepository().create(workout) // GOOD - Convert first const dbWorkout = convertWorkoutToDb(workout) await getWorkoutsRepository().create(dbWorkout)