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:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

20
lib/db/index.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* Local database module exports.
*/
export {
initDatabase,
getLastSyncTimestamp,
setLastSyncTimestamp,
getCachedHashes,
getCachedCardCount,
upsertCards,
clearCache,
closeDatabase,
} from "./localDatabase";
export {
createSyncService,
type SyncService,
type SyncStatus,
} from "./syncService";

180
lib/db/localDatabase.ts Normal file
View 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;
}
}

183
lib/db/syncService.ts Normal file
View file

@ -0,0 +1,183 @@
/**
* Sync service for keeping local SQLite cache in sync with Convex.
* Provides offline-first functionality with background sync.
*/
import { ConvexReactClient } from "convex/react";
import { api } from "../../convex/_generated/api";
import {
initDatabase,
getLastSyncTimestamp,
setLastSyncTimestamp,
getCachedHashes,
getCachedCardCount,
upsertCards,
clearCache,
} from "./localDatabase";
import type { CardHashEntry } from "../recognition";
export interface SyncStatus {
isInitialized: boolean;
isSyncing: boolean;
lastSync: number;
localCardCount: number;
error?: string;
}
export interface SyncService {
initialize: () => Promise<void>;
sync: () => Promise<void>;
getHashes: () => Promise<CardHashEntry[]>;
getStatus: () => SyncStatus;
clearLocalCache: () => Promise<void>;
onStatusChange: (callback: (status: SyncStatus) => void) => () => void;
}
/**
* Create a sync service instance.
*/
export function createSyncService(convexClient: ConvexReactClient): SyncService {
let status: SyncStatus = {
isInitialized: false,
isSyncing: false,
lastSync: 0,
localCardCount: 0,
};
const listeners = new Set<(status: SyncStatus) => void>();
function notifyListeners() {
listeners.forEach((cb) => cb(status));
}
function updateStatus(partial: Partial<SyncStatus>) {
status = { ...status, ...partial };
notifyListeners();
}
return {
/**
* Initialize the local database and load cached data.
*/
async initialize() {
try {
await initDatabase();
const [lastSync, cardCount] = await Promise.all([
getLastSyncTimestamp(),
getCachedCardCount(),
]);
updateStatus({
isInitialized: true,
lastSync,
localCardCount: cardCount,
error: undefined,
});
console.log(`[SyncService] Initialized with ${cardCount} cached cards`);
} catch (error) {
console.error("[SyncService] Initialization failed:", error);
updateStatus({
error: error instanceof Error ? error.message : "Initialization failed",
});
}
},
/**
* Sync with Convex - fetch new/updated cards.
*/
async sync() {
if (status.isSyncing) {
console.log("[SyncService] Sync already in progress");
return;
}
updateStatus({ isSyncing: true, error: undefined });
try {
// Get cards updated since last sync
const lastSync = status.lastSync;
// Query Convex for updated cards
// Note: This uses the HTTP client for one-off queries
const cards = lastSync > 0
? await convexClient.query(api.cards.listUpdatedAfter, { since: lastSync })
: await convexClient.query(api.cards.list, {});
if (cards && cards.length > 0) {
// Convert to local format and save
const localCards = cards.map((card) => ({
scryfallId: card.scryfallId,
oracleId: card.oracleId,
name: card.name,
setCode: card.setCode,
collectorNumber: card.collectorNumber,
rarity: card.rarity,
artist: card.artist,
imageUri: card.imageUri,
hash: new Uint8Array(card.hash),
hashVersion: card.hashVersion,
updatedAt: card.updatedAt,
}));
await upsertCards(localCards);
console.log(`[SyncService] Synced ${localCards.length} cards`);
}
// Update sync timestamp
const now = Date.now();
await setLastSyncTimestamp(now);
// Update status
const cardCount = await getCachedCardCount();
updateStatus({
isSyncing: false,
lastSync: now,
localCardCount: cardCount,
});
console.log(`[SyncService] Sync complete. ${cardCount} cards cached.`);
} catch (error) {
console.error("[SyncService] Sync failed:", error);
updateStatus({
isSyncing: false,
error: error instanceof Error ? error.message : "Sync failed",
});
}
},
/**
* Get cached hashes for recognition.
*/
async getHashes() {
return getCachedHashes();
},
/**
* Get current sync status.
*/
getStatus() {
return status;
},
/**
* Clear local cache and re-sync.
*/
async clearLocalCache() {
await clearCache();
updateStatus({
lastSync: 0,
localCardCount: 0,
});
},
/**
* Subscribe to status changes.
*/
onStatusChange(callback: (status: SyncStatus) => void) {
listeners.add(callback);
return () => listeners.delete(callback);
},
};
}