scry/convex/cards.ts
Chris Kruining 83ab4df537
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>
2026-02-09 16:16:34 +01:00

146 lines
3.6 KiB
TypeScript

import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
// Get all cards (for local caching)
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("cards").collect();
},
});
// Get cards updated after a timestamp (for incremental sync)
export const listUpdatedAfter = query({
args: { since: v.number() },
handler: async (ctx, { since }) => {
return await ctx.db
.query("cards")
.withIndex("by_updated", (q) => q.gt("updatedAt", since))
.collect();
},
});
// Get a single card by Scryfall ID
export const getByScryfallId = query({
args: { scryfallId: v.string() },
handler: async (ctx, { scryfallId }) => {
return await ctx.db
.query("cards")
.withIndex("by_scryfall", (q) => q.eq("scryfallId", scryfallId))
.first();
},
});
// Get cards by oracle ID (all printings of a card)
export const getByOracleId = query({
args: { oracleId: v.string() },
handler: async (ctx, { oracleId }) => {
return await ctx.db
.query("cards")
.withIndex("by_oracle", (q) => q.eq("oracleId", oracleId))
.collect();
},
});
// Search cards by name
export const searchByName = query({
args: { name: v.string() },
handler: async (ctx, { name }) => {
const cards = await ctx.db.query("cards").collect();
const lowerName = name.toLowerCase();
return cards.filter((card) =>
card.name.toLowerCase().includes(lowerName)
);
},
});
// Get total card count
export const count = query({
args: {},
handler: async (ctx) => {
const cards = await ctx.db.query("cards").collect();
return cards.length;
},
});
// Insert a card (used by migration script)
export const insert = mutation({
args: {
scryfallId: v.string(),
oracleId: v.string(),
name: v.string(),
setCode: v.string(),
collectorNumber: v.string(),
rarity: v.string(),
artist: v.optional(v.string()),
imageUri: v.optional(v.string()),
hash: v.bytes(),
hashVersion: v.number(),
},
handler: async (ctx, args) => {
// Check if card already exists
const existing = await ctx.db
.query("cards")
.withIndex("by_scryfall", (q) => q.eq("scryfallId", args.scryfallId))
.first();
if (existing) {
// Update existing card
await ctx.db.patch(existing._id, {
...args,
updatedAt: Date.now(),
});
return existing._id;
}
// Insert new card
return await ctx.db.insert("cards", {
...args,
updatedAt: Date.now(),
});
},
});
// Batch insert cards
export const insertBatch = mutation({
args: {
cards: v.array(
v.object({
scryfallId: v.string(),
oracleId: v.string(),
name: v.string(),
setCode: v.string(),
collectorNumber: v.string(),
rarity: v.string(),
artist: v.optional(v.string()),
imageUri: v.optional(v.string()),
hash: v.bytes(),
hashVersion: v.number(),
})
),
},
handler: async (ctx, { cards }) => {
const results = [];
for (const card of cards) {
const existing = await ctx.db
.query("cards")
.withIndex("by_scryfall", (q) => q.eq("scryfallId", card.scryfallId))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
...card,
updatedAt: Date.now(),
});
results.push(existing._id);
} else {
const id = await ctx.db.insert("cards", {
...card,
updatedAt: Date.now(),
});
results.push(id);
}
}
return results;
},
});