Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:
- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)
Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support
Removes all .NET/MAUI code (src/, test/, tools/).
💘 Generated with Crush
Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
180
lib/db/localDatabase.ts
Normal file
180
lib/db/localDatabase.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Local SQLite database for offline card hash caching.
|
||||
* Syncs with Convex and provides offline recognition support.
|
||||
*/
|
||||
|
||||
import * as SQLite from "expo-sqlite";
|
||||
import type { CardHashEntry } from "../recognition";
|
||||
|
||||
const DB_NAME = "scry_cache.db";
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the local database.
|
||||
*/
|
||||
export async function initDatabase(): Promise<void> {
|
||||
if (db) return;
|
||||
|
||||
db = await SQLite.openDatabaseAsync(DB_NAME);
|
||||
|
||||
// Create tables
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
oracle_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
set_code TEXT NOT NULL,
|
||||
collector_number TEXT,
|
||||
rarity TEXT,
|
||||
artist TEXT,
|
||||
image_uri TEXT,
|
||||
hash BLOB NOT NULL,
|
||||
hash_version INTEGER DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_updated ON cards(updated_at);
|
||||
`);
|
||||
|
||||
console.log("[LocalDB] Database initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last sync timestamp.
|
||||
*/
|
||||
export async function getLastSyncTimestamp(): Promise<number> {
|
||||
if (!db) await initDatabase();
|
||||
|
||||
const result = await db!.getFirstAsync<{ value: string }>(
|
||||
"SELECT value FROM sync_metadata WHERE key = ?",
|
||||
["last_sync"]
|
||||
);
|
||||
|
||||
return result ? parseInt(result.value, 10) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last sync timestamp.
|
||||
*/
|
||||
export async function setLastSyncTimestamp(timestamp: number): Promise<void> {
|
||||
if (!db) await initDatabase();
|
||||
|
||||
await db!.runAsync(
|
||||
"INSERT OR REPLACE INTO sync_metadata (key, value) VALUES (?, ?)",
|
||||
["last_sync", timestamp.toString()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached card hashes for recognition.
|
||||
*/
|
||||
export async function getCachedHashes(): Promise<CardHashEntry[]> {
|
||||
if (!db) await initDatabase();
|
||||
|
||||
const rows = await db!.getAllAsync<{
|
||||
scryfall_id: string;
|
||||
name: string;
|
||||
set_code: string;
|
||||
collector_number: string | null;
|
||||
image_uri: string | null;
|
||||
hash: Uint8Array;
|
||||
}>("SELECT scryfall_id, name, set_code, collector_number, image_uri, hash FROM cards");
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.scryfall_id,
|
||||
name: row.name,
|
||||
setCode: row.set_code,
|
||||
collectorNumber: row.collector_number || undefined,
|
||||
imageUri: row.image_uri || undefined,
|
||||
hash: new Uint8Array(row.hash),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of cached cards.
|
||||
*/
|
||||
export async function getCachedCardCount(): Promise<number> {
|
||||
if (!db) await initDatabase();
|
||||
|
||||
const result = await db!.getFirstAsync<{ count: number }>(
|
||||
"SELECT COUNT(*) as count FROM cards"
|
||||
);
|
||||
|
||||
return result?.count || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update cards from Convex.
|
||||
*/
|
||||
export async function upsertCards(cards: Array<{
|
||||
scryfallId: string;
|
||||
oracleId: string;
|
||||
name: string;
|
||||
setCode: string;
|
||||
collectorNumber: string;
|
||||
rarity: string;
|
||||
artist?: string;
|
||||
imageUri?: string;
|
||||
hash: Uint8Array;
|
||||
hashVersion: number;
|
||||
updatedAt: number;
|
||||
}>): Promise<void> {
|
||||
if (!db) await initDatabase();
|
||||
|
||||
// Use a transaction for batch insert
|
||||
await db!.withTransactionAsync(async () => {
|
||||
for (const card of cards) {
|
||||
await db!.runAsync(
|
||||
`INSERT OR REPLACE INTO cards
|
||||
(scryfall_id, oracle_id, name, set_code, collector_number, rarity, artist, image_uri, hash, hash_version, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
card.scryfallId,
|
||||
card.oracleId,
|
||||
card.name,
|
||||
card.setCode,
|
||||
card.collectorNumber,
|
||||
card.rarity,
|
||||
card.artist || null,
|
||||
card.imageUri || null,
|
||||
card.hash,
|
||||
card.hashVersion,
|
||||
card.updatedAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[LocalDB] Upserted ${cards.length} cards`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data.
|
||||
*/
|
||||
export async function clearCache(): Promise<void> {
|
||||
if (!db) await initDatabase();
|
||||
|
||||
await db!.execAsync(`
|
||||
DELETE FROM cards;
|
||||
DELETE FROM sync_metadata;
|
||||
`);
|
||||
|
||||
console.log("[LocalDB] Cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
if (db) {
|
||||
await db.closeAsync();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue