Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/runbook/ingest.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ The chunk shape is owned by the workflow; check `workflows/scryfall/steps.ts` fo

---

## 6. Related runbooks
## 6. First run after the JP-collector-printings deploy (one-time `printingsUpdated` spike)

The commit that added curated Japanese collector printings (`feat(scryfall): ingest curated
Japanese collector printings`) folded `lang` and `printedName` into the printing **version**
hash (`lib/scryfall/map.ts`). Every printing row stored before that deploy carries a hash that
predates those two fields, so the **first** post-deploy Scryfall bulk run sees every printing
as `toUpdate` and rewrites the entire `printing` table once.

Expected on that first run only:

- `printingsUpdated` ≈ the full printing count (not the usual near-zero delta).
- Elevated step duration and Postgres WAL volume for `upsertBatch` (one large rewrite).

This is **bounded and expected** — not a regression. Subsequent runs return to the normal
near-zero `printingsUpdated`. If the spike repeats on a later run, that *is* anomalous: check
that the version hash is stable (no per-run nondeterminism in `hashObject(base)`).

---

## 7. Related runbooks

- `docs/ops/postgres-runbook.md` — Postgres pressure during/after ingest, autovacuum tuning on `card`/`printing`, and §10 specifically for "ingest hasn't run lately" diagnostics.
27 changes: 27 additions & 0 deletions lib/scryfall/__tests__/map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,33 @@ describe("toPrintingCreate", () => {
expect(a.version).toBe(b.version);
});

it("defaults lang from card.lang and printedName null when absent", () => {
const p = toPrintingCreate(1, makeCard());
expect(p.lang).toBe("en");
expect(p.printedName).toBeNull();
});

it("maps lang and printedName from a Japanese printing", () => {
const p = toPrintingCreate(
1,
makeCard({ lang: "ja", printed_name: "対抗呪文" }),
);
expect(p.lang).toBe("ja");
expect(p.printedName).toBe("対抗呪文");
});

it("changing lang changes the version", () => {
const a = toPrintingCreate(1, makeCard());
const b = toPrintingCreate(1, makeCard({ lang: "ja" }));
expect(a.version).not.toBe(b.version);
});

it("changing printedName changes the version", () => {
const a = toPrintingCreate(1, makeCard({ printed_name: "稲妻" }));
const b = toPrintingCreate(1, makeCard({ printed_name: "対抗呪文" }));
expect(a.version).not.toBe(b.version);
});

