Skip to content

Commit 5e02112

Browse files
committed
fix(mobile): resolve database initialization race conditions and improve migrations
Fixes #36 - Fix race condition where database functions were called before initialization completed - Add initialization promise to prevent multiple concurrent initializations - Create base tables BEFORE running migrations to avoid "table not exist" errors - Add safety checks to create missing cache_metadata and sync_queue tables for existing users - Auto-initialize database when accessed if not yet initialized - Fix cache stats to update in real-time using useFocusEffect on Settings screen - Ensure backward compatibility for users upgrading from older versions This fixes the "no such table: cache_metadata" and "no such table: sync_queue" errors that occurred when migrations tried to reference tables that weren't created yet.
1 parent 6e949da commit 5e02112

2 files changed

Lines changed: 120 additions & 15 deletions

File tree

apps/mobile/v1/src/lib/database/index.ts

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as SQLite from 'expo-sqlite';
1111
*/
1212

1313
let database: SQLite.SQLiteDatabase | null = null;
14+
let initializationPromise: Promise<SQLite.SQLiteDatabase> | null = null;
1415

1516
const DB_NAME = 'typelets_mobile.db';
1617
const DB_VERSION = 5;
@@ -164,7 +165,15 @@ async function migrateDatabase(db: SQLite.SQLiteDatabase): Promise<void> {
164165
// Clear notes cache so they get re-fetched with attachment counts
165166
console.log('[SQLite] Clearing notes cache to refresh with attachment counts...');
166167
await db.execAsync(`DELETE FROM notes;`);
167-
await db.execAsync(`DELETE FROM cache_metadata WHERE resource_type = 'notes';`);
168+
169+
// Only clear cache_metadata if the table exists
170+
const cacheTableCheck = await db.getFirstAsync<{ count: number }>(
171+
`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='cache_metadata'`
172+
);
173+
if (cacheTableCheck && cacheTableCheck.count > 0) {
174+
await db.execAsync(`DELETE FROM cache_metadata WHERE resource_type = 'notes';`);
175+
}
176+
168177
console.log('[SQLite] Notes cache cleared - will be refreshed on next load');
169178
}
170179
} catch (error) {
@@ -190,12 +199,75 @@ async function migrateDatabase(db: SQLite.SQLiteDatabase): Promise<void> {
190199
// Clear notes cache so they get re-fetched with public notes fields
191200
console.log('[SQLite] Clearing notes cache to refresh with public notes fields...');
192201
await db.execAsync(`DELETE FROM notes;`);
193-
await db.execAsync(`DELETE FROM cache_metadata WHERE resource_type = 'notes';`);
202+
203+
// Only clear cache_metadata if the table exists
204+
const cacheTableCheck = await db.getFirstAsync<{ count: number }>(
205+
`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='cache_metadata'`
206+
);
207+
if (cacheTableCheck && cacheTableCheck.count > 0) {
208+
await db.execAsync(`DELETE FROM cache_metadata WHERE resource_type = 'notes';`);
209+
}
210+
194211
console.log('[SQLite] Notes cache cleared - will be refreshed on next load');
195212
}
196213
} catch (error) {
197214
console.error('[SQLite] Failed to check/add public notes columns:', error);
198215
}
216+
217+
// Safety check: Ensure cache_metadata table exists (runs every time)
218+
try {
219+
const tableCheck = await db.getFirstAsync<{ count: number }>(
220+
`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='cache_metadata'`
221+
);
222+
223+
if (tableCheck && tableCheck.count === 0) {
224+
console.log('[SQLite] cache_metadata table missing, creating it now...');
225+
await db.execAsync(`
226+
CREATE TABLE cache_metadata (
227+
id TEXT PRIMARY KEY,
228+
resource_type TEXT NOT NULL,
229+
resource_id TEXT,
230+
e_tag TEXT,
231+
last_modified INTEGER,
232+
cached_at INTEGER NOT NULL,
233+
expires_at INTEGER NOT NULL
234+
);
235+
CREATE INDEX IF NOT EXISTS idx_cache_resource ON cache_metadata(resource_type, resource_id);
236+
`);
237+
console.log('[SQLite] cache_metadata table created successfully');
238+
}
239+
} catch (error) {
240+
console.error('[SQLite] Failed to check/create cache_metadata table:', error);
241+
}
242+
243+
// Safety check: Ensure sync_queue table exists (runs every time)
244+
try {
245+
const tableCheck = await db.getFirstAsync<{ count: number }>(
246+
`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='sync_queue'`
247+
);
248+
249+
if (tableCheck && tableCheck.count === 0) {
250+
console.log('[SQLite] sync_queue table missing, creating it now...');
251+
await db.execAsync(`
252+
CREATE TABLE sync_queue (
253+
id TEXT PRIMARY KEY,
254+
resource_type TEXT NOT NULL,
255+
resource_id TEXT NOT NULL,
256+
operation TEXT NOT NULL,
257+
payload TEXT NOT NULL,
258+
status TEXT DEFAULT 'pending',
259+
retry_count INTEGER DEFAULT 0,
260+
error_message TEXT,
261+
created_at INTEGER NOT NULL,
262+
synced_at INTEGER
263+
);
264+
CREATE INDEX IF NOT EXISTS idx_sync_status ON sync_queue(status);
265+
`);
266+
console.log('[SQLite] sync_queue table created successfully');
267+
}
268+
} catch (error) {
269+
console.error('[SQLite] Failed to check/create sync_queue table:', error);
270+
}
199271
}
200272

