repository-pattern
Create and manage Dexie/IndexedDB repositories with type-safe interfaces, converters, and standardized CRUD operations. Use when (1) adding entity storage, (2) implementing save/load/delete operations, (3) designing database schema and indexes, (4) converting between database (Db*) and domain types, (5) handling database errors or migrations, (6) using existing repositories (SettingsRepository, WorkoutsRepository, TemplatesRepository, CustomExercisesRepository, BenchmarksRepository, ActiveWorkoutRepository). Triggers include "database", "repository", "save data", "fetch from database", "delete from storage", "database schema", "database table", "indexes", "migration", "persist", "convert workout", "converter", "buildPartialUpdate", "mock repository", "database error", "bulk operations", "import/export", or specific repository names.
$ インストール
git clone https://github.com/alexanderop/workoutTracker /tmp/workoutTracker && cp -r /tmp/workoutTracker/.claude/skills/repository-pattern ~/.claude/skills/workoutTracker// tip: Run this command in your terminal to install the skill
name: repository-pattern description: Create and manage Dexie/IndexedDB repositories with type-safe interfaces, converters, and standardized CRUD operations. Use when (1) adding entity storage, (2) implementing save/load/delete operations, (3) designing database schema and indexes, (4) converting between database (Db*) and domain types, (5) handling database errors or migrations, (6) using existing repositories (SettingsRepository, WorkoutsRepository, TemplatesRepository, CustomExercisesRepository, BenchmarksRepository, ActiveWorkoutRepository). Triggers include "database", "repository", "save data", "fetch from database", "delete from storage", "database schema", "database table", "indexes", "migration", "persist", "convert workout", "converter", "buildPartialUpdate", "mock repository", "database error", "bulk operations", "import/export", or specific repository names.
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 foundcreatereturns the created entity with generated IDupdateanddeletereturnvoid- 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 operationsLOAD_FAILED- Read operationsNOT_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 + RepositoryProvidersrc/db/schema.ts- Db-prefixed type definitionssrc/db/implementations/dexie/database.ts- Table + indexessrc/db/implementations/dexie/[entity].ts- Implementationsrc/db/implementations/dexie/index.ts- Factory registrationsrc/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)
Repository
