Loading...
Loading...
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.
npx skill4agent add alexanderop/workouttracker repository-patternInterfaces → Implementations → Provider → Public API
↓ ↓ ↓ ↓
db/interfaces db/impl/dexie db/provider db/indexdb/interfaces.tsdb/implementations/dexie/[entity].tsdb/implementations/dexie/index.tsdb/index.tsgetEntityRepository()src/db/interfaces.tsexport 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
}RepositoryProviderexport type RepositoryProvider = {
// ... existing
entity: EntityRepository
}ReadonlyArray<T>Readonly<T>getByIdundefinedcreateupdatedeletevoidOmit<>idcreatedAtsrc/db/schema.tsDb/**
* 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
}Dbnullundefinedstringkg: stringreps: stringkindexport function isDbEntity(x: unknown): x is DbEntitysrc/db/implementations/dexie/database.tsexport 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
})
}
}where()orderBy()equals()'[field1+field2], field1, field2'src/db/implementations/dexie/[entity].tsimport 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
},
}
}tryCatch()createdAtupdatedAtgenerateId()src/db/implementations/dexie/index.tsimport { createDexieEntityRepository } from './entity'
export function createDexieRepositoryProvider(): RepositoryProvider {
return {
activeWorkout: createDexieActiveWorkoutRepository(db),
workouts: createDexieWorkoutsRepository(db),
// ... existing repositories
entity: createDexieEntityRepository(db), // ADD THIS
}
}src/db/index.tsexport function getEntityRepository(): EntityRepository {
return getRepositoryProvider().entity
}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,
}
}const [error, result] = await tryCatch(operation)
if (error) {
throw createDatabaseError('ERROR_CODE', 'description', error)
}SAVE_FAILEDLOAD_FAILEDNOT_FOUNDcreatedAt: Date.now()create()updatedAt: Date.now()update()lastUsedAt: Date.now()generateId()database.tsimport { generateId } from './database'
const newEntity = {
...entity,
id: generateId(), // crypto.randomUUID()
}async delete(id: string): Promise<void> {
await tryCatch(db.entities.delete(id))
// No NOT_FOUND check - silent success
}Readonly<T>ReadonlyArray<T>Omit<>src/db/interfaces.tssrc/db/schema.tssrc/db/implementations/dexie/database.tssrc/db/implementations/dexie/[entity].tssrc/db/implementations/dexie/index.tssrc/db/index.ts@/lib/tryCatch@/db/implementations/dexie/databasePromise.all()bulkAdd()import { createMockRepositories } from '@/__tests__/helpers/mockRepositories'
const mockRepos = createMockRepositories()
mockRepos.entity.getAll.mockResolvedValue([...])database.ts.stores({}).upgrade()this.version(3)
.stores({
entities: 'id, name, createdAt',
})
.upgrade(tx => {
// Optional data migration logic
return tx.table('entities').toCollection().modify(entity => {
entity.newField = 'default'
})
})Db*| Aspect | Database ( | Domain |
|---|---|---|
| File | | |
| Prefix | | |
| No value | | |
| Optimized for | Storage | App logic |
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')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)const repo = getWorkoutsRepository()
await repo.getAll()
await repo.getById(id)
await repo.create(convertWorkoutToDb(workout))
await repo.delete(id)const repo = getActiveWorkoutRepository()
await repo.load()
await repo.save(dbActiveWorkout)
await repo.delete()
await repo.exists()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)const repo = getTemplatesRepository()
await repo.getAll()
await repo.getById(id)
await repo.create(template)
await repo.update(id, changes)
await repo.delete(id)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)update()buildPartialUpdateimport { 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){ name: 'Squat', equipment: undefined }nullundefinedundefined// Database types
type DbExercise = {
equipment: Equipment | null // Use null
}
// Domain types
type Exercise = {
equipment?: Equipment // Use undefined
}import { resetDatabase } from '@/__tests__/setup'
beforeEach(async () => {
await resetDatabase()
})// BAD - Type mismatch
await getWorkoutsRepository().create(workout)
// GOOD - Convert first
const dbWorkout = convertWorkoutToDb(workout)
await getWorkoutsRepository().create(dbWorkout)