···5555 "card": {
5656 "type": "object",
5757 "properties": {
5858- "scryfallId": {
5959- "type": "string",
6060- "description": "Scryfall UUID for the specific printing."
5858+ "ref": {
5959+ "type": "ref",
6060+ "ref": "com.deckbelcher.defs#cardRef",
6161+ "description": "Reference to the card (scryfall printing + oracle card)."
6162 },
6263 "quantity": {
6364 "type": "integer",
···8889 },
8990 "description": "A card entry in a decklist.",
9091 "required": [
9191- "scryfallId",
9292+ "ref",
9293 "quantity",
9394 "section"
9495 ]
+26
lexicons/com/deckbelcher/defs.json
···11+{
22+ "lexicon": 1,
33+ "id": "com.deckbelcher.defs",
44+ "defs": {
55+ "cardRef": {
66+ "type": "object",
77+ "properties": {
88+ "scryfallUri": {
99+ "type": "string",
1010+ "format": "uri",
1111+ "description": "Scryfall printing URI (scry:<uuid>) - authoritative identifier"
1212+ },
1313+ "oracleUri": {
1414+ "type": "string",
1515+ "format": "uri",
1616+ "description": "Oracle card URI (oracle:<uuid>) - for external indexing.\nDerived from scryfallUri; on conflict, scryfallUri takes precedence."
1717+ }
1818+ },
1919+ "description": "Reference to a Magic: The Gathering card with printing and oracle identifiers.",
2020+ "required": [
2121+ "scryfallUri",
2222+ "oracleUri"
2323+ ]
2424+ }
2525+ }
2626+}
+6-4
lexicons/com/deckbelcher/richtext/facet.json
···113113 "cardRef": {
114114 "type": "object",
115115 "properties": {
116116- "scryfallId": {
117117- "type": "string"
116116+ "ref": {
117117+ "type": "ref",
118118+ "ref": "com.deckbelcher.defs#cardRef",
119119+ "description": "Reference to the card (scryfall printing + oracle card)."
118120 }
119121 },
120120- "description": "Facet feature for a card reference.\nLinks to a Magic: The Gathering card by Scryfall ID.\nThe text is usually the card name.",
122122+ "description": "Facet feature for a card reference.\nLinks to a Magic: The Gathering card.\nThe text is usually the card name.",
121123 "required": [
122122- "scryfallId"
124124+ "ref"
123125 ]
124126 }
125127 }
+6-15
src/components/DeckPreview.tsx
···44import { CardImage } from "@/components/CardImage";
55import { ClientDate } from "@/components/ClientDate";
66import { asRkey, type Rkey } from "@/lib/atproto-client";
77+import type { Deck, DeckCard } from "@/lib/deck-types";
78import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle";
89import { formatDisplayName } from "@/lib/format-utils";
910import type { ScryfallId } from "@/lib/scryfall-types";
10111111-export interface DeckData {
1212- name: string;
1313- format?: string;
1414- cards: Array<{ scryfallId: string; quantity: number; section: string }>;
1515- createdAt: string;
1616- updatedAt?: string;
1717-}
1818-1912export interface DeckPreviewProps {
2013 did: Did;
2114 rkey: Rkey | string;
2222- deck: DeckData;
1515+ deck: Deck;
2316 /** Whether to show handle row (fetches DID document, with skeleton while loading) */
2417 showHandle?: boolean;
2518 /** Whether to show section counts like "100 main · 15 side" (default: true) */
···6154 return parts.join(" · ");
6255}
63566464-function getThumbnailId(
6565- cards: { scryfallId: string; section: string }[],
6666-): ScryfallId | null {
5757+function getThumbnailId(cards: DeckCard[]): ScryfallId | null {
6758 const commander = cards.find((c) => c.section === "commander");
6868- if (commander) return commander.scryfallId as ScryfallId;
5959+ if (commander) return commander.scryfallId;
69607061 const mainboard = cards.find((c) => c.section === "mainboard");
7171- if (mainboard) return mainboard.scryfallId as ScryfallId;
6262+ if (mainboard) return mainboard.scryfallId;
72637373- return cards[0]?.scryfallId as ScryfallId | null;
6464+ return cards[0]?.scryfallId ?? null;
7465}
75667667export function DeckPreview({
···44 */
5566import type { ComDeckbelcherDeckList } from "./lexicons/index";
77-import type { Card, ManaColor, ScryfallId } from "./scryfall-types";
77+import type { Card, ManaColor, OracleId, ScryfallId } from "./scryfall-types";
8899export type Section = "commander" | "mainboard" | "sideboard" | "maybeboard";
10101111-export type DeckCard = Omit<ComDeckbelcherDeckList.Card, "scryfallId"> & {
1111+/**
1212+ * App-side card entry with flat typed IDs.
1313+ * The lexicon stores ref.scryfallUri and ref.oracleUri as URIs,
1414+ * but app code works with typed IDs after boundary parsing.
1515+ */
1616+export type DeckCard = Omit<ComDeckbelcherDeckList.Card, "ref"> & {
1217 scryfallId: ScryfallId;
1818+ oracleId: OracleId;
1319};
14201521export type Deck = Omit<ComDeckbelcherDeckList.Main, "cards"> & {
···7379export function addCardToDeck(
7480 deck: Deck,
7581 scryfallId: ScryfallId,
8282+ oracleId: OracleId,
7683 section: Section,
7784 quantity = 1,
7885): Deck {
···929993100 return {
94101 ...deck,
9595- cards: [...deck.cards, { scryfallId, quantity, section, tags: [] }],
102102+ cards: [
103103+ ...deck.cards,
104104+ { scryfallId, oracleId, quantity, section, tags: [] },
105105+ ],
96106 updatedAt: new Date().toISOString(),
97107 };
98108}
+2-1
src/lib/goldfish/__tests__/engine.test.ts
···11import { describe, expect, it } from "vitest";
22import type { DeckCard } from "@/lib/deck-types";
33-import { asScryfallId } from "@/lib/scryfall-types";
33+import { asOracleId, asScryfallId } from "@/lib/scryfall-types";
44import { createSeededRng } from "@/lib/useSeededRandom";
55import {
66 addCounter,
···2727function mockDeck(count: number): DeckCard[] {
2828 return Array.from({ length: count }, (_, i) => ({
2929 scryfallId: asScryfallId(`card-${i}`),
3030+ oracleId: asOracleId(`oracle-${i}`),
3031 quantity: 1,
3132 section: "mainboard" as const,
3233 tags: [],
+1
src/lib/lexicons/index.ts
···22export * as ComDeckbelcherActorProfile from "./types/com/deckbelcher/actor/profile.js";
33export * as ComDeckbelcherCollectionList from "./types/com/deckbelcher/collection/list.js";
44export * as ComDeckbelcherDeckList from "./types/com/deckbelcher/deck/list.js";
55+export * as ComDeckbelcherDefs from "./types/com/deckbelcher/defs.js";
56export * as ComDeckbelcherRichtext from "./types/com/deckbelcher/richtext.js";
67export * as ComDeckbelcherRichtextFacet from "./types/com/deckbelcher/richtext/facet.js";
78export * as ComDeckbelcherSocialLike from "./types/com/deckbelcher/social/like.js";
···11import type {} from "@atcute/lexicons";
22import * as v from "@atcute/lexicons/validations";
33import type {} from "@atcute/lexicons/ambient";
44+import * as ComDeckbelcherDefs from "../defs.js";
45import * as ComDeckbelcherRichtext from "../richtext.js";
5667const _cardSchema = /*#__PURE__*/ v.object({
···1516 /*#__PURE__*/ v.integerRange(1),
1617 ]),
1718 /**
1818- * Scryfall UUID for the specific printing.
1919+ * Reference to the card (scryfall printing + oracle card).
1920 */
2020- scryfallId: /*#__PURE__*/ v.string(),
2121+ get ref() {
2222+ return ComDeckbelcherDefs.cardRefSchema;
2323+ },
2124 /**
2225 * Which section of the deck this card belongs to. Extensible to support format-specific sections.
2326 */
+24
src/lib/lexicons/types/com/deckbelcher/defs.ts
···11+import type {} from "@atcute/lexicons";
22+import * as v from "@atcute/lexicons/validations";
33+44+const _cardRefSchema = /*#__PURE__*/ v.object({
55+ $type: /*#__PURE__*/ v.optional(
66+ /*#__PURE__*/ v.literal("com.deckbelcher.defs#cardRef"),
77+ ),
88+ /**
99+ * Oracle card URI (oracle:<uuid>) - for external indexing. Derived from scryfallUri; on conflict, scryfallUri takes precedence.
1010+ */
1111+ oracleUri: /*#__PURE__*/ v.genericUriString(),
1212+ /**
1313+ * Scryfall printing URI (scry:<uuid>) - authoritative identifier
1414+ */
1515+ scryfallUri: /*#__PURE__*/ v.genericUriString(),
1616+});
1717+1818+type cardRef$schematype = typeof _cardRefSchema;
1919+2020+export interface cardRefSchema extends cardRef$schematype {}
2121+2222+export const cardRefSchema = _cardRefSchema as cardRefSchema;
2323+2424+export interface CardRef extends v.InferInput<typeof cardRefSchema> {}
···11import type {} from "@atcute/lexicons";
22import * as v from "@atcute/lexicons/validations";
33+import * as ComDeckbelcherDefs from "../defs.js";
3445const _boldSchema = /*#__PURE__*/ v.object({
56 $type: /*#__PURE__*/ v.optional(
···2324 $type: /*#__PURE__*/ v.optional(
2425 /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#cardRef"),
2526 ),
2626- scryfallId: /*#__PURE__*/ v.string(),
2727+ /**
2828+ * Reference to the card (scryfall printing + oracle card).
2929+ */
3030+ get ref() {
3131+ return ComDeckbelcherDefs.cardRefSchema;
3232+ },
2733});
2834const _codeSchema = /*#__PURE__*/ v.object({
2935 $type: /*#__PURE__*/ v.optional(
+5-11
src/lib/printing-selection.ts
···7171 * Group deck cards by oracle ID.
7272 * Returns a map of oracle ID to deck cards with that oracle.
7373 */
7474-async function groupCardsByOracle(
7575- deck: Deck,
7676- provider: CardDataProvider,
7777-): Promise<Map<OracleId, DeckCard[]>> {
7474+function groupCardsByOracle(deck: Deck): Map<OracleId, DeckCard[]> {
7875 const byOracle = new Map<OracleId, DeckCard[]>();
79768077 for (const card of deck.cards) {
8181- const cardData = await provider.getCardById(card.scryfallId);
8282- if (!cardData) continue;
8383-8484- const existing = byOracle.get(cardData.oracle_id) ?? [];
8585- byOracle.set(cardData.oracle_id, [...existing, card]);
7878+ const existing = byOracle.get(card.oracleId) ?? [];
7979+ byOracle.set(card.oracleId, [...existing, card]);
8680 }
87818882 return byOracle;
···9892 provider: CardDataProvider,
9993): Promise<Map<ScryfallId, ScryfallId>> {
10094 const updates = new Map<ScryfallId, ScryfallId>();
101101- const byOracle = await groupCardsByOracle(deck, provider);
9595+ const byOracle = groupCardsByOracle(deck);
1029610397 for (const [oracleId, cards] of byOracle) {
10498 const printingIds = await provider.getPrintingsByOracleId(oracleId);
···138132 provider: CardDataProvider,
139133): Promise<Map<ScryfallId, ScryfallId>> {
140134 const updates = new Map<ScryfallId, ScryfallId>();
141141- const byOracle = await groupCardsByOracle(deck, provider);
135135+ const byOracle = groupCardsByOracle(deck);
142136143137 for (const [oracleId, cards] of byOracle) {
144138 const canonicalId = await provider.getCanonicalPrinting(oracleId);
+31-11
src/lib/richtext-convert.ts
···1717 Italic,
1818 Link,
1919} from "@/lib/lexicons/types/com/deckbelcher/richtext/facet";
2020+import {
2121+ asOracleId,
2222+ asScryfallId,
2323+ parseOracleUri,
2424+ parseScryfallUri,
2525+ toOracleUri,
2626+ toScryfallUri,
2727+} from "./scryfall-types";
20282129export type { LexiconDocument };
2230···263271 } else if (child.type.name === "cardRef") {
264272 const name = (child.attrs.name as string) || "";
265273 const scryfallId = (child.attrs.scryfallId as string) || "";
274274+ const oracleId = (child.attrs.oracleId as string) || "";
266275 const textBytes = new TextEncoder().encode(name);
267276268277 textParts.push(name);
269278270270- if (scryfallId) {
279279+ if (scryfallId && oracleId) {
271280 facets.push({
272281 index: {
273282 byteStart: byteOffset,
···276285 features: [
277286 {
278287 $type: "com.deckbelcher.richtext.facet#cardRef",
279279- scryfallId,
288288+ ref: {
289289+ scryfallUri: toScryfallUri(asScryfallId(scryfallId)),
290290+ oracleUri: toOracleUri(asOracleId(oracleId)),
291291+ },
280292 },
281293 ],
282294 });
···569581 (f) =>
570582 (f as { $type?: string }).$type ===
571583 "com.deckbelcher.richtext.facet#cardRef",
572572- ) as { $type: string; scryfallId?: string } | undefined;
584584+ ) as
585585+ | { $type: string; ref?: { scryfallUri?: string; oracleUri?: string } }
586586+ | undefined;
573587574574- if (cardRefFeature) {
575575- nodes.push(
576576- schema.nodes.cardRef.create({
577577- name: segment.text,
578578- scryfallId: cardRefFeature.scryfallId || "",
579579- }),
580580- );
581581- continue;
588588+ if (cardRefFeature?.ref) {
589589+ const scryfallId = parseScryfallUri(cardRefFeature.ref.scryfallUri || "");
590590+ const oracleId = parseOracleUri(cardRefFeature.ref.oracleUri || "");
591591+ // Only create cardRef node if both URIs are valid, otherwise fall through to plain text
592592+ if (scryfallId && oracleId) {
593593+ nodes.push(
594594+ schema.nodes.cardRef.create({
595595+ name: segment.text,
596596+ scryfallId,
597597+ oracleId,
598598+ }),
599599+ );
600600+ continue;
601601+ }
582602 }
583603584604 // Check for tag facet - these become inline nodes
+35
src/lib/scryfall-types.ts
···2929 return id as OracleId;
3030}
31313232+// URI scheme prefixes for external indexing
3333+const SCRYFALL_URI_PREFIX = "scry:" as const;
3434+const ORACLE_URI_PREFIX = "oracle:" as const;
3535+3636+export type ScryfallUri = `${typeof SCRYFALL_URI_PREFIX}${string}`;
3737+export type OracleUri = `${typeof ORACLE_URI_PREFIX}${string}`;
3838+3939+/** Convert a ScryfallId to a scry: URI */
4040+export function toScryfallUri(id: ScryfallId): ScryfallUri {
4141+ return `${SCRYFALL_URI_PREFIX}${id}`;
4242+}
4343+4444+/** Convert an OracleId to an oracle: URI */
4545+export function toOracleUri(id: OracleId): OracleUri {
4646+ return `${ORACLE_URI_PREFIX}${id}`;
4747+}
4848+4949+/** Parse a scry: URI and return the ScryfallId, or null if invalid */
5050+export function parseScryfallUri(uri: string): ScryfallId | null {
5151+ if (!uri.startsWith(SCRYFALL_URI_PREFIX)) {
5252+ return null;
5353+ }
5454+ const id = uri.slice(SCRYFALL_URI_PREFIX.length);
5555+ return isScryfallId(id) ? id : null;
5656+}
5757+5858+/** Parse an oracle: URI and return the OracleId, or null if invalid */
5959+export function parseOracleUri(uri: string): OracleId | null {
6060+ if (!uri.startsWith(ORACLE_URI_PREFIX)) {
6161+ return null;
6262+ }
6363+ const id = uri.slice(ORACLE_URI_PREFIX.length);
6464+ return isOracleId(id) ? id : null;
6565+}
6666+3267export type Rarity =
3368 | "common"
3469 | "uncommon"
+29-2
src/lib/ufos-queries.ts
···5566import { queryOptions } from "@tanstack/react-query";
77import type { Result } from "./atproto-client";
88+import { transformListRecord } from "./collection-list-queries";
99+import { transformDeckRecord } from "./deck-queries";
810import type {
911 ComDeckbelcherCollectionList,
1012 ComDeckbelcherDeckList,
···1315 ActivityCollection,
1416 UfosDeckRecord,
1517 UfosListRecord,
1818+ UfosRawDeckRecord,
1919+ UfosRawListRecord,
1620 UfosRecord,
1721} from "./ufos-types";
1822···6266 return record.collection === "com.deckbelcher.collection.list";
6367}
64686969+function transformActivityRecord(
7070+ rawRecord: UfosRecord<unknown>,
7171+): ActivityRecord | null {
7272+ if (rawRecord.collection === "com.deckbelcher.deck.list") {
7373+ const deckRecord = rawRecord as UfosRawDeckRecord;
7474+ return {
7575+ ...deckRecord,
7676+ record: transformDeckRecord(deckRecord.record),
7777+ } as UfosDeckRecord;
7878+ }
7979+ if (rawRecord.collection === "com.deckbelcher.collection.list") {
8080+ const listRecord = rawRecord as UfosRawListRecord;
8181+ return {
8282+ ...listRecord,
8383+ record: transformListRecord(listRecord.record),
8484+ } as UfosListRecord;
8585+ }
8686+ return null;
8787+}
8888+6589/**
6690 * Query options for recent activity (decks + lists)
6791 */
6892export const recentActivityQueryOptions = (limit = 10) =>
6993 queryOptions({
7094 queryKey: ["ufos", "recentActivity", limit] as const,
7171- queryFn: async () => {
9595+ queryFn: async (): Promise<ActivityRecord[]> => {
7296 const result = await fetchRecentRecords<
7397 ComDeckbelcherDeckList.Main | ComDeckbelcherCollectionList.Main
7498 >(
···78102 if (!result.success) {
79103 throw result.error;
80104 }
8181- return result.data as ActivityRecord[];
105105+106106+ return result.data
107107+ .map(transformActivityRecord)
108108+ .filter((r): r is ActivityRecord => r !== null);
82109 },
83110 staleTime: 60 * 1000,
84111 refetchOnWindowFocus: true,
+20-4
src/lib/ufos-types.ts
···44 */
5566import type { Did } from "@atcute/lexicons";
77+import type { CollectionList } from "./collection-list-types";
88+import type { Deck } from "./deck-types";
79import type {
810 ComDeckbelcherCollectionList,
911 ComDeckbelcherDeckList,
···2224}
23252426/**
2525- * Deck record from UFOs API
2727+ * Raw deck record from UFOs API (before boundary transformation)
2628 */
2727-export type UfosDeckRecord = UfosRecord<ComDeckbelcherDeckList.Main>;
2929+export type UfosRawDeckRecord = UfosRecord<ComDeckbelcherDeckList.Main>;
28302931/**
3030- * Collection list record from UFOs API
3232+ * Raw list record from UFOs API (before boundary transformation)
3133 */
3232-export type UfosListRecord = UfosRecord<ComDeckbelcherCollectionList.Main>;
3434+export type UfosRawListRecord = UfosRecord<ComDeckbelcherCollectionList.Main>;
3535+3636+/**
3737+ * Deck record with transformed app types
3838+ */
3939+export type UfosDeckRecord = UfosRecord<Deck> & {
4040+ collection: "com.deckbelcher.deck.list";
4141+};
4242+4343+/**
4444+ * Collection list record with transformed app types
4545+ */
4646+export type UfosListRecord = UfosRecord<CollectionList> & {
4747+ collection: "com.deckbelcher.collection.list";
4848+};
33493450/**
3551 * Supported collection NSIDs for the activity feed
···11import "@typelex/emitter";
22import "./externals.tsp";
33import "./richtext.tsp";
44+import "./defs.tsp";
4556namespace com.deckbelcher.collection.list {
67 /** A curated list of cards and/or decks. */
···29303031 /** A card saved to the list. */
3132 model CardItem {
3232- /** Scryfall UUID for the card. */
3333+ /** Reference to the card (scryfall printing + oracle card). */
3334 @required
3434- scryfallId: string;
3535+ ref: com.deckbelcher.defs.CardRef;
35363637 /** Timestamp when this item was added to the list. */
3738 @required
+3-2
typelex/deck-list.tsp
···11import "@typelex/emitter";
22import "./externals.tsp";
33import "./richtext.tsp";
44+import "./defs.tsp";
4556namespace com.deckbelcher.deck.list {
67 /** A Magic: The Gathering decklist. */
···39404041 /** A card entry in a decklist. */
4142 model Card {
4242- /** Scryfall UUID for the specific printing. */
4343+ /** Reference to the card (scryfall printing + oracle card). */
4344 @required
4444- scryfallId: string;
4545+ ref: com.deckbelcher.defs.CardRef;
45464647 /** Number of copies in the deck. */
4748 @required
+15
typelex/defs.tsp
···11+import "@typelex/emitter";
22+33+namespace com.deckbelcher.defs {
44+ /** Reference to a Magic: The Gathering card with printing and oracle identifiers. */
55+ model CardRef {
66+ /** Scryfall printing URI (scry:<uuid>) - authoritative identifier */
77+ @required
88+ scryfallUri: uri;
99+1010+ /** Oracle card URI (oracle:<uuid>) - for external indexing.
1111+ * Derived from scryfallUri; on conflict, scryfallUri takes precedence. */
1212+ @required
1313+ oracleUri: uri;
1414+ }
1515+}
···11import "@typelex/emitter";
22import "./externals.tsp";
33+import "./defs.tsp";
3445namespace com.deckbelcher.richtext.facet {
56 /**
···84858586 /**
8687 * Facet feature for a card reference.
8787- * Links to a Magic: The Gathering card by Scryfall ID.
8888+ * Links to a Magic: The Gathering card.
8889 * The text is usually the card name.
8990 */
9091 model CardRef {
9292+ /** Reference to the card (scryfall printing + oracle card). */
9193 @required
9292- scryfallId: string;
9494+ ref: com.deckbelcher.defs.CardRef;
9395 }
9496}