Card Library Import (Parquet)
How the evidence corpus behind CARD search (/research/cards) gets loaded. The
published card dump ships as Parquet shards — one row per cut card, with the
tagline, both cites, the spoken and full text, the card HTML, and the caselist
metadata — and this feature imports them, a file at a time, either from a
terminal or from /admin.
- CLI:
debate-cards-upload(packages/debate-search-evidence/src/cli/upload-parquet.ts) - Admin panel: the "Card library import" card on
/admin(apps/debate-ai.com/components/admin/DebateCardParquetUpload.tsx) - Endpoint:
GET/POST /api/admin/debate-cards - Tables:
debate_cards,debate_card_imports(apps/debate-ai.com/lib/database/schema.ts, migrationdrizzle/0035_debate_cards.sql) - Package:
debate-research-evidence(lib/parquet-card-import.ts,lib/parquet-card-reader.ts,lib/parquet-card-upload.ts,lib/parquet-upload-cli-options.ts)
Why the file is never uploaded
A shard is hundreds of megabytes of card HTML — past a Worker's request limit, and past the memory a Worker has to decode Parquet in. So the decode happens where the file already is:
- The client (CLI or browser) reads the shard's footer, then walks it one row group at a time — nothing holds the whole file.
- Each window is normalized into card records and split into batches of ~250.
- Each batch is
POSTed to/api/admin/debate-cards, which re-validates every row and upserts it.
The endpoint therefore only ever sees rows, and a 40 GB corpus imports through the same code path as a 40 MB one.
Both importers call the same functions to read and normalize, so a row the CLI skips is a row the admin panel skips, with the same code and the same reason.
How a read window is sized
A row group is the smallest thing a Parquet decoder can read: asking for rows
0–2,000 of a 500,000-row group decodes all 500,000 and discards the rest. So
windows are cut on the shard's own row groups, never on a flat row count —
a window that straddles a boundary, or a run of windows inside one group, pays
to decode that group again every time. The importer reads a group in one window
whenever its card columns fit in 32 MB (the footer's uncompressed sizes say
whether they do), and splits the group only when they do not, at most 100,000
rows at a time. --chunk forces a smaller ceiling; leave it unset and the
shard decides.
The footer is parsed once per shard and handed to every window, so a wide dump does not re-read megabytes of metadata per read.
Columns
| Parquet column | Column stored | Notes |
|---|---|---|
id | id (primary key) | The dump's own id. Required — a row without one has no upsert key. |
tag | tag | The claim the card is read for. |
cite / fullcite | cite, fullcite | Short cite and full citation. |
summary, spoken, fulltext | same | Underlined reading, spoken text, unhighlighted body. |
textLength | text_length | Derived from the body when the column is absent. |
markup | markup | Card HTML with <mark>/<u> intact. |
pocket, hat, block | same | The dump's three outline levels. |
bucketId, duplicateCount | bucket_id, duplicate_count | Dedup bucket and corpus-wide repeat count. |
side, year, event, level | same | Normalized to A/N, an integer, and lowercase codes. |
caselistDisplayName | caselist_display_name | e.g. NDT/CEDA 2021-22. |
| — | source_file, imported_at | Which shard the row came from, and when. |
snake_case spellings (text_length, caselist_display_name, full_text, …)
are accepted too, since the same dump is published both ways. Columns the table
does not store (embeddings, provenance blobs) are never decompressed — the
importer asks only for the card columns the shard actually has, so a shard
missing one still imports the rest.
Rows that are skipped
Every rejection is reported with a code and a reason, per row, rather than dropped silently:
| Code | Meaning |
|---|---|
missing-id | No positive integer id. |
empty-card | An id, but no tag, summary, spoken, fulltext or markup. |
row-too-large | Over 1,000,000 characters in one card — a malformed row. |
not-a-row | Not an object; the file is probably not a card dump. |
Ids repeated within a run are dropped as duplicates (last write wins), which is also what keeps SQLite from rejecting a batch that names the same id twice.
Re-importing
Cards are upserted by id, so importing the same shard twice is a no-op and
a corrected shard replays cleanly over the old one. debate_card_imports keeps
one row per file — rows imported, rows skipped, who ran it, and when — and
resets that file's counters when a new run starts rather than doubling them.
CLI
# One shard against a locally running dev server
bun run --filter debate-research-evidence upload:cards cards-0000.parquet
# A whole directory, one file at a time, against production
CARD_IMPORT_TOKEN=… bunx debate-cards-upload shards/*.parquet \
--endpoint https://debate-ai.com/api/admin/debate-cards
# Check a shard parses, and see what would be skipped, writing nothing
bunx debate-cards-upload cards-0000.parquet --dry-run| Flag | Purpose |
|---|---|
--endpoint <url> | Ingest endpoint (env DEBATE_CARDS_ENDPOINT). |
--token <token> | Bearer token matching the server's CARD_IMPORT_TOKEN (env CARD_IMPORT_TOKEN). |
--cookie <cookie> | A signed-in admin session's cookie, instead of a token (env DEBATE_ADMIN_COOKIE). |
--batch <rows> | Cards per request (default 250). |
--chunk <rows> | Cap the rows decoded per read window. Unset by default — windows are sized from the shard's row groups. |
--start-row <n> | Skip the first n rows of each shard — how an interrupted import resumes. |
--max-rows <n> | Import at most n rows per shard. |
--per-file-dedupe | Dedupe ids within each shard only, not across the run. |
--dry-run | Read and normalize, post nothing. |
--quiet | Only print each shard's final summary. |
Files are imported strictly one after another, each with its own summary. A
shard that fails does not abandon the rest of the run, but the exit code is
non-zero (1 for a failed file, 2 for a usage error).
Admin panel
The Card library import card on /admin does the same thing for an
operator with a shard on their laptop and no terminal: pick one or more
.parquet files, watch each one's progress bar, stop mid-run if needed. The
tab has to stay open for the duration, since the browser is doing the decoding.
Below the queue it shows what the library currently holds and every file
imported into it so far.
Syncing openCaselist speech docs
The Sync ongoing speech docs card on /admin pulls this season's
openCaselist bulk archives (HS Policy, HS LD, HS PF, NDT/CEDA, NFA LD) into the
same library. Season dumps are large (the HS PF dump was 1.9 GB in October
2026), so the panel never downloads one whole: it reads the archive's file list
first, then fetches the documents a few megabytes at a time, showing how much
of the current archive has been read. Keep the tab open until it finishes.
Everything that goes wrong is written to the Import log under the caselist rows: a caselist whose archives could not be found (with the reason for each source tried), an archive that could not be opened, every document that could not be read, and every batch of cards the server rejected. The log survives a reload, can be filtered to errors and warnings, and has Copy and Download buttons for sharing it.
Search index
/api/search matches card text through an SQLite FTS5 index,
debate_cards_fts (apps/debate-ai.com/lib/search/card-fts.ts), and ranks it
with bm25(), weighting the tag above the summary, highlighted text, cite and
body. The index is external-content: it stores tokens and positions only, and
three triggers on debate_cards keep it in step with every insert, update of
an indexed column, and delete — so an import needs no extra step. The
debate_cards_vocab view (fts5vocab) feeds /api/search/autocomplete.
There is no migration folder, so the first search creates the index. A corpus
of up to 20,000 cards is indexed right then; a larger one is left to the
admin backfill, and searches keep using the old LIKE match until it is done.
/api/admin/search-index (same authorization as below) reports coverage on
GET and takes POST {"action": …}:
backfillindexes the next 10,000 cards the index lacks. Repeat until the response says"complete": true.rebuildre-indexes everything in one statement. Run it after restoring a SQL backup: the backup'sINSERT OR REPLACEbypasses the delete trigger and leaves stale entries behind.optimizemerges index segments after a large import.
The weekly cron (lib/search/card-search-maintenance.ts) does the same
backfill on its own, up to 250,000 cards a run, then optimizes, so a large
corpus reaches the index without anyone pressing the button. It and every
admin action also create the two sort indexes
(lib/search/card-search-indexes.ts): imported_at, id for the newest-first
list the page opens on, and duplicate_count for Most read. They are never
built from a search request, because building one holds the database.
Time limits
D1 can't cancel a query, so /api/search stops waiting instead
(lib/search/deadline.ts). A ranked FTS query gets 2.5 s; past that the
route runs the same match unranked, which stops at the result limit instead
of scoring every match, and gives it 2 s. The LIKE fallback gets 4 s. A
search that runs out answers timedOut: true with what it has, possibly
nothing. The page gives up on a request after 10 s instead of grab's default
30 s.
Known gaps
- Underlined text is not indexed (it exists only as
<u>tags inmarkup), so an Underlined scope still runs the slowLIKEsearch. - Autocomplete ranks words by how often they occur in cards, not by what people search for.
- A timed-out query keeps running in D1 after the route answers, so a burst of very slow searches still loads the database.
- The page doesn't tell you a result list was cut short by
timedOut.
Authorization
POST and GET /api/admin/debate-cards accept either:
- an admin session — the
ADMIN_EMAILSallowlist that gates the rest of/admin(open to everyone when unset), which is how the admin panel and--cookieauthenticate; or - a bearer token equal to the
CARD_IMPORT_TOKENsecret, which is how the CLI authenticates with no browser session. The token path exists only when that secret is configured — an unset secret is never an unauthenticated write path.
Set it with npx wrangler secret put CARD_IMPORT_TOKEN.
