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.
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).
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #66.
Problem
parentOf()re-inferred the IMAP hierarchy separator from each folder's name — taking the character just before thedisplayNamesuffix offullName. That relies on an unenforced cross-layer invariant (displayName == fullName.substringAfterLast(separator)) established three layers away inImapClient.listFolders, which reads the authoritative JavaMailfolder.separator, uses it once, and discards it. Any future change to howdisplayNameis derived (trimming, decoding at a different layer) silently breaks parenting:parentOfreturns null and colliding user folders degrade to the[fullPath]safety-net labels, with no test failing.Change
FetchedFolder.hierarchyDelimiter: Char?→FolderEntity.hierarchyDelimiter: String?(one-char string) →Folder.hierarchyDelimiter: Char?, populated during folder sync from the actualfolder.separator.parentOf()splits on the persisted delimiter instead of re-inferring it. Legacy rows (delimiternull) fall back to the original name-inference path, so existing installs keep working until the next folder refresh (FolderDao.replaceForAccountdelete-then-insert) backfills the real value. A server-reported "no hierarchy" (NUL) and an unreadable separator are stored asnulltoo — inference is correct (or trivially top-level) for both.folders.hierarchyDelimiter TEXTcolumn (thebodyHtmlnullable-no-default pattern), with the exported15.jsonschema 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-replayMigrationTestauto-discovers migrations and the target version from the newest exported schema, so the renumber is mechanical (rename theMigration, bump@Databaseversion, re-export the schema JSON).Note on
DatabaseModule.ktThe 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:provideDatabaseconfigures no destructive fallback, so without it Room throwsIllegalStateExceptionand 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
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 anulldelimiter fall back to the safety net; anulldelimiter still infers the separator for legacy rows.FolderMapperTest): the delimiter round-tripsFetchedFolder → entity → domain; an unknown delimiter persists/reads back asnull.MigrationTest): a dedicated v14 → v15 test (existing folder rows survive and read anulldelimiter) plus a chain-replay assertion. Run by CI's E2E matrix (no local emulator).🤖 Generated with Claude Code