repository-pattern
npx skills add https://github.com/alexanderop/workouttracker --skill repository-pattern
Agent 安装分布
Skill 文档
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)