201273
/**
@@ -207,16 +279,22 @@ export async function initializeDatabase(): Promise<SQLite.SQLiteDatabase> {
207279
return database;
208280
}
209281

282+
// If initialization is in progress, wait for it
283+
if (initializationPromise) {
284+
console.log('[SQLite] Database initialization already in progress, waiting...');
285+
return initializationPromise;
286+
}
287+
210288
console.log('[SQLite] Initializing database...');
211289

290+
// Create initialization promise
291+
initializationPromise = (async () => {
292+
212293
try {
213294
// Open database
214295
database = await SQLite.openDatabaseAsync(DB_NAME);
215296

216-
// Run migrations first
217-
await migrateDatabase(database);
218-
219-
// Create tables (if they don't exist)
297+
// Create base tables FIRST (if they don't exist) - this ensures migrations can reference them
220298
await database.execAsync(`
221299
PRAGMA journal_mode = WAL;
222300
@@ -304,24 +382,47 @@ export async function initializeDatabase(): Promise<SQLite.SQLiteDatabase> {
304382
CREATE INDEX IF NOT EXISTS idx_notes_folder_status ON notes(folder_id, deleted, archived) WHERE folder_id IS NOT NULL;
305383
`);
306384

385+
console.log('[SQLite] Base tables created');
386+
387+
// Now run migrations - migrations can safely reference all tables
388+
await migrateDatabase(database);
389+
307390
console.log('[SQLite] Database initialized successfully');
308391
return database;
309392
} catch (error) {
310393
console.error('[SQLite] Failed to initialize database:', error);
394+
database = null;
395+
initializationPromise = null;
311396
throw error;
312397
}
398+
})();
399+
400+
return initializationPromise;
313401
}
314402

315403
/**
316404
* Get the database instance
317-
* @throws Error if database hasn't been initialized yet
405+
* Automatically initializes if not yet initialized
318406
*/
319407
export function getDatabase(): SQLite.SQLiteDatabase {
408+
if (!database && !initializationPromise) {
409+
// Auto-initialize if not already initializing
410+
console.warn('[SQLite] Database accessed before initialization, auto-initializing...');
411+
// We can't await here since this is a sync function, but we start the init
412+
initializeDatabase().catch(err => {
413+
console.error('[SQLite] Auto-initialization failed:', err);
414+
});
415+
throw new Error(
416+
'[SQLite] Database not initialized yet. Initialization started automatically.'
417+
);
418+
}
419+
320420
if (!database) {
321421
throw new Error(
322-
'[SQLite] Database not initialized. Call initializeDatabase() first.'
422+
'[SQLite] Database initialization in progress. Please wait.'
323423
);
324424
}
425+
325426
return database;
326427
}
327428

@@ -362,6 +463,7 @@ export async function closeDatabase(): Promise<void> {
362463
if (database) {
363464
await database.closeAsync();
364465
database = null;
466+
initializationPromise = null;
365467
console.log('[SQLite] Database closed');
366468
}
367469
}

apps/mobile/v1/src/screens/Settings/index.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useUser } from '@clerk/clerk-expo';
22
import { Ionicons } from '@expo/vector-icons';
33
import { BottomSheetBackdrop, BottomSheetBackdropProps,BottomSheetModal, BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet';
44
import AsyncStorage from '@react-native-async-storage/async-storage';
5+
import { useFocusEffect } from '@react-navigation/native';
56
import { GlassView } from 'expo-glass-effect';
67
import { LinearGradient } from 'expo-linear-gradient';
78
import { useRouter } from 'expo-router';
@@ -129,12 +130,7 @@ export default function SettingsScreen({ onLogout }: Props) {
129130
loadCachePreference();
130131
}, []);
131132

132-
// Load cache stats
133-
useEffect(() => {
134-
loadCacheStats();
135-
}, []);
136-
137-
const loadCacheStats = async () => {
133+
const loadCacheStats = useCallback(async () => {
138134
try {
139135
const stats = await getCacheStats();
140136
setCacheStats(stats);
@@ -143,7 +139,14 @@ export default function SettingsScreen({ onLogout }: Props) {
143139
console.error('Failed to load cache stats:', error);
144140
}
145141
}
146-
};
142+
}, []);
143+
144+
// Load cache stats on mount and whenever screen comes into focus
145+
useFocusEffect(
146+
useCallback(() => {
147+
loadCacheStats();
148+
}, [loadCacheStats])
149+
);
147150

148151
const saveViewMode = async (mode: 'list' | 'grid') => {
149152
try {

0 commit comments

Comments
 (0)