Skip to content

feat: scale the deck schema to Anki-sized decks (#43, all phases) - #49

Merged
jvsena42 merged 12 commits into
mainfrom
feat/43-chunked-deck-schema
Aug 16, 2026
Merged

feat: scale the deck schema to Anki-sized decks (#43, all phases)#49
jvsena42 merged 12 commits into
mainfrom
feat/43-chunked-deck-schema

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Implements all seven phases of #43. Twelve atomic commits; each phase verified with tests, lint, and an end-to-end run on device against a real homeserver.

Why

The layout assumed small hand-curated decks: one homeserver record per card, plus a manifest carrying an unbounded card index. Real Anki decks are 2k–50k cards.

At 20k cards Before After
manifest.json ~1.46 MB ~3 KB
One card edit uploads ~1.46 MB ~63 KB
Publish requests 20,001 serial ~201, concurrent
Deck open requests 20,000 serial ~200, concurrent
Deck grid, 5 decks ~7.3 MB ~15 KB
Studied deck, total records ~40,000 ~400

What's in it

§1 Chunked cards + slim manifest. Cards batch into cards/{n}.json (100 each); the manifest carries card_count + a chunk table and no card index. Study order moves onto the card as a sparse ord, so an insert takes a midpoint instead of renumbering everything after it.

§1b Cheap metadata saves. Renaming a deck wrote every chunk. Saves that don't touch the card set now write one record.

§4 Concurrency, resumability, progress. Bounded parallelism; the manifest is written first with incomplete: true so an interrupted publish stays reachable and deletable instead of orphaning chunks; Flow-shaped progress; list() finally paginated.

§6 + §2 Provenance and SRS. A source block for clone/import lineage. Review state moves to /pub/loopky/srs/{authorPubky}/{deckId}/{n}.json — chunked, and author-scoped so two authors sharing a deck id stop colliding — with in-memory write-behind.

§3 Media. Blobs resolve against the deck's author, absolute pubky:// refs let a clone reference an origin, rehost() copies it locally on first use, plus an LRU so images survive recomposition.

§5 Bulk import. parseBulk with file-sized limits, a summary screen instead of swipe-triage, debounced live preview, and truncation reported instead of silently swallowed.

§7 .apkg — see below.

Things that turned out differently than planned

  • .apkg needed none of its three estimated dependencies. The estimate assumed a KMP zip reader, a SQLite driver and zstd. Android has java.util.zip and android.database.sqlite in the platform. So it shipped in this PR rather than waiting. Not handled, and reported rather than failed on: zstd collection.anki21b, and iOS (no platform zip/SQLite for Kotlin/Native, and iOS isn't runnable end to end yet).
  • The homeserver rate-limits concurrent writes. Deck/card schema doesn't scale to Anki-sized decks: chunk records, slim the manifest, add provenance #43 listed this as unknown feat: Discover social MVP (follow graph + friend profiles + following feed) #2. Publishing 1,200 cards with 8 in flight reliably returned 429. Fixed with bounded backoff-and-retry, and MAX_IN_FLIGHT lowered to 4. This was only found by running it — no unit test would have.
  • Deck.pubkyUri was left alone. The plan said to route it through PubkyPaths; that inverts the layering (domain → data, Architecture §4.1).

Bugs fixed structurally, not patched

  1. CardRepository.upsert/delete never touched the manifest, so single-card edits left it permanently stale and deletes left dangling entries re-fetched on every load.
  2. sync() diffed the manifest against the local cache and issued homeserver DELETEs for the difference — a stale cache could delete live cards.
  3. MediaRepository.get/delete resolved against the session author, making media on any deck you don't own unreachable (Split keeping someone else's deck into two features: Follow deck (read-only, receives updates) and Clone deck (copy into your account) #33 blocker 2).
  4. publish() orphaned chunk records when a deck shrank.
  5. MutableSessionProvider.value was non-atomic; SessionRevalidatorImpl serialized revalidation without coalescing it. Both fixed before enabling concurrency.

Breaking

On-homeserver layout changes with no migration, per the issue — nothing is published. SCHEMA_VERSION deliberately stays 1. Existing test decks must be wiped; they decode with card_count = 0.

Verification

  • ~700 tests green across targets; detekt clean; APK builds.
  • On device against a real homeserver: 6-card paste round trip with ordering preserved across a cold restart; single-card edit persisting through the chunk+manifest path; 1,200-card .txt import; 800-note .apkg with HTML and a tags column; and an interrupted publish confirmed to leave a reachable, deletable deck.

Refs #43. Unblocks #33 blockers 2, 3 and 4 (blocker 1 is independent and stays there).

🤖 Generated with Claude Code

jvsena42 and others added 2 commits August 15, 2026 20:49
Every published deck cost one homeserver record per card, and manifest.json
carried an unbounded card index (~73 bytes/card). At Anki proportions that
breaks on every axis: a 20k-card deck meant a ~1.46 MB manifest, 20,001 serial
PUTs to publish, 20,000 GETs to open, and a full 1.46 MB rewrite to edit a
single card. A deck tile could not render "20,000 cards" without downloading
all 20,000 entries.

Cards now live batched in `cards/{n}.json` (CHUNK_SIZE = 100), and the manifest
carries only `card_count` plus a `chunks[{n, count, updated_at}]` table. At 20k
cards that is ~3 KB of manifest, ~201 writes to publish, ~200 reads to open,
and ~63 KB to edit one card.

Study order moves onto the card as a sparse `ord` (stride 1000), so inserts
take a midpoint instead of renumbering every following card — which under
chunking would mean rewriting every chunk. Membership is the union of the
chunks, so there is no separate index to drift.

Two bugs fall out of the restructure rather than being patched around:

- CardRepository.upsert/delete never touched the manifest, so a single-card
  edit left `cards[].updated_at` stale forever and a delete left a dangling
  index entry that fetchByDeck re-GET'd and logged as unreadable on every
  load. Single-card writes now go through DeckRepository.upsertCard/deleteCard,
  which own the chunk write and the manifest patch together.
- sync() diffed the manifest against the local card cache and issued homeserver
  DELETEs for anything missing, so a stale cache could delete a live card.
  Membership now falls out of the chunks it reads.

Locating a card to edit uses the chunk mapping the card cache records, so an
edit reads one chunk rather than scanning all of them — pinned by a test.

The app is test-env only with nothing published, so there is no migration and
SCHEMA_VERSION stays at 1; existing test decks must be wiped.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites Architecture §8.0 (paths, both JSON schemas, all five sync steps) and
specs §11.1 for the chunked layout, and adds Architecture §8.4 recording the
scale targets, the numbers chunking buys, and the four things still unmeasured
— chief among them the homeserver's per-record ceiling, which is what should
really set CHUNK_SIZE.

Two sections were already wrong before this change and are corrected rather
than carried forward:

- §8.3 and §12 #6 said SRS is "in-memory in v1; not synced to Pubky". It has
  been Pubky-backed since SrsStateDto, which said so in its own KDoc. §8.3 also
  had no row for decks you follow but don't own.
- §8.0's path table omitted `srs/` entirely, so it disagreed with PubkyPaths.

Also narrows §14's "the triage queue is the reusable spine" claim: bulk import
breaks it, since nobody swipes through a 20k-card Anki export. What sources
actually share is the parse → preview → commit spine. §5.4 gains the bulk
summary screen, and §13 Q4 (max paste size) is resolved.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42

Copy link
Copy Markdown
Owner Author

Follow-ups filed for the gaps this PR records rather than closes:

Remaining #43 phases follow in their own PRs.

jvsena42 and others added 10 commits August 15, 2026 21:05
…s alone

DeckEditorViewModel.save() called publish() for every change, which rewrites
every chunk plus the manifest. Renaming a 20k-card deck therefore re-uploaded
all 20,000 cards to change one string — ~201 requests for a field that lives in
a single record.

Saves that don't touch the card set now go through updateMetadata(), writing
one record. Card adds, removes, edits and reorders still republish, since those
do change what the chunks must contain.

The check compares against the cards as loaded rather than against a count, so
swapping a card's text without changing how many there are still republishes.
It deliberately ignores updatedAt, which buildCards restamps on every save and
which would otherwise report every save as a change. A deck whose manifest has
a card count but no chunk table is treated as changed, so a malformed manifest
repairs itself on the next save rather than being patched in place.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rogress

Every homeserver operation was a serial loop, so a 20k-card publish was 201
round trips end to end and opening that deck was another 200. Publishing,
chunk reads, deck listing and the delete sweep now run up to 8 requests at a
time via a shared mapConcurrently helper. Bounded rather than unlimited: the
FFI's concurrency characteristics are undocumented, and a homeserver may well
rate-limit a client that opens hundreds of connections.

Two prerequisites had to land first, since concurrency makes both much hotter:

- MutableSessionProvider.value was a plain non-atomic var, read on every
  authenticated call and written by sign-in/sign-out/revalidation. Now
  @volatile — without it a thread could keep observing a stale secret and
  retry forever.
- SessionRevalidatorImpl serialized revalidation but did not coalesce it:
  every waiter re-ran the FFI call in turn. It now checks whether the secret
  it was waiting on has already been replaced and reuses the refreshed
  session. This matters much more when an expiry fails eight parallel writes
  at once.

Publish is also now interruptible without leaving wreckage. The manifest is
written first with `incomplete: true` to claim the deck, then the chunks, then
the manifest again to clear the mark. Previously the manifest went last, so a
failure part-way through left orphaned chunk records under a deck root that
listByAuthor could not see — the user had no way to reach the deck to delete
it. Chunk PUTs are idempotent overwrites, so re-running publish simply
rewrites them.

publish() gains a progress overload feeding Flow-shaped updates into
PublishDeckUiState, so a large import shows a real bar instead of a spinner.
The chunk counter is mutex-guarded because chunks now complete out of order.

Finally, list() is paginated. It has always accepted cursor/limit and no call
site used them, so anything past the server's default page was invisible —
survivable for a deck listing, not survivable for delete(), which relies on
that sweep to avoid orphaning records.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-behind

SRS was one record per card under /decks/{deckId}/srs/{cardId}.json, so a
studied 20k-card deck was ~40,000 homeserver records on top of the deck's own,
and dueForDeck was 20,000 GETs. It also keyed only on deckId, so two authors
whose decks share an id collided in your srs/ tree — #33 blocker 4, and exactly
the collision that following decks from many authors makes likely.

Review state now lives at /pub/loopky/srs/{authorPubky}/{deckId}/{n}.json:
still on *your* homeserver, but keyed by the deck's author and batched. Moving
it out from under /decks/ is the point, not a side effect — your review state
for someone else's deck was never the owner's data, and the old nesting only
looked right while every deck was your own.

SRS has the opposite access pattern to cards: writes are frequent and per-card,
reads need everything at once. So reads are chunked and concurrent, and writes
buffer in memory, flushing per affected chunk. A 30-card session costs one
chunk write instead of 30 record writes.

The flush cannot live in StudySessionViewModel: viewModelScope is cancelled in
onCleared(), so a flush started as the screen goes away would be killed before
finishing — losing exactly the reviews it was meant to save. The repository
owns an app-scoped CoroutineScope and exposes flushAsync() for that case, and
also flushes every 20 reviews so a crash costs a few cards, not a session.

Two things worth noting in the implementation:

- Each state's chunk is recorded when written or loaded, never recomputed. A
  flush that derived the chunk differently from the write that dirtied it would
  persist into the wrong record.
- The reader discovers chunks by listing the deck's srs/ directory rather than
  deriving the range from the card count. Deriving it silently missed any chunk
  placed by the fallback assignment used when a card's deck position isn't
  loaded, making those reviews permanently unreadable.

Refs #43, #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ow foreign refs

Three changes, all needed before media works at Anki proportions or across
accounts.

MediaRepository.get() and delete() resolved paths against the *session* author
rather than the deck's, so a blob on any deck you don't own was unreachable —
it looked for the file under your own pubky. That silently blocked following a
deck with images (#33 blocker 2). get() now takes the deck's authorPubky; call
sites thread it through from the deck being displayed.

MediaRefDto gains an absolute `uri`, so a card can reference a blob under
another author's deck. This is what makes cloning a media-heavy deck viable:
the clone points at the original's blobs instead of re-uploading hundreds of MB
up front. rehost() then copies one under the clone's own path and clears the
uri, so a clone becomes self-contained opportunistically as blobs are used.
Content addressing by sha256 makes that swap invisible — the digest is
recomputed from the bytes, so the copy lands exactly where a fresh upload
would.

Media had no cache at all, so a card's image re-downloaded on every
recomposition. Adds a small bounded LRU — deliberately small, since it exists
to survive recomposition and a back-swipe, not to hold a deck's media. An
Anki deck's audio does not fit in memory, which is exactly why the fetch stays
lazy and per-card rather than the bulk prefetch Architecture §8.0 used to
describe (and which was never implemented).

Refs #43, #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… preview

specs §13 Q4 asked whether 10k chars / 500 cards was the right cap, citing
Anki. Now that storage no longer caps deck size, the answer splits in two.

The paste box keeps a modest character cap and a raised 2,000-card cap: the
constraint there is swipe-triage and a human reading the text, not storage.
File import gets parseBulk(), with file-sized limits, and skips the paste box
entirely rather than raising its cap to fit a ~2 MB export.

parseBulk is the *same parser*, not a second one. Anki's "Notes in Plain Text"
export is tab-separated, which spec §6 rule 3 already handles, and its optional
third tags column is dropped exactly as any third column is (§8). So Anki .txt
import needs zero new dependencies — unlike .apkg, which needs a KMP zip
reader, a SQLite driver and zstd.

Truncation past the cap was a silent `.take`: an over-long import lost its tail
with nothing in the UI to say so. ImportDraft now carries the dropped count.

Live-preview parsing was O(n) over the whole input on every keystroke, which is
fine for a 40-line paste and not fine as inputs grow. Keystrokes are now
debounced; an explicit separator change still re-parses immediately, since the
user is waiting on that one.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…riage

specs §14 claimed "the triage queue is the reusable spine; every future import
source plugs into it". Bulk import breaks that claim, and this is the code that
breaks it: nobody swipes through a 20,000-card Anki export one card at a time.

File import gets a summary — N cards parsed, what was skipped, what was
deduplicated, what was truncated, a three-card sample, one confirm — and then
hands off to the same PublishDeckViewModel commit flow paste uses. The spine
every source actually shares is parse → preview → commit, not the queue.

The file is read on the Android side and the shared ViewModel takes plain text,
so the same summary works unchanged for any future source that can produce
text. Reached from a secondary "Or import a file" action under the paste CTA;
paste stays the primary flow (spec §1).

Everything the parse dropped is stated on screen rather than swallowed, which
is the whole reason ImportDraft started carrying a truncation count.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured, not anticipated. Publishing a 1,200-card deck end-to-end against a
real homeserver failed partway with `429 Too Many Requests` — issue #43 listed
FFI/server concurrency behaviour as unknown #2, and this is the answer.

Two changes:

- The session-authenticated write helpers now retry on 429 with exponential
  backoff, bounded so a persistently unavailable homeserver still fails rather
  than hanging. A 429 is transient and the request well-formed, so surfacing it
  to the user would be wrong. The three near-identical retry helpers collapse
  into one that handles both session expiry and rate limiting.
- MAX_IN_FLIGHT drops from 8 to 4, so a large publish doesn't spend most of its
  time in backoff. Belt and braces: the retry is what makes it correct, the
  lower ceiling is what makes it quick.

Also surfaces the incomplete flag on deck detail. The failed publish above left
a deck reading "900 due · 1200 cards" — the marker manifest worked exactly as
designed, keeping the deck reachable and deletable instead of orphaning nine
chunk records, but nothing told the user why it was short. The card count comes
from the manifest, so without this the deck looks complete while holding fewer
cards than it claims.

Verified: the same 1,200-card import now publishes cleanly and reads back
correctly after a cold restart.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§8.4 listed four unknowns. Two now have answers from running a 1,200-card
import against a real homeserver rather than reasoning about it: the server
rate-limits concurrent writes with 429, and an interrupted publish leaves a
recoverable deck rather than orphaned records.

The remaining unknowns are narrowed to what is genuinely still untested —
notably the per-record size ceiling, which is the one that would invalidate
CHUNK_SIZE.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#43 §7 assumed .apkg needed three new dependencies — a KMP zip reader, a SQLite
driver and zstd — and sequenced it last for that reason. On Android it needs
none of them: java.util.zip and android.database.sqlite are both in the
platform. So the expensive part of the estimate turned out to be avoidable, and
this ships now rather than waiting.

An .apkg is a zip around a SQLite collection whose `notes` table joins each
note's fields with the ASCII unit separator. Unpacking it to tab-separated text
means it feeds the *same* parser as a "Notes in Plain Text" export and the same
summary + commit flow — no second import pipeline.

Fields beyond the first two are dropped, matching the rule paste already
applies to extra columns (spec §8). Anki's inline HTML is stripped to readable
text, with whitespace collapsed so adjacent tags don't leave double spaces.
Real rich-text fidelity and the Note→Card split stay in #46.

Two things are deliberately not handled, and say so rather than failing oddly:
`collection.anki21b` (zstd, Anki 2.1.50+) and iOS, which has no platform zip or
SQLite exposed to Kotlin/Native and is not runnable end to end anyway. Both
point the user at the plain-text export, which works everywhere today.

Verified on device: an 800-note .apkg with HTML and a tags field imports,
strips to clean text, and publishes as an 800-card deck.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were open in specs §14 and Architecture §12 #6. .apkg shipped without the
three dependencies its estimate assumed, and SRS is now chunked, author-scoped
and write-behind — so both are recorded as answers rather than left as
speculation for the next reader to re-derive.

Refs #43

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42

Copy link
Copy Markdown
Owner Author

Filed #55 for the file-import UI. It's honest about what this PR left as a skeleton — in particular four things wired at one end only (publish progress, suggested title, detected separator, .apkg deck name), and two main-thread blocks in the file read and parse that only stay invisible because 1,200 rows parse fast.

@jvsena42
jvsena42 merged commit 019298f into main Aug 16, 2026
2 checks passed
@jvsena42
jvsena42 deleted the feat/43-chunked-deck-schema branch August 16, 2026 17:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant