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

parentOf() re-inferred the IMAP hierarchy separator from each folder's name,
relying on an unenforced invariant (displayName == fullName.substringAfterLast(
separator)) established three layers from where ImapClient reads the
authoritative JavaMail folder.separator and then discards it.

Carry that separator through FetchedFolder -> FolderEntity -> Folder and split a
folder's parent on it. Fall back to the old name inference only for legacy rows
whose delimiter is null, until the next folder refresh (delete-then-insert)
backfills the real value.

Adds a nullable folders.hierarchyDelimiter column via a Room v14 -> v15 migration
with the exported v15 schema, and registers MIGRATION_14_15 in DatabaseModule so
existing v14 installs actually upgrade (provideDatabase configures no destructive
fallback, so an unregistered migration would crash every upgrading user).

Tests: JVM unit tests for parentOf() (persisted vs. null delimiter, incl. the
case where inference cannot locate the parent) and the FetchedFolder->entity
round-trip; an instrumented v14->v15 migration test plus the chain-replay
assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 07:02:09 -05:00
co-authored by Claude Fable 5
parent 1d6ebe8249
commit e28c52b6bf
12 changed files with 859 additions and 11 deletions
@@ -73,6 +73,35 @@ class MigrationTest {
db.close()
}
/** v14 -> v15 (issue #66): `folders.hierarchyDelimiter` appears defaulting to NULL; data survives. */
@Test
fun migrate14To15_addsNullHierarchyDelimiterToFolders() {
helper.createDatabase(TEST_DB, 14).apply {
insertAccount()
execSQL(
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder, specialUse) " +
"VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0, 0), " +
"('acct', 'Work.Reports', 'Reports', 'NORMAL', 1, 1, 0)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 15, true, MIGRATION_14_15)
db.query("SELECT fullName, hierarchyDelimiter FROM folders ORDER BY sortOrder").use { c ->
assertTrue(c.moveToFirst())
assertEquals("INBOX", c.getString(0))
assertTrue("existing folders read a null delimiter until the next refresh", c.isNull(1))
assertTrue(c.moveToNext())
assertEquals("Work.Reports", c.getString(0))
assertTrue(c.isNull(1))
assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext())
}
// The folders' account is untouched.
assertEquals(1, db.count("accounts"))
db.close()
}
/**
* A gap in the chain (or a migration whose target schema was never committed) crashes upgrading
* users, so fail fast with a readable message before replaying anything.
@@ -228,6 +257,12 @@ class MigrationTest {
assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst())
assertEquals(0, c.getInt(0))
}
// 14->15 adds a nullable hierarchyDelimiter; the folder cached at v8 predates it, so it reads
// null and the drawer infers the separator from the name until the next folder refresh.
query("SELECT hierarchyDelimiter FROM folders WHERE fullName = 'INBOX'").use { c ->
assertTrue(c.moveToFirst())
assertTrue("a folder cached before v15 must read a null delimiter", c.isNull(0))
}
}
private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c ->