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>
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
import { defineSchema, defineTable } from "convex/server";
|
|
import { v } from "convex/values";
|
|
import { authTables } from "@convex-dev/auth/server";
|
|
|
|
// Override the default users table to store NO personal data (GDPR compliance)
|
|
// User profile info (name, email, etc.) must be fetched from Zitadel OIDC userinfo endpoint
|
|
const minimalAuthTables = {
|
|
...authTables,
|
|
// Override users table - only store what's required for auth to function
|
|
users: defineTable({
|
|
// No name, email, image, or any PII stored
|
|
// The auth system needs this table to exist but we strip all profile data
|
|
}),
|
|
};
|
|
|
|
export default defineSchema({
|
|
...minimalAuthTables,
|
|
|
|
// Card printings with perceptual hashes
|
|
cards: defineTable({
|
|
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(), // 24-byte perceptual hash
|
|
hashVersion: v.number(), // Algorithm version for migrations
|
|
updatedAt: v.number(),
|
|
})
|
|
.index("by_scryfall", ["scryfallId"])
|
|
.index("by_oracle", ["oracleId"])
|
|
.index("by_name", ["name"])
|
|
.index("by_updated", ["updatedAt"]),
|
|
|
|
// Oracle cards (abstract game cards)
|
|
oracles: defineTable({
|
|
oracleId: v.string(),
|
|
name: v.string(),
|
|
manaCost: v.optional(v.string()),
|
|
cmc: v.optional(v.number()),
|
|
typeLine: v.optional(v.string()),
|
|
oracleText: v.optional(v.string()),
|
|
colors: v.optional(v.array(v.string())),
|
|
colorIdentity: v.optional(v.array(v.string())),
|
|
keywords: v.optional(v.array(v.string())),
|
|
power: v.optional(v.string()),
|
|
toughness: v.optional(v.string()),
|
|
})
|
|
.index("by_oracle", ["oracleId"])
|
|
.index("by_name", ["name"]),
|
|
|
|
// MTG sets
|
|
sets: defineTable({
|
|
setId: v.string(),
|
|
code: v.string(),
|
|
name: v.string(),
|
|
setType: v.optional(v.string()),
|
|
releasedAt: v.optional(v.string()),
|
|
cardCount: v.optional(v.number()),
|
|
iconSvgUri: v.optional(v.string()),
|
|
})
|
|
.index("by_set", ["setId"])
|
|
.index("by_code", ["code"]),
|
|
|
|
// User collections
|
|
collections: defineTable({
|
|
userId: v.id("users"),
|
|
cardId: v.id("cards"),
|
|
quantity: v.number(),
|
|
isFoil: v.boolean(),
|
|
addedAt: v.number(),
|
|
})
|
|
.index("by_user", ["userId"])
|
|
.index("by_user_card", ["userId", "cardId", "isFoil"]),
|
|
|
|
// Scan history
|
|
scanHistory: defineTable({
|
|
userId: v.id("users"),
|
|
cardId: v.optional(v.id("cards")),
|
|
confidence: v.number(),
|
|
scannedAt: v.number(),
|
|
addedToCollection: v.boolean(),
|
|
}).index("by_user", ["userId"]),
|
|
|
|
// Sync metadata
|
|
metadata: defineTable({
|
|
key: v.string(),
|
|
value: v.string(),
|
|
}).index("by_key", ["key"]),
|
|
});
|