it("changing setCode changes the version", () => {
const a = toPrintingCreate(1, makeCard());
const b = toPrintingCreate(1, makeCard({ set: "other" }));
Expand Down
11 changes: 9 additions & 2 deletions lib/scryfall/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ const DENIED_LAYOUTS = new Set([
"art_series",
]);

export function filterCard(card: ScryfallCard): boolean {
if (card.lang !== "en") return false;
// Language-agnostic playability guard: rejects non-deckable layouts and
// non-paper (digital-only) printings. Shared by the bulk path (via `filterCard`)
// and the JP collector-printing path, which carries `lang !== "en"` by design
// and so can't reuse `filterCard` directly.
export function isPaperPlayable(card: ScryfallCard): boolean {
if (DENIED_LAYOUTS.has(card.layout)) return false;
if (!card.games?.includes("paper")) return false;
return true;
}

export function filterCard(card: ScryfallCard): boolean {
return card.lang === "en" && isPaperPlayable(card);
}
18 changes: 18 additions & 0 deletions lib/scryfall/jp-collector-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Source of truth for which Japanese collector printings the ingest enriches
// after the English bulk upsert. Each entry is a Scryfall search query run with
// `unique=prints`; results across queries are deduped by `scryfallId` before
// upsert. Spot-check a query's result count before adding it.
//
// The art tag does the heavy lifting — `art:japanese-exclusive-art` already
// isolates Japanese-exclusive-art printings across every set (sta, soa, war,
// iko, sld, snc, ...), so new exclusive-art sets are picked up with no
// per-set maintenance. NEO is a named supplement because its soft-glow /
// ukiyo-e treatments reuse the English art (a finish/frame, not exclusive art)
// and so fall outside the art tag.
export const JP_COLLECTOR_QUERIES = [
// Cross-set Japanese-exclusive-art collector printings. `-is:promo` drops the
// scattered promo sets (Player Rewards, Worlds, premium-foil).
"art:japanese-exclusive-art lang:ja -is:promo",
// Kamigawa soft-glow / ukiyo-e reuse English art, so the art tag misses them.
"set:neo lang:ja is:fullart",
] as const;
2 changes: 2 additions & 0 deletions lib/scryfall/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export function toPrintingCreate(
priceEurFoil: parsePrice(card.prices?.eur_foil),
priceEurEtched: parsePrice(card.prices?.eur_etched),
rarity: normalizeRarity(card.rarity),
lang: card.lang,
printedName: card.printed_name ?? null,
};

return { ...base, version: hashObject(base) };
Expand Down
1 change: 1 addition & 0 deletions lib/scryfall/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const ScryfallCardSchema = z
layout: z.string(),
games: z.array(z.string()),
name: z.string().min(1),
printed_name: z.string().optional(),
type_line: z.string().optional(),
oracle_text: z.string().optional(),
mana_cost: z.string().optional(),
Expand Down
3 changes: 3 additions & 0 deletions prisma/migrations/20260608223646_printing_lang/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "printing" ADD COLUMN "lang" TEXT NOT NULL DEFAULT 'en',
ADD COLUMN "printed_name" TEXT;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ model Printing {
priceEurFoil Decimal? @map("price_eur_foil") @db.Decimal(10, 2)
priceEurEtched Decimal? @map("price_eur_etched") @db.Decimal(10, 2)
rarity Rarity?
lang String @default("en")
printedName String? @map("printed_name")
version String?
deckCards DeckCard[]
holdings Holding[]
Expand Down
35 changes: 35 additions & 0 deletions workflows/scryfall/__tests__/ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock("../steps", () => ({
releaseIngestLock: vi.fn(),
downloadAndStage: vi.fn(),
upsertBatch: vi.fn(),
ingestCollectorPrintings: vi.fn(),
commitScryfallCheckpoint: vi.fn(),
cleanupStaging: vi.fn(),
}));
Expand All @@ -23,6 +24,7 @@ import {
downloadAndStage,
fetchBulkManifest,
getLastCheckpoint,
ingestCollectorPrintings,
releaseIngestLock,
SCRYFALL_SOURCE,
upsertBatch,
Expand All @@ -35,6 +37,7 @@ const mockedAcquireLock = vi.mocked(acquireIngestLock);
const mockedReleaseLock = vi.mocked(releaseIngestLock);
const mockedDownload = vi.mocked(downloadAndStage);
const mockedUpsert = vi.mocked(upsertBatch);
const mockedIngestJp = vi.mocked(ingestCollectorPrintings);
const mockedCommit = vi.mocked(commitScryfallCheckpoint);
const mockedCleanup = vi.mocked(cleanupStaging);

Expand All @@ -54,6 +57,7 @@ function emptyBatchStats() {
beforeEach(() => {
vi.clearAllMocks();
mockedUpsert.mockResolvedValue(emptyBatchStats());
mockedIngestJp.mockResolvedValue(emptyBatchStats());
mockedCommit.mockResolvedValue(undefined);
mockedCleanup.mockResolvedValue(undefined);
mockedAcquireLock.mockResolvedValue(true);
Expand Down Expand Up @@ -99,6 +103,10 @@ describe("scryfallIngestWorkflow", () => {
callOrder.push("upsert");
return emptyBatchStats();
});
mockedIngestJp.mockImplementation(async () => {
callOrder.push("ingestCollectorPrintings");
return emptyBatchStats();
});
mockedCommit.mockImplementation(async () => {
callOrder.push("commitScryfallCheckpoint");
});
Expand All @@ -125,6 +133,7 @@ describe("scryfallIngestWorkflow", () => {
"download",
"upsert",
"upsert",
"ingestCollectorPrintings",
"commitScryfallCheckpoint",
"cleanup",
]);
Expand All @@ -143,6 +152,32 @@ describe("scryfallIngestWorkflow", () => {
);
});

it("commits the checkpoint when JP enrichment rejects (best-effort, no strand)", async () => {
mockedFetch.mockResolvedValue({
downloadUri: "https://d.example/file.json",
updatedAt: "2026-02-15T00:00:00Z",
});
mockedGetCheckpoint.mockResolvedValue("2026-01-01T00:00:00Z");
mockedDownload.mockResolvedValue({ totalBatches: 1, filterSkipped: 0 });
mockedUpsert.mockResolvedValue(emptyBatchStats());
// A search-API outage throws inside the enrichment step. It must NOT skip
// the checkpoint write, or the next cron re-downloads the full bulk.
mockedIngestJp.mockRejectedValue(new Error("scryfall search down"));

const result = await scryfallIngestWorkflow();

expect(mockedCommit).toHaveBeenCalledWith(
SCRYFALL_SOURCE,
"2026-02-15T00:00:00Z",
);
expect(mockedCleanup).toHaveBeenCalledWith("test-run-id", 1);
expect(mockedReleaseLock).toHaveBeenCalledWith(
SCRYFALL_SOURCE,
"test-run-id",
);
expect(result).toMatchObject({ updatedAt: "2026-02-15T00:00:00Z" });
});

it("does not write checkpoint when a batch fails, but still cleans up staging", async () => {
mockedFetch.mockResolvedValue({
downloadUri: "https://d.example/file.json",
Expand Down
Loading