feat(folders): persist the server-reported IMAP hierarchy delimiter #122

Merged
JMR-dev merged 2 commits from feat-persist-imap-delimiter into main 2026-07-02 13:01:48 +00:00
JMR-dev commented 2026-07-02 12:02:39 +00:00 (Migrated from github.com)

Closes #66.

Problem

parentOf() re-inferred the IMAP hierarchy separator from each folder's name — taking the character just before the displayName suffix of fullName. That relies on an unenforced cross-layer invariant (displayName == fullName.substringAfterLast(separator)) established three layers away in ImapClient.listFolders, which reads the authoritative JavaMail folder.separator, uses it once, and discards it. Any future change to how displayName is derived (trimming, decoding at a different layer) silently breaks parenting: parentOf returns null and colliding user folders degrade to the [fullPath] safety-net labels, with no test failing.

Change

  • Persist the delimiter, per folder. Each LIST response line carries its own separator, so the delimiter is stored per-folder (the granularity the server reports it at), not per-account. It flows FetchedFolder.hierarchyDelimiter: Char? → FolderEntity.hierarchyDelimiter: String? (one-char string) → Folder.hierarchyDelimiter: Char?, populated during folder sync from the actual folder.separator.
  • parentOf() splits on the persisted delimiter instead of re-inferring it. Legacy rows (delimiter null) fall back to the original name-inference path, so existing installs keep working until the next folder refresh (FolderDao.replaceForAccount delete-then-insert) backfills the real value. A server-reported "no hierarchy" (NUL) and an unreadable separator are stored as null too — inference is correct (or trivially top-level) for both.
  • Room v14 → v15 migration adds a nullable folders.hierarchyDelimiter TEXT column (the bodyHtml nullable-no-default pattern), with the exported 15.json schema committed.

⚠️ Schema-version coordination with #118

This takes the cache DB to v15. PR #118 (fix-accounts-out-of-cache-db, open/in-review) also currently targets v15. Per the maintainer's decision, #66 takes v15 now. Whichever of #66/#118 merges second must renumber its migration to v15 → v16 — the chain-replay MigrationTest auto-discovers migrations and the target version from the newest exported schema, so the renumber is mechanical (rename the Migration, bump @Database version, re-export the schema JSON).

Note on DatabaseModule.kt

The task fenced off di/DatabaseModule.kt (it's being refactored by #118). I made the single mandatory one-line registration (.addMigrations(… , MIGRATION_14_15) + its import) that every migration in this repo requires: provideDatabase configures no destructive fallback, so without it Room throws IllegalStateException and every existing v14 user crashes on upgrade. This is the minimal mechanical change, not the #118 refactor; it is a trivial conflict for #118 to resolve when it rebases and renumbers.

Tests

  • JVM unit (FolderLabelsTest): a persisted delimiter parents folders whose leaf is not a raw suffix of the path (a case inference cannot handle); the same folders with a null delimiter fall back to the safety net; a null delimiter still infers the separator for legacy rows.
  • JVM unit (FolderMapperTest): the delimiter round-trips FetchedFolder → entity → domain; an unknown delimiter persists/reads back as null.
  • Instrumented (MigrationTest): a dedicated v14 → v15 test (existing folder rows survive and read a null delimiter) plus a chain-replay assertion. Run by CI's E2E matrix (no local emulator).

🤖 Generated with Claude Code

Closes #66. ## Problem `parentOf()` re-inferred the IMAP hierarchy separator from each folder's name — taking the character just before the `displayName` suffix of `fullName`. That relies on an unenforced cross-layer invariant (`displayName == fullName.substringAfterLast(separator)`) established three layers away in `ImapClient.listFolders`, which reads the authoritative JavaMail `folder.separator`, uses it once, and discards it. Any future change to how `displayName` is derived (trimming, decoding at a different layer) silently breaks parenting: `parentOf` returns null and colliding user folders degrade to the `[fullPath]` safety-net labels, with no test failing. ## Change - **Persist the delimiter, per folder.** Each LIST response line carries its own separator, so the delimiter is stored per-folder (the granularity the server reports it at), not per-account. It flows `FetchedFolder.hierarchyDelimiter: Char?` → `FolderEntity.hierarchyDelimiter: String?` (one-char string) → `Folder.hierarchyDelimiter: Char?`, populated during folder sync from the actual `folder.separator`. - **`parentOf()` splits on the persisted delimiter** instead of re-inferring it. Legacy rows (delimiter `null`) fall back to the original name-inference path, so existing installs keep working until the next folder refresh (`FolderDao.replaceForAccount` delete-then-insert) backfills the real value. A server-reported "no hierarchy" (NUL) and an unreadable separator are stored as `null` too — inference is correct (or trivially top-level) for both. - **Room v14 → v15 migration** adds a nullable `folders.hierarchyDelimiter TEXT` column (the `bodyHtml` nullable-no-default pattern), with the exported `15.json` schema committed. ## ⚠️ Schema-version coordination with #118 **This takes the cache DB to v15.** PR #118 (`fix-accounts-out-of-cache-db`, open/in-review) also currently targets v15. Per the maintainer's decision, #66 takes v15 now. **Whichever of #66/#118 merges second must renumber its migration to v15 → v16** — the chain-replay `MigrationTest` auto-discovers migrations and the target version from the newest exported schema, so the renumber is mechanical (rename the `Migration`, bump `@Database` version, re-export the schema JSON). ## Note on `DatabaseModule.kt` The task fenced off `di/DatabaseModule.kt` (it's being refactored by #118). I made the **single mandatory one-line registration** (`.addMigrations(… , MIGRATION_14_15)` + its import) that every migration in this repo requires: `provideDatabase` configures **no** destructive fallback, so without it Room throws `IllegalStateException` and every existing v14 user crashes on upgrade. This is the minimal mechanical change, not the #118 refactor; it is a trivial conflict for #118 to resolve when it rebases and renumbers. ## Tests - **JVM unit** (`FolderLabelsTest`): a persisted delimiter parents folders whose leaf is *not* a raw suffix of the path (a case inference cannot handle); the same folders with a `null` delimiter fall back to the safety net; a `null` delimiter still infers the separator for legacy rows. - **JVM unit** (`FolderMapperTest`): the delimiter round-trips `FetchedFolder → entity → domain`; an unknown delimiter persists/reads back as `null`. - **Instrumented** (`MigrationTest`): a dedicated v14 → v15 test (existing folder rows survive and read a `null` delimiter) plus a chain-replay assertion. Run by CI's E2E matrix (no local emulator). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.