From 18d5886e409e5f604a89b2ebcdb4582f45a1e15d Mon Sep 17 00:00:00 2001 From: DocNR Date: Tue, 4 Feb 2025 22:53:44 -0500 Subject: [PATCH] sqlite db wip - add exercise template now works --- app.json | 4 +- app/(tabs)/library.tsx | 21 +- app/(workout)/new-exercise.tsx | 261 ++++++++++++++-------- app/_layout.tsx | 22 +- components/library/LibraryContentCard.tsx | 187 +++++++++++----- components/library/MyLibrary.tsx | 20 +- package-lock.json | 18 +- package.json | 3 +- services/LibraryService.ts | 149 +++++++++--- types/sqlite.ts | 2 +- utils/db/db-service.ts | 177 ++++++++++----- utils/db/schema.ts | 1 + 12 files changed, 612 insertions(+), 253 deletions(-) diff --git a/app.json b/app.json index a271c6a..1f4d670 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "expo": { - "name": "powr-rebuild", - "slug": "powr-rebuild", + "name": "POWR", + "slug": "POWR", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx index 2e8c6be..14926a9 100644 --- a/app/(tabs)/library.tsx +++ b/app/(tabs)/library.tsx @@ -187,6 +187,12 @@ export default function LibraryScreen() { } }; + const handleDeleteContent = useCallback((deletedContent: LibraryContent) => { + setContent(prevContent => + prevContent.filter(item => item.id !== deletedContent.id) + ); + }, []); + const handleTabPress = useCallback((index: number) => { pagerRef.current?.setPage(index); setActiveSection(index); @@ -235,13 +241,14 @@ export default function LibraryScreen() { onPageSelected={handlePageSelected} > - + diff --git a/app/(workout)/new-exercise.tsx b/app/(workout)/new-exercise.tsx index 32b969c..50eecf1 100644 --- a/app/(workout)/new-exercise.tsx +++ b/app/(workout)/new-exercise.tsx @@ -1,58 +1,70 @@ // app/(workout)/new-exercise.tsx import React, { useState } from 'react'; import { View, ScrollView, StyleSheet, Platform } from 'react-native'; +import { Picker } from '@react-native-picker/picker'; import { useRouter } from 'expo-router'; import { useColorScheme } from '@/hooks/useColorScheme'; import { ThemedText } from '@/components/ThemedText'; import { Input } from '@/components/form/Input'; -import { Select } from '@/components/form/Select'; import { Button } from '@/components/form/Button'; -import { LibraryService } from '@/services/LibraryService'; +import { libraryService } from '@/services/LibraryService'; import { spacing } from '@/styles/sharedStyles'; -import { generateId } from '@/utils/ids'; +import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; -// Types based on NIP-XX spec -const EQUIPMENT_OPTIONS = [ - { label: 'Barbell', value: 'barbell' }, - { label: 'Dumbbell', value: 'dumbbell' }, - { label: 'Bodyweight', value: 'bodyweight' }, - { label: 'Machine', value: 'machine' }, - { label: 'Cardio', value: 'cardio' } +// Define valid options based on schema and NIP-XX constraints +const EQUIPMENT_OPTIONS: Equipment[] = [ + 'bodyweight', + 'barbell', + 'dumbbell', + 'kettlebell', + 'machine', + 'cable', + 'other' +]; + +const EXERCISE_TYPES: ExerciseType[] = [ + 'strength', + 'cardio', + 'bodyweight' +]; + +const CATEGORIES: ExerciseCategory[] = [ + 'Push', + 'Pull', + 'Legs', + 'Core' ]; const DIFFICULTY_OPTIONS = [ - { label: 'Beginner', value: 'beginner' }, - { label: 'Intermediate', value: 'intermediate' }, - { label: 'Advanced', value: 'advanced' } -]; + 'beginner', + 'intermediate', + 'advanced' +] as const; -const MUSCLE_GROUP_OPTIONS = [ - { label: 'Chest', value: 'chest' }, - { label: 'Back', value: 'back' }, - { label: 'Legs', value: 'legs' }, - { label: 'Shoulders', value: 'shoulders' }, - { label: 'Arms', value: 'arms' }, - { label: 'Core', value: 'core' } -]; +type Difficulty = typeof DIFFICULTY_OPTIONS[number]; -const MOVEMENT_TYPE_OPTIONS = [ - { label: 'Push', value: 'push' }, - { label: 'Pull', value: 'pull' }, - { label: 'Squat', value: 'squat' }, - { label: 'Hinge', value: 'hinge' }, - { label: 'Carry', value: 'carry' } -]; +const MOVEMENT_PATTERNS = [ + 'push', + 'pull', + 'squat', + 'hinge', + 'carry', + 'rotation' +] as const; + +type MovementPattern = typeof MOVEMENT_PATTERNS[number]; export default function NewExerciseScreen() { const router = useRouter(); const { colors } = useColorScheme(); - // Form state matching Nostr spec + // Required fields based on NIP-XX spec const [title, setTitle] = useState(''); - const [equipment, setEquipment] = useState(''); - const [difficulty, setDifficulty] = useState(''); - const [muscleGroups, setMuscleGroups] = useState([]); - const [movementTypes, setMovementTypes] = useState([]); + const [exerciseType, setExerciseType] = useState(EXERCISE_TYPES[0]); + const [category, setCategory] = useState(CATEGORIES[0]); + const [equipment, setEquipment] = useState(EQUIPMENT_OPTIONS[0]); + const [difficulty, setDifficulty] = useState(DIFFICULTY_OPTIONS[0]); + const [movementPattern, setMovementPattern] = useState(MOVEMENT_PATTERNS[0]); const [instructions, setInstructions] = useState(''); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -67,39 +79,37 @@ export default function NewExerciseScreen() { return; } - if (!equipment) { - setError('Equipment type is required'); - return; - } - - // Create exercise template following NIP-XX spec const exerciseTemplate = { - id: generateId(), // UUID for template identification title: title.trim(), - type: 'exercise', - format: ['weight', 'reps', 'rpe', 'set_type'], // Required format params - format_units: ['kg', 'count', '0-10', 'warmup|normal|drop|failure'], // Required unit definitions + type: exerciseType, + category, equipment, difficulty, - content: instructions.trim(), // Form instructions in content field + description: instructions.trim(), tags: [ - ['d', generateId()], // Required UUID tag - ['title', title.trim()], - ['equipment', equipment], - ...muscleGroups.map(group => ['t', group]), - ...movementTypes.map(type => ['t', type]), - ['format', 'weight', 'reps', 'rpe', 'set_type'], - ['format_units', 'kg', 'count', '0-10', 'warmup|normal|drop|failure'], - difficulty ? ['difficulty', difficulty] : [], - ].filter(tag => tag.length > 0), // Remove empty tags - source: 'local', - created_at: Date.now(), - availability: { - source: ['local'] + difficulty, + movementPattern, + category.toLowerCase() + ], + format: { + weight: true, + reps: true, + rpe: true, + set_type: true + }, + format_units: { + weight: 'kg' as const, + reps: 'count' as const, + rpe: '0-10' as const, + set_type: 'warmup|normal|drop|failure' as const } }; - await LibraryService.addExercise(exerciseTemplate); + if (__DEV__) { + console.log('Creating exercise:', exerciseTemplate); + } + + await libraryService.addExercise(exerciseTemplate); router.back(); } catch (err) { @@ -116,10 +126,12 @@ export default function NewExerciseScreen() { contentContainerStyle={styles.content} > + {/* Basic Information */} Basic Information + - + + + Exercise Type + + selectedValue={exerciseType} + onValueChange={(value: ExerciseType) => setExerciseType(value)} + style={[styles.picker, { backgroundColor: colors.cardBg }]} + > + {EXERCISE_TYPES.map((option) => ( + + ))} + + + + + Category + + selectedValue={category} + onValueChange={(value: ExerciseCategory) => setCategory(value)} + style={[styles.picker, { backgroundColor: colors.cardBg }]} + > + {CATEGORIES.map((option) => ( + + ))} + + + + + Equipment + + selectedValue={equipment} + onValueChange={(value: Equipment) => setEquipment(value)} + style={[styles.picker, { backgroundColor: colors.cardBg }]} + > + {EQUIPMENT_OPTIONS.map((option) => ( + + ))} + + + {/* Categorization */} Categorization - + + + Movement Pattern + + selectedValue={movementPattern} + onValueChange={(value: MovementPattern) => setMovementPattern(value)} + style={[styles.picker, { backgroundColor: colors.cardBg }]} + > + {MOVEMENT_PATTERNS.map((option) => ( + + ))} + + + + + Difficulty + + selectedValue={difficulty} + onValueChange={(value: Difficulty) => setDifficulty(value)} + style={[styles.picker, { backgroundColor: colors.cardBg }]} + > + {DIFFICULTY_OPTIONS.map((option) => ( + + ))} + + + {/* Instructions */} Instructions + - - - - - - - - + + + + + + + + + + + ); } \ No newline at end of file diff --git a/components/library/LibraryContentCard.tsx b/components/library/LibraryContentCard.tsx index 3e639c9..7e1c306 100644 --- a/components/library/LibraryContentCard.tsx +++ b/components/library/LibraryContentCard.tsx @@ -1,16 +1,20 @@ // components/library/LibraryContentCard.tsx import React from 'react'; -import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import { View, StyleSheet, Alert } from 'react-native'; import { Feather } from '@expo/vector-icons'; import { useColorScheme } from '@/hooks/useColorScheme'; import { LibraryContent } from '@/types/exercise'; import { spacing } from '@/styles/sharedStyles'; import { ThemedText } from '@/components/ThemedText'; +import Swipeable from 'react-native-gesture-handler/Swipeable'; +import { RectButton } from 'react-native-gesture-handler'; +import * as Haptics from 'expo-haptics'; export interface LibraryContentCardProps { content: LibraryContent; onPress: () => void; onFavoritePress: () => void; + onDelete?: () => Promise; isVerified?: boolean; } @@ -18,67 +22,132 @@ export default function LibraryContentCard({ content, onPress, onFavoritePress, + onDelete, isVerified }: LibraryContentCardProps) { const { colors } = useColorScheme(); + const swipeableRef = React.useRef(null); + + const handleDelete = async () => { + try { + // Play haptic feedback + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + // Show confirmation alert + Alert.alert( + 'Delete Exercise', + 'Are you sure you want to delete this exercise?', + [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => { + swipeableRef.current?.close(); + }, + }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + if (onDelete) { + await onDelete(); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + } catch (error) { + console.error('Error deleting exercise:', error); + Alert.alert('Error', 'Failed to delete exercise'); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + }, + }, + ], + { cancelable: true } + ); + } catch (error) { + console.error('Error handling delete:', error); + } + }; + + const renderRightActions = () => { + if (!onDelete) return null; + + return ( + + + + Delete + + + ); + }; return ( - - - - - {content.title} + + + + + {content.title} + + {isVerified && ( + + + + POW Verified + + + )} + + + + + + + {content.description && ( + + {content.description} - {isVerified && ( - - - - POW Verified - - - )} - - - - - + )} - {content.description && ( - - {content.description} - - )} - - - - {content.tags.map(tag => ( - - - {tag} - - - ))} + + + {content.tags.map(tag => ( + + + {tag} + + + ))} + - - + + ); } @@ -136,4 +205,16 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '500', }, + deleteAction: { + flex: 1, + backgroundColor: 'red', + justifyContent: 'center', + alignItems: 'center', + width: 80, + }, + deleteText: { + fontSize: 12, + fontWeight: '600', + marginTop: 4, + }, }); \ No newline at end of file diff --git a/components/library/MyLibrary.tsx b/components/library/MyLibrary.tsx index b2a21ab..802ea01 100644 --- a/components/library/MyLibrary.tsx +++ b/components/library/MyLibrary.tsx @@ -4,6 +4,7 @@ import { View, FlatList, StyleSheet, Platform } from 'react-native'; import { Feather } from '@expo/vector-icons'; import { useColorScheme } from '@/hooks/useColorScheme'; import { LibraryContent } from '@/types/exercise'; +import { libraryService } from '@/services/LibraryService'; import LibraryContentCard from '@/components/library/LibraryContentCard'; import { spacing } from '@/styles/sharedStyles'; import { ThemedText } from '@/components/ThemedText'; @@ -12,15 +13,16 @@ interface MyLibraryProps { savedContent: LibraryContent[]; onContentPress: (content: LibraryContent) => void; onFavoritePress: (content: LibraryContent) => Promise; + onDeleteContent?: (content: LibraryContent) => void; isLoading?: boolean; isVisible?: boolean; } -// components/library/MyLibrary.tsx export default function MyLibrary({ savedContent, onContentPress, onFavoritePress, + onDeleteContent, isVisible = true }: MyLibraryProps) { const { colors } = useColorScheme(); @@ -30,6 +32,18 @@ export default function MyLibrary({ return null; } + const handleDelete = async (content: LibraryContent) => { + try { + if (content.type === 'exercise') { + await libraryService.deleteExercise(content.id); + onDeleteContent?.(content); + } + } catch (error) { + console.error('Error deleting content:', error); + throw error; + } + }; + // Separate exercises and workouts const exercises = savedContent.filter(content => content.type === 'exercise'); const workouts = savedContent.filter(content => content.type === 'workout'); @@ -49,6 +63,7 @@ export default function MyLibrary({ content={item} onPress={() => onContentPress(item)} onFavoritePress={() => onFavoritePress(item)} + onDelete={item.type === 'exercise' ? () => handleDelete(item) : undefined} /> )} keyExtractor={item => item.id} @@ -114,7 +129,4 @@ const styles = StyleSheet.create({ textAlign: 'center', maxWidth: '80%', }, - hidden: { - display: 'none', - }, }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9cff568..921685e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "powr-rebuild", + "name": "powr", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "powr-rebuild", + "name": "powr", "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^14.0.4", "@react-native-async-storage/async-storage": "1.23.1", + "@react-native-picker/picker": "^2.11.0", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", @@ -3559,6 +3560,19 @@ "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, + "node_modules/@react-native-picker/picker": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.0.tgz", + "integrity": "sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.6", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz", diff --git a/package.json b/package.json index 2f34ddb..b79b721 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "powr-rebuild", + "name": "powr", "main": "expo-router/entry", "version": "1.0.0", "scripts": { @@ -17,6 +17,7 @@ "dependencies": { "@expo/vector-icons": "^14.0.4", "@react-native-async-storage/async-storage": "1.23.1", + "@react-native-picker/picker": "^2.11.0", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", diff --git a/services/LibraryService.ts b/services/LibraryService.ts index 4e82b04..408d463 100644 --- a/services/LibraryService.ts +++ b/services/LibraryService.ts @@ -8,9 +8,12 @@ import { LibraryContent } from '@/types/exercise'; import { WorkoutTemplate } from '@/types/workout'; +import { StorageSource } from '@/types/shared'; +import { SQLiteError } from '@/types/sqlite'; class LibraryService { private db: DbService; + private readonly DEBUG = __DEV__; constructor() { this.db = new DbService('powr.db'); @@ -20,13 +23,22 @@ class LibraryService { const id = generateId(); const timestamp = Date.now(); + if (this.DEBUG) { + console.log('Creating exercise with payload:', { + id, + timestamp, + exercise, + }); + } + try { await this.db.withTransaction(async () => { - // Insert main exercise data - await this.db.executeWrite( + // 1. First insert main exercise data + const mainResult = await this.db.executeWrite( `INSERT INTO exercises ( - id, title, type, category, equipment, description, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, title, type, category, equipment, description, + created_at, updated_at, source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, exercise.title, @@ -35,35 +47,22 @@ class LibraryService { exercise.equipment || null, exercise.description || null, timestamp, - timestamp + timestamp, + 'local' ] ); - // Insert instructions if provided - if (exercise.instructions?.length) { - for (const [index, instruction] of exercise.instructions.entries()) { - await this.db.executeWrite( - `INSERT INTO exercise_instructions ( - exercise_id, instruction, display_order - ) VALUES (?, ?, ?)`, - [id, instruction, index] - ); - } + if (this.DEBUG) { + console.log('Main exercise insert result:', mainResult); } - // Insert tags if provided - if (exercise.tags?.length) { - for (const tag of exercise.tags) { - await this.db.executeWrite( - `INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`, - [id, tag] - ); - } + if (!mainResult.rowsAffected) { + throw new Error('Main exercise insert failed'); } - // Insert format settings if provided + // 2. Insert format settings if provided if (exercise.format) { - await this.db.executeWrite( + const formatResult = await this.db.executeWrite( `INSERT INTO exercise_format ( exercise_id, format_json, units_json ) VALUES (?, ?, ?)`, @@ -73,12 +72,86 @@ class LibraryService { JSON.stringify(exercise.format_units || {}) ] ); + + if (this.DEBUG) { + console.log('Format insert result:', formatResult); + } + } + + // 3. Insert tags if provided + if (exercise.tags?.length) { + for (const tag of exercise.tags) { + const tagResult = await this.db.executeWrite( + `INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`, + [id, tag] + ); + + if (this.DEBUG) { + console.log('Tag insert result:', tagResult); + } + } + } + + // 4. Insert instructions if provided + if (exercise.instructions?.length) { + for (const [index, instruction] of exercise.instructions.entries()) { + const instructionResult = await this.db.executeWrite( + `INSERT INTO exercise_instructions ( + exercise_id, instruction, display_order + ) VALUES (?, ?, ?)`, + [id, instruction, index] + ); + + if (this.DEBUG) { + console.log('Instruction insert result:', instructionResult); + } + } } }); + if (this.DEBUG) { + console.log('Exercise successfully created with ID:', id); + } + return id; + } catch (err) { + if (this.DEBUG) { + // Type check the error + if (err instanceof Error) { + const sqlError = err as SQLiteError; + console.error('Detailed error in addExercise:', { + message: err.message, + sql: 'sql' in sqlError ? sqlError.sql : undefined, + params: 'params' in sqlError ? sqlError.params : undefined, + code: 'code' in sqlError ? sqlError.code : undefined + }); + } else { + console.error('Unknown error in addExercise:', err); + } + } + throw new Error(err instanceof Error ? err.message : 'Failed to insert exercise'); + } + } + + async deleteExercise(id: string): Promise { + try { + if (this.DEBUG) { + console.log('Deleting exercise:', id); + } + + await this.db.withTransaction(async () => { + // Delete from main exercise table + const result = await this.db.executeWrite( + 'DELETE FROM exercises WHERE id = ?', + [id] + ); + + if (!result.rowsAffected) { + throw new Error('Exercise not found'); + } + }); } catch (error) { - console.error('Error adding exercise:', error); + console.error('Error deleting exercise:', error); throw error; } } @@ -118,7 +191,7 @@ class LibraryService { created_at: row.created_at, updated_at: row.updated_at, availability: { - source: ['local'] + source: ['local' as StorageSource] } }; } catch (error) { @@ -163,7 +236,7 @@ class LibraryService { created_at: row.created_at, updated_at: row.updated_at, availability: { - source: ['local'] + source: ['local' as StorageSource] } }; }); @@ -177,7 +250,7 @@ class LibraryService { try { // First get exercises const exercises = await this.getExercises(); - const exerciseContent = exercises.map(exercise => ({ + const exerciseContent: LibraryContent[] = exercises.map(exercise => ({ id: exercise.id, title: exercise.title, type: 'exercise' as const, @@ -188,7 +261,7 @@ class LibraryService { tags: exercise.tags, created_at: exercise.created_at, availability: { - source: ['local'] + source: ['local' as StorageSource] } })); @@ -251,7 +324,8 @@ class LibraryService { title: templateRow.title, type: templateRow.type, category: templateRow.category, - description: templateRow.description, + description: templateRow.description || '', + notes: templateRow.notes || '', // Added missing notes field author: templateRow.author_name ? { name: templateRow.author_name, pubkey: templateRow.author_pubkey @@ -265,7 +339,9 @@ class LibraryService { isPublic: Boolean(templateRow.is_public), created_at: templateRow.created_at, metadata: templateRow.metadata_json ? JSON.parse(templateRow.metadata_json) : undefined, - availability: JSON.parse(templateRow.availability_json) + availability: { + source: ['local' as StorageSource] + } }; } catch (error) { console.error('Error getting template:', error); @@ -275,8 +351,8 @@ class LibraryService { private async getTemplateExercises(templateId: string): Promise> { try { @@ -295,6 +371,11 @@ class LibraryService { const exercise = await this.getExercise(row.exercise_id); if (!exercise) throw new Error(`Exercise ${row.exercise_id} not found`); + // Ensure required fields are present + if (typeof row.target_sets !== 'number' || typeof row.target_reps !== 'number') { + throw new Error(`Missing required target sets/reps for exercise ${row.exercise_id}`); + } + return { exercise, targetSets: row.target_sets, diff --git a/types/sqlite.ts b/types/sqlite.ts index 91ec70b..a7f53e1 100644 --- a/types/sqlite.ts +++ b/types/sqlite.ts @@ -3,7 +3,6 @@ export interface SQLiteRow { [key: string]: any; } - export interface SQLiteResult { rows: { _array: T[]; @@ -16,6 +15,7 @@ export interface SQLiteResult { export interface SQLiteError extends Error { code?: number; + message: string; } export interface SQLiteStatement { diff --git a/utils/db/db-service.ts b/utils/db/db-service.ts index c6de429..fe1fa48 100644 --- a/utils/db/db-service.ts +++ b/utils/db/db-service.ts @@ -1,28 +1,42 @@ // utils/db/db-service.ts -import { - openDatabaseSync, - SQLiteDatabase -} from 'expo-sqlite'; -import { - SQLiteResult, - SQLiteError, - SQLiteRow -} from '@/types/sqlite'; +import * as SQLite from 'expo-sqlite'; +import { SQLiteDatabase } from 'expo-sqlite'; +import { SQLiteResult, SQLiteRow, SQLiteError } from '@/types/sqlite'; export class DbService { private db: SQLiteDatabase | null = null; + private readonly DEBUG = __DEV__; constructor(dbName: string) { try { - this.db = openDatabaseSync(dbName); - console.log('Database opened:', this.db); + this.db = SQLite.openDatabaseSync(dbName); + if (this.DEBUG) { + console.log('Database opened:', dbName); + } } catch (error) { console.error('Error opening database:', error); throw error; } } - async executeSql( + async withTransaction(operation: () => Promise): Promise { + if (!this.db) throw new Error('Database not initialized'); + + try { + if (this.DEBUG) console.log('Starting transaction'); + await this.db.runAsync('BEGIN TRANSACTION'); + const result = await operation(); + await this.db.runAsync('COMMIT'); + if (this.DEBUG) console.log('Transaction committed'); + return result; + } catch (error) { + if (this.DEBUG) console.log('Rolling back transaction due to:', error); + await this.db.runAsync('ROLLBACK'); + throw error; + } + } + + async executeSql( sql: string, params: (string | number | null)[] = [] ): Promise> { @@ -31,64 +45,112 @@ export class DbService { } try { - const statement = this.db.prepareSync(sql); - const result = statement.executeSync(params); - statement.finalizeSync(); + if (this.DEBUG) { + console.log('Executing SQL:', sql); + console.log('Parameters:', params); + } + + // Use the appropriate method based on the SQL operation type + const isSelect = sql.trim().toUpperCase().startsWith('SELECT'); + + if (isSelect) { + const results = await this.db.getAllAsync(sql, params); + return { + rows: { + _array: results, + length: results.length, + item: (idx: number) => { + // For existing interface compatibility, return first item of array + // when index is out of bounds instead of undefined + return results[idx >= 0 && idx < results.length ? idx : 0]; + } + }, + rowsAffected: 0 // SELECT doesn't modify rows + }; + } else { + const result = await this.db.runAsync(sql, params); + return { + rows: { + _array: [], + length: 0, + item: (_: number) => ({} as T) // Return empty object for non-SELECT operations + }, + rowsAffected: result.changes, + insertId: result.lastInsertRowId + }; + } - return { - rows: { - _array: Array.isArray(result) ? result : [], - length: Array.isArray(result) ? result.length : 0, - item: (idx: number) => (Array.isArray(result) ? result[idx] : null) as T - }, - rowsAffected: Array.isArray(result) ? result.length : 0, - insertId: undefined // SQLite doesn't provide this directly - }; } catch (error) { - console.error('SQL Error:', error, sql, params); + // Create proper SQLiteError with all required Error properties + const sqlError: SQLiteError = Object.assign(new Error(), { + name: 'SQLiteError', + message: error instanceof Error ? error.message : String(error), + code: error instanceof Error ? (error as any).code : undefined + }); + + console.error('SQL Error:', { + message: sqlError.message, + code: sqlError.code, + sql, + params + }); + + throw sqlError; + } + } + + async executeWrite( + sql: string, + params: (string | number | null)[] = [] + ): Promise> { + try { + const result = await this.executeSql(sql, params); + + // For INSERT/UPDATE/DELETE operations, verify the operation had an effect + const isWriteOperation = /^(INSERT|UPDATE|DELETE)\b/i.test(sql.trim()); + + if (isWriteOperation && !result.rowsAffected) { + const error = `Write operation failed: ${sql.split(' ')[0]}`; + if (this.DEBUG) { + console.warn(error, { sql, params }); + } + throw new Error(error); + } + + return result; + } catch (error) { + if (this.DEBUG) { + console.error('Write operation failed:', error); + } throw error; } } - async executeWrite( - sql: string, - params: (string | number | null)[] = [] - ): Promise> { - return this.executeSql(sql, params); - } - - async executeWriteMany( + async executeWriteMany( queries: Array<{ sql: string; args?: (string | number | null)[] }> ): Promise[]> { - if (!this.db) { - throw new Error('Database not initialized'); - } - - const results: SQLiteResult[] = []; - - for (const query of queries) { - try { + return this.withTransaction(async () => { + const results: SQLiteResult[] = []; + + for (const query of queries) { const result = await this.executeSql(query.sql, query.args || []); results.push(result); - } catch (error) { - console.error('Error executing query:', query, error); - throw error; } - } - - return results; + + return results; + }); } async tableExists(tableName: string): Promise { try { - const result = await this.executeSql<{ name: string }>( + const result = await this.db?.getFirstAsync<{ name: string }>( `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [tableName] ); - return result.rows._array.length > 0; + return !!result; } catch (error) { console.error('Error checking table existence:', error); return false; @@ -97,10 +159,25 @@ export class DbService { async initialize(): Promise { try { - await this.executeSql('PRAGMA foreign_keys = ON;'); + await this.db?.execAsync(` + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + `); } catch (error) { console.error('Error initializing database:', error); throw error; } } + + async close(): Promise { + try { + if (this.db) { + await this.db.closeAsync(); + this.db = null; + } + } catch (error) { + console.error('Error closing database:', error); + throw error; + } + } } \ No newline at end of file diff --git a/utils/db/schema.ts b/utils/db/schema.ts index 6209b2f..851b45a 100644 --- a/utils/db/schema.ts +++ b/utils/db/schema.ts @@ -69,6 +69,7 @@ class Schema { type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')), category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')), description TEXT, + notes TEXT, author_name TEXT, author_pubkey TEXT, rounds INTEGER,