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

116
scripts/migrate-hashes.ts Normal file
View file

@ -0,0 +1,116 @@
/**
* Migration script to upload card hashes from SQLite to Convex.
*
* Usage:
* npx tsx scripts/migrate-hashes.ts
*
* Prerequisites:
* - Run `npx convex dev` first to set up the Convex project
* - Ensure CONVEX_URL is set in .env.local
*/
import { ConvexHttpClient } from "convex/browser";
import Database from "better-sqlite3";
import { api } from "../convex/_generated/api";
import * as dotenv from "dotenv";
import * as path from "path";
import * as fs from "fs";
// Load environment variables
dotenv.config({ path: path.join(__dirname, "..", ".env.local") });
const CONVEX_URL = process.env.EXPO_PUBLIC_CONVEX_URL || process.env.CONVEX_URL;
const DB_PATH =
process.env.DB_PATH || path.join(__dirname, "..", "card_hashes.db");
const BATCH_SIZE = 50;
const HASH_VERSION = 1;
interface CardRow {
id: string;
oracle_id: string;
name: string;
set_code: string;
collector_number: string | null;
rarity: string | null;
artist: string | null;
image_uri: string | null;
hash: Buffer;
}
async function main() {
if (!CONVEX_URL) {
console.error("Error: CONVEX_URL not set in .env.local");
console.error("Run 'npx convex dev --once --configure=new' first");
process.exit(1);
}
if (!fs.existsSync(DB_PATH)) {
console.error(`Error: Database not found at ${DB_PATH}`);
process.exit(1);
}
console.log(`Connecting to Convex: ${CONVEX_URL}`);
console.log(`Reading from SQLite: ${DB_PATH}`);
const client = new ConvexHttpClient(CONVEX_URL);
const db = new Database(DB_PATH, { readonly: true });
try {
// Get total count
const countRow = db
.prepare("SELECT COUNT(*) as count FROM cards WHERE hash IS NOT NULL")
.get() as { count: number };
const totalCards = countRow.count;
console.log(`Found ${totalCards} cards with hashes to migrate`);
// Query cards with hashes
const stmt = db.prepare(`
SELECT id, oracle_id, name, set_code, collector_number, rarity, artist, image_uri, hash
FROM cards
WHERE hash IS NOT NULL
ORDER BY name
`);
const cards = stmt.all() as CardRow[];
let migrated = 0;
let errors = 0;
// Process in batches
for (let i = 0; i < cards.length; i += BATCH_SIZE) {
const batch = cards.slice(i, i + BATCH_SIZE);
const convexCards = batch.map((card) => ({
scryfallId: card.id,
oracleId: card.oracle_id,
name: card.name,
setCode: card.set_code,
collectorNumber: card.collector_number || "",
rarity: card.rarity || "common",
artist: card.artist || undefined,
imageUri: card.image_uri || undefined,
hash: new Uint8Array(card.hash).buffer as ArrayBuffer,
hashVersion: HASH_VERSION,
}));
try {
await client.mutation(api.cards.insertBatch, { cards: convexCards });
migrated += batch.length;
const pct = ((migrated / totalCards) * 100).toFixed(1);
process.stdout.write(`\rMigrated ${migrated}/${totalCards} (${pct}%)`);
} catch (err) {
console.error(`\nError migrating batch starting at ${i}:`, err);
errors += batch.length;
}
}
console.log(`\n\nMigration complete!`);
console.log(` Migrated: ${migrated}`);
console.log(` Errors: ${errors}`);
} finally {
db.close();
}
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});