Skip to main content

Decks

Decks are stored rows, not encoded strings. A deck has an owner, a format, a visibility, zones of cards, a collaborator roster and a revision history. The plain-text interchange format replaces the old short form: it can be pasted into a forum post, diffed, and typed by hand.

For full request/response schemas, see API reference.


Model​

Counting is by oracle, display is by printing. A deck_cards row names both: the printing supplies art and the printing rung of legality, while every construction rule (copy limits, domain matching, zone eligibility) reads oracle fields. Three copies of one card split across two arts are three copies against the limit and two rows in the list.

ZoneNotes
legendExactly one card
mainThe deck proper
sideboard
runes
battlefields
consideringOurs, not a game zone; counts toward nothing

The chosen champion is a flag on a main row, not a zone: you may run three copies and nominate one of them.

Tokens are derived, never stored membership. A deck's tokens are whatever its oracles' makes_token relationships point at, so a client cannot add or remove one. deck_token_printings only records which art the deck shows for a token it already makes.

Validation is advisory and computed on read, by the shared validateDeck in @riftseer/types/deck-validate: a deck saved under one set of format rules must stay loadable after those rules change. GET /decks/:id returns the violations alongside the cards.

Readable decks accept comments from any signed-in user. Threading is parent_id with a stored depth (capped at 7 — deeper replies flatten), read back as one flat list of up to 500 rows that the client arranges. Deleting is always a soft tombstone — the row stays so replies keep their place — by the comment's author or the deck's owner, and that is the entire moderation model. Each row carries can_delete for the caller, so clients never re-derive it. Any signed-in reader can like a comment: POST or DELETE on /decks/:id/comments/:commentId/like. Counts are computed on read and ride every comment as like_count; an authenticated caller also gets is_liked.

Folders are a user's private organisation of decks, under /deck-folders (a /decks/folders path would be swallowed by /decks/:id). v1 is flat, unordered and private. Any deck the owner can read may be filed — an item is a bookmark, not a claim — and folder contents are pruned on read to what is still readable. Deleting a folder never touches its decks.

Deck pages report a view count (view_count on every deck payload). The page fires POST /decks/:id/views once per visit; the API deduplicates per viewer for six hours — by user id when signed in, otherwise by a digest of address, client and UTC day that is never stored outside the expiring dedup key — and an owner viewing their own deck never counts. Counting a view does not touch updated_at, so browsing never reorders "recently updated".

Any deck you can read can be favorited. Counts are computed on read and ride every deck payload as favorite_count; an authenticated caller also gets is_favorited. GET /decks?filter=favorites lists the caller's favorites, pruned to decks they can still read.

Cards can carry manual tags — free-text labels (at most 20 per card, 40 characters each), stored per (deck, oracle) so a zone move or an art swap never drops them and both rows of a two-art card share one list. Tags are annotation, not deck content: they are not revisioned, they do not enter the text interchange format, and PUT /decks/:id/card-tags replaces a card's list wholesale. Every card row on the deck payload carries its tags (empty when none).

Each card and token row carries an optional image object of derived art URLs (small, normal, large, original — WebP variants for hosted art, only original when we merely know an upstream source, absent when no art exists). It is derived at read time and never stored, so a rehosted image changes the URLs without a deck write. Clients rendering a whole deck as a grid should read it rather than fetching card detail per row.


Visibility and roles​

Visibility and role are orthogonal.

VisibilityWho can read
publicAnyone, and it appears in the owner's public list
unlistedAnyone holding the id — never listed for another user
privateThe owner and collaborators
RoleCapability
ownerEverything, and the only role that may delete the deck, manage collaborators, or change visibility
editorCard mutations and metadata patches, but not visibility — being invited to help build is not consent to be published
viewerRead only

The Worker holds a service-role key and bypasses RLS, so the route code in src/routes/decks.ts is the real authorisation boundary; the database policies are defence in depth against a leaked anon key.

A deck the caller may not read returns 404, not 403 — a 403 would confirm the deck exists.


Endpoints at a glance​

