Learn Decks Cloud Sync

Syncs CardMirror's Learn custom decks (deckId/name/cardIds/ createdAt) to a signed-in user's account, the same way Learn Flashcards Cloud Sync syncs a card's own content — the Learn store (packages/debate-editor/src/editor/learn-store.ts) was device-local only.

  • Package: debate-editor (learnDecksSync, LearnDecksSync, learn-decks-client, learn-deck-manage-ui), apps/debate-ai.com (/api/learn-decks, saved_learn_decks D1 table)

What it does

On boot, once the local Learn store has loaded, learnDecksSync.init() merges once against the account: any deck synced from another device but missing locally is adopted under its own id, and any deck that exists only on this device is best-effort pushed up — neither side ever overwrites the other, it only fills gaps. After the merge, every later deck create or delete mirrors itself to the account the same way; a rename or membership change instead sends one op (addCardId/removeCardId/rename, PATCH /api/learn-decks/[deckId]) per actual change, resolved against the account's current stored deck rather than a whole-deck replace — see Known gaps. Signed out, or under the Electron desktop build, decks keep working exactly as before, purely on-device.

A deck's cardIds reference cards synced separately by Learn Flashcards Cloud Sync; a deck can legitimately reference a card that hasn't reached this device yet, the same soft-reference gap that sync already accepts for card content.

What's intentionally excluded

The other Learn sub-collections (schedules, anchors, AI threads, notes, doc registry) stay local-only for the same reason learn-cards-cloud-sync.mdx documents: they live in one shared blob per device, not one row per record, so they don't fit this sync's per-record shape. The review log syncs separately — see Learn Review Log Cloud Sync.

Known gaps

  • No optimistic-concurrency handling — editing the same deck (renaming it, or changing its members) from two signed-in devices at once has the last write win. Fixed: LearnDecksSync#pushDeckChange (learn-decks-sync.ts) now diffs the previous vs. current deck and sends one op per actual change — addCardId/removeCardId per card, rename for a name change — instead of the whole {name, cardIds} snapshot it used to push. PATCH /api/learn-decks/[deckId] resolves each op against the row's current stored deck (read-then-write) via applyLearnDeckOp (debate-editor's learn-deck-op.ts), the same shape /api/settings's favoriteTools/wordLimitPresets/ outlineFilterPresets/savedArgumentCollections ops already use — so two devices adding, removing, or renaming at once can no longer drop each other's change. A brand-new deck still pushes in full (there is no row to race against yet); if an op 404s — this deck's own create push hasn't landed yet — pushDeckChange falls back to a full PUT, which also covers every pending change in one call. PUT itself is unchanged and still accepted for a caller that genuinely needs a whole-deck replace, but nothing in this app sends one for a rename or membership change anymore.
  • No dedicated deck-management UI shows a per-deck sync status yet; decks are currently created/edited from the home screen's scope picker, which has no sync indicator. Fixed, and the old description was stale on top of being incomplete: the "home screen's scope picker" it referred to (home-screen.ts's per-scope "due today" rows) can only ever display an existing deck — it never called createDeck/renameDeck/deleteDeck/ setDeckMembership — and that screen has been a permanent no-op since 2026-08-26 regardless (see its own show() doc comment), so it was never a reachable path either way. Before this fix, a deck could only come to exist on a device via account sync adopting one created somewhere, but no device anywhere could create the first one — decks were fully unreachable from the UI. learn-deck-manage-ui.ts's Decks button (next to History in the "Manage flashcards" overlay's bar) opens create/rename/two-click-delete for decks, and an expandable per-deck card picker (add from a dropdown of cards not yet in the deck, remove with a per-card button) — the first UI surface for any of LearnStore's deck mutators. It shows the same coarse "Synced to your account" / "Not synced — sign in to sync" line (via learnDecksSync.isSynced()) the flashcard list and Review history already do. LearnDecksSync tracks no per-deck status (its pushes/deletes are fire-and-forget), so a per-deck badge would need that state added to the sync class first — a real follow-up, not done here. Fixed: LearnDecksSync now keeps a landed map of each deck's last confirmed value — advanced only once a create/op/fallback push actually resolves, distinct from the baseline map used purely to diff the next change — and exposes it as getDeckSyncStatus(deckId) ("synced" / "pending" / "unknown", mirroring debate-data-sync's getToolRecordSyncStatus shape) plus an onStatusChange subscription so a caller can re-render once a fire-and-forget push settles, since that alone doesn't mutate the store. learn-deck-manage-ui.ts shows each deck's status as a small "Synced" / "Not yet synced" badge next to its name — the same chip language user-dictionary-ui.ts uses for getToolRecordSyncStatus — omitted entirely while the coarse status is "unknown".