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:
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user