MethodPathDescription
GET/api/v1/decksYour decks, or a user's by ?handle
POST/api/v1/decksCreate a deck
GET/api/v1/decks/:idCards, derived tokens and violations
PATCH/api/v1/decks/:idName, description, primer, format, visibility
DELETE/api/v1/decks/:idOwner only
PUT/api/v1/decks/:id/cardsBatch zone mutation
PUT/api/v1/decks/:id/card-tagsReplace one card's manual tags
POST/api/v1/decks/:id/favoriteFavorite (idempotent)
DELETE/api/v1/decks/:id/favoriteUnfavorite (idempotent)
POST/api/v1/decks/:id/viewsCount a view (deduplicated)
GET/api/v1/decks/:id/commentsList comments (flat; client builds the tree)
POST/api/v1/decks/:id/commentsComment or reply
DELETE/api/v1/decks/:id/comments/:commentIdTombstone a comment (author or deck owner)
GET/api/v1/deck-foldersYour folders (?deck= adds membership)
POST/api/v1/deck-foldersCreate a folder
GET/api/v1/deck-folders/:idOne folder and its readable decks
PATCH/api/v1/deck-folders/:idRename a folder
DELETE/api/v1/deck-folders/:idDelete a folder (decks untouched)
PUT/api/v1/deck-folders/:id/decks/:deckIdFile a deck (idempotent)
DELETE/api/v1/deck-folders/:id/decks/:deckIdUnfile a deck (idempotent)
GET/api/v1/decks/:id/revisionsEdit history; ?limit= 1–50, default 50
POST/api/v1/decks/:id/inviteCreate or regenerate the invite link
DELETE/api/v1/decks/:id/inviteDisable the invite link
POST/api/v1/decks/join/:codeRedeem an invite link
POST/api/v1/decks/:id/collaboratorsInvite by handle (owner only)
DELETE/api/v1/decks/:id/collaborators?handle=Remove (owner only)
POST/api/v1/decks/importImport Moxfield-style text
GET/api/v1/decks/:id/exportExport Moxfield-style text

Reads take optional auth — who is asking changes the answer, but anonymous is allowed. Every write requires a bearer token.


PUT /api/v1/decks/:id/cards​

One call carries a whole batch, because the builder edits in bursts and the history should record intent rather than keystrokes.

PUT /api/v1/decks/<id>/cards
{
"changes": [
{ "zone": "legend", "printing_id": "67f4…dd7", "oracle_id": "…", "quantity": 1 },
{ "zone": "main", "printing_id": "67f4…abc", "oracle_id": "…", "quantity": 3, "is_champion": true },
{ "zone": "main", "printing_id": "67f4…def", "quantity": 0 }
]
}

quantity: 0 removes the row, and a removal need not restate oracle_id. The whole batch is applied by the deck_apply_card_changes RPC in one transaction: revision creation, the five-minute coalescing window and the champion hand-off all have to be atomic.

The response is the re-read view — revision_id, cards, tokens, violations — so the builder never has to guess at the new state. revision_id is null when a burst nets to no change at all.


POST /decks/:id/invite generates a fresh code and sets the role it grants. Redeeming through POST /decks/join/:code inserts a deck_collaborators row with added_via: "link", which is what makes the two operations independent: regenerating or disabling the link never kicks anyone already in, and any individual collaborator stays revocable.


Text interchange​

Moxfield-style: zone headers, then <qty> <name> lines with an optional (SET) COLLECTOR suffix pinning one printing and a trailing *CH* marking the chosen champion.

Legend
1 Test Legend (OGN) 001

Main
3 Test Unit (OGN) 002 *CH*

Runes
12 Test Rune (OGN) 003

Parsing never throws. POST /decks/import resolves each name (and optional set and collector number) against the catalogue — that resolution is the API's job, since only it can see the catalogue — and reports the lines it could not read in unresolved rather than failing the import. A line whose card cannot sit in the zone its header named is routed to the zone it is eligible for, which is what makes a bare list with no headers import correctly.

A Champion header is accepted on import even though a champion is not a zone. Other sites section the chosen champion separately; here it is a flag on a main-deck row, so those lines land in main flagged as if each carried *CH*. Export writes the flag, never the section.


Flow diagram​