From 842bcb296baa33d3cb9772f2ee828123c169db4d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:29:30 +0000 Subject: [PATCH] Skip permission inserts whose application no longer exists OnConflictStrategy.IGNORE does not suppress foreign-key failures, so a permission written after its parent application was deleted (e.g. an EditPermission toggle racing an app delete/reset) crashed with SQLITE_CONSTRAINT_FOREIGNKEY. insertPermissions2 now filters out rows whose pkKey has no matching application, and insertPermissions runs its delete+insert in a single transaction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014UZDNnChDiRzw5PtWc7MhD --- .../nostrsigner/database/ApplicationDao.kt | 22 +++++++++++++++++-- .../database/CachingApplicationDao.kt | 2 ++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt index da41d27a..121e61b9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt @@ -188,12 +188,30 @@ interface ApplicationDao { @Transaction suspend fun insertPermissions2Raw(permissions: List): List? - suspend fun insertPermissions2(permissions: List): List? = insertPermissions2Raw(permissions) + @Query("SELECT `key` FROM application WHERE `key` IN (:keys)") + suspend fun getExistingApplicationKeys(keys: List): List + + /** + * Inserts permissions, skipping rows whose parent application no longer + * exists. `OnConflictStrategy.IGNORE` does not cover foreign-key failures, + * so a permission written after its application was deleted (e.g. a toggle + * racing an app delete/reset) would otherwise crash with + * `SQLITE_CONSTRAINT_FOREIGNKEY`. + */ + @Transaction + suspend fun insertPermissions2(permissions: List): List? { + if (permissions.isEmpty()) return emptyList() + val existingKeys = getExistingApplicationKeys(permissions.map { it.pkKey }.distinct()).toSet() + val valid = permissions.filter { it.pkKey in existingKeys } + if (valid.isEmpty()) return emptyList() + return insertPermissions2Raw(valid) + } @Insert(onConflict = OnConflictStrategy.REPLACE) @Transaction suspend fun insertPermissionsRaw(permissions: List): List? + @Transaction suspend fun insertPermissions(permissions: List): List? { permissions.forEach { if (it.kind != null) { @@ -212,7 +230,7 @@ interface ApplicationDao { deletePermissions(it.pkKey, it.type) } } - return insertPermissions2Raw(permissions) + return insertPermissions2(permissions) } @Transaction diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt index dda2e5ea..9ac5a5d0 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt @@ -308,6 +308,8 @@ class CachingApplicationDao( override suspend fun insertPermissions2Raw(permissions: List): List? = delegate.insertPermissions2Raw(permissions) + override suspend fun getExistingApplicationKeys(keys: List): List = delegate.getExistingApplicationKeys(keys) + override suspend fun insertPermissionsRaw(permissions: List): List? = delegate.insertPermissionsRaw(permissions) override suspend fun deleteRaw(entity: ApplicationEntity) = delegate.deleteRaw(entity)