Skip to content

Commit 62aa2d8

Browse files
authored
feat: uuidv7 identifier generation (#11513)
2 parents ca66ee5 + 423a918 commit 62aa2d8

23 files changed

Lines changed: 331 additions & 33 deletions

File tree

‎core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/BaseUuidIdentifierFactory.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ abstract class BaseUuidIdentifierFactory<T : BaseUuidIdentifier>(
2525

2626
override fun of(raw: String): T = fromUuid(Uuid.parse(raw))
2727

28-
override fun create(): T = fromUuid(Uuid.random())
28+
override fun create(): T = fromUuid(Uuid.generateV7())
2929
}

‎core/architecture/api/src/commonTest/kotlin/net/thunderbird/core/architecture/model/BaseUuidIdentifierFactoryTest.kt‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,40 @@ class BaseUuidIdentifierFactoryTest {
3030
}
3131

3232
@Test
33-
fun `given create is called twice then returns different Ids`() {
33+
fun `given create is called then returns UUIDv7 Id`() {
3434
// Arrange + Act
35-
val id1 = TestIdFactory.create()
36-
val id2 = TestIdFactory.create()
35+
val id = TestIdFactory.create()
3736

3837
// Assert
39-
assertThat(id1).isNotEqualTo(id2)
40-
assertThat(id1.toString()).isNotEqualTo(id2.toString())
38+
id.value.toLongs { mostSignificantBits, leastSignificantBits ->
39+
val version = (mostSignificantBits ushr 12) and 0x0F
40+
val variant = leastSignificantBits ushr 62
41+
42+
assertThat(version).isEqualTo(7L)
43+
assertThat(variant).isEqualTo(2L)
44+
}
45+
}
46+
47+
@Test
48+
fun `given create is called repeatedly then returns ordered unique Ids`() {
49+
// Arrange + Act
50+
val ids = List(100) { TestIdFactory.create() }
51+
52+
// Assert
53+
assertThat(ids).isEqualTo(ids.sorted())
54+
assertThat(ids.toSet().size).isEqualTo(ids.size)
55+
}
56+
57+
@Test
58+
fun `given existing UUID when of is called then returns compatible Id`() {
59+
// Arrange
60+
val raw = "123e4567-e89b-42d3-a456-426655440000"
61+
62+
// Act
63+
val id = TestIdFactory.of(raw)
64+
65+
// Assert
66+
assertThat(id.value).isEqualTo(Uuid.parse(raw))
4167
}
4268

4369
@Test

‎docs/engineering/rfcs/0009-uuidv7-identifier-migration.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ is stable for its lifetime. It needs its own decision, inventory, failure handli
2828
### Ownership and module placement
2929

3030
- `AccountId` remains in `feature:account:api`.
31-
- `FolderId`, `MessageId`, and `ThreadId` remain in the mail domain.
31+
- `FolderId` belongs to `feature:mail:folder:api`.
32+
- `MessageId`, `ThreadId`, and `AttachmentId` belong to `feature:mail:message:api`.
3233
- `BaseIdentifier`, `BaseUuidIdentifier`, `IdentifierFactory`, and `BaseUuidIdentifierFactory` remain in
3334
`core:architecture:api`.
3435
- No `core:types:api` module is introduced by this RFC.

‎docs/engineering/technical-designs/0003-global-database.md‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,16 @@ database and are stable while that record exists, including across application r
4040
They are not protocol identifiers, portable-profile identifiers, or synchronization identifiers. In particular, an
4141
IMAP UID, a folder server ID, and an RFC 5322 `Message-ID` header must not be used as a global database identifier.
4242

43-
| Identifier | Owner | Meaning and boundary |
44-
|----------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
45-
| `AccountId` | `feature:account:api` | The existing UUID-backed account identifier. Global-mail records retain its existing persisted value to establish account scope. Cutover neither replaces nor regenerates it. Account settings and profile runtime state remain outside the global mail database. |
46-
| `FolderId` | Mail domain | Identifies one local folder record across all accounts. It replaces the legacy account-local folder number at repository boundaries. |
47-
| `MessageId` | Mail domain | Identifies one local message record across all accounts. A message copied to another folder is a separate local record and therefore has a separate `MessageId`. |
48-
| `ThreadId` | Mail domain | Identifies one account-scoped conversation, which can contain local message records from Inbox, Sent, Archive, and other folders. It is a durable conversation aggregate, not a legacy numeric thread-root key. Thread operations use `ThreadId`. Operations on an individual local record use `MessageId`. |
49-
| `AttachmentId` | Mail domain | An opaque attachment-access URI or equivalent reference. It resolves unambiguously after cutover and is not a raw message-part primary key. Message-part keys stay internal unless a future focused attachment contract requires one. |
50-
51-
RFC 0009 owns the UUID representation and generation policy for `FolderId`, `MessageId`, and `ThreadId`. These types,
52-
along with `AccountId` and `AttachmentId`, are the only identifiers that cross the mail repository boundary for this
43+
| Identifier | Owner | Meaning and boundary |
44+
|----------------|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
45+
| `AccountId` | `feature:account:api` | The existing UUID-backed account identifier. Global-mail records retain its existing persisted value to establish account scope. Cutover neither replaces nor regenerates it. Account settings and profile runtime state remain outside the global mail database. |
46+
| `FolderId` | `feature:mail:folder:api` | Identifies one local folder record across all accounts. It replaces the legacy account-local folder number at repository boundaries. |
47+
| `MessageId` | `feature:mail:message:api` | Identifies one local message record across all accounts. A message copied to another folder is a separate local record and therefore has a separate `MessageId`. |
48+
| `ThreadId` | `feature:mail:message:api` | Identifies one account-scoped conversation, which can contain local message records from Inbox, Sent, Archive, and other folders. It is a durable conversation aggregate, not a legacy numeric thread-root key. Thread operations use `ThreadId`. Operations on an individual local record use `MessageId`. |
49+
| `AttachmentId` | `feature:mail:message:api` | Identifies one persisted attachment record across all accounts. It is distinct from attachment access URIs and internal message-part primary keys. |
50+
51+
RFC 0009 owns the UUID representation and generation policy for `FolderId`, `MessageId`, `ThreadId`, and `AttachmentId`.
52+
These types, along with `AccountId`, are the only identifiers that cross the mail repository boundary for this
5353
design. Repository contracts never expose legacy numeric IDs or persistence keys.
5454

5555
The thread builder uses the imported messages' threading headers across all folders of the same account. When a newly
@@ -71,7 +71,8 @@ For imported data, the migrator records an account-qualified source key before l
7171
| Folder | `(AccountId, legacy folder id)` | `FolderId` | folder extra values, message folder references, queued-command folder references |
7272
| Message | `(AccountId, legacy message id)` | `MessageId` | outbox state, notifications, full-text mapping, message part root, thread message reference |
7373
| Thread | `(AccountId, legacy folder id, legacy thread root)` | `ThreadId` | rebuild cross-folder memberships from message threading headers and rewrite threaded-list and thread-cache values |
74-
| Message part | `(AccountId, legacy message-part id)` | internal only | part root and parent references, message root-part reference, attachment-file lookup and URI resolution |
74+
| Message part | `(AccountId, legacy message-part id)` | internal only | part root and parent references, message root-part reference |
75+
| Attachment | `(AccountId, legacy message-part id)` | `AttachmentId` | attachment-file lookup and access URI resolution |
7576
| Pending command | `(AccountId, legacy command id)` | internal only | command row identity and serialized folder references |
7677

7778
The exact physical representation is internal, but it must enforce uniqueness of every source key and reject an import
@@ -103,7 +104,7 @@ failure state. A migration gate holds startup, sync, and other background mail w
103104
6. Establish the durable cutover state and switch repository bindings to the global implementation.
104105
7. Start post-cutover cleanup of all legacy database and attachment artifacts.
105106

106-
The import writes folder and message identifier mappings before importing dependents. After all message records for an
107+
The import writes folder, message, and attachment identifier mappings before importing dependents. After all message records for an
107108
account are available, it builds cross-folder `ThreadId` memberships from their threading headers and records the
108109
legacy folder-local thread-root mappings before rewriting threaded-list and cache values. It then validates all
109110
rewritten relationships before the global database is published. It translates serialized pending-command folder

‎feature/account/api/src/commonTest/kotlin/net/thunderbird/feature/account/UnifiedAccountIdTest.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ class UnifiedAccountIdTest {
2020
assertThat(UnifiedAccountId.isUnified).isTrue()
2121
}
2222

23+
@Test
24+
fun `factory parses nil UUID as unified account id`() {
25+
val accountId = AccountIdFactory.of("00000000-0000-0000-0000-000000000000")
26+
27+
assertThat(accountId).isEqualTo(UnifiedAccountId)
28+
}
29+
2330
@Test
2431
fun `isUnified returns false for non-unified account id`() {
2532
val nonUnifiedAccountId = AccountIdFactory.of("123e4567-e89b-12d3-a456-426614174000")

‎feature/mail/folder/api/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ kotlin {
88
}
99
sourceSets {
1010
commonMain.dependencies {
11+
api(projects.core.architecture.api)
1112
implementation(projects.core.common)
1213
implementation(projects.core.logging.api)
1314
implementation(projects.feature.account.api)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package net.thunderbird.feature.mail.folder
2+
3+
import kotlin.uuid.ExperimentalUuidApi
4+
import kotlin.uuid.Uuid
5+
import net.thunderbird.core.architecture.model.BaseUuidIdentifier
6+
7+
/**
8+
* Identifies a local folder record across all accounts.
9+
*/
10+
@OptIn(ExperimentalUuidApi::class)
11+
class FolderId(
12+
value: Uuid,
13+
) : BaseUuidIdentifier(value)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package net.thunderbird.feature.mail.folder
2+
3+
import kotlin.uuid.ExperimentalUuidApi
4+
import net.thunderbird.core.architecture.model.BaseUuidIdentifierFactory
5+
6+
/**
7+
* Factory for creating [FolderId] values.
8+
*/
9+
@OptIn(ExperimentalUuidApi::class)
10+
object FolderIdFactory : BaseUuidIdentifierFactory<FolderId>(::FolderId)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package net.thunderbird.feature.mail.folder
2+
3+
import assertk.assertThat
4+
import assertk.assertions.isEqualTo
5+
import kotlin.test.Test
6+
import kotlin.uuid.ExperimentalUuidApi
7+
import kotlin.uuid.Uuid
8+
9+
@OptIn(ExperimentalUuidApi::class)
10+
class FolderIdFactoryTest {
11+
12+
@Test
13+
fun `given existing UUID when of is called then returns compatible FolderId`() {
14+
// Arrange
15+
val raw = "123e4567-e89b-42d3-a456-426655440000"
16+
17+
// Act
18+
val folderId = FolderIdFactory.of(raw)
19+
20+
// Assert
21+
assertThat(folderId.value).isEqualTo(Uuid.parse(raw))
22+
}
23+
24+
@Test
25+
fun `given create is called then returns UUIDv7 FolderId`() {
26+
// Arrange + Act
27+
val folderId = FolderIdFactory.create()
28+
29+
// Assert
30+
folderId.value.toLongs { mostSignificantBits, leastSignificantBits ->
31+
assertThat((mostSignificantBits ushr 12) and 0x0F).isEqualTo(7L)
32+
assertThat(leastSignificantBits ushr 62).isEqualTo(2L)
33+
}
34+
}
35+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
plugins {
2+
id(ThunderbirdPlugins.Library.kmp)
3+
}
4+
5+
kotlin {
6+
android {
7+
namespace = "net.thunderbird.feature.mail.message"
8+
}
9+
sourceSets {
10+
commonMain.dependencies {
11+
api(projects.core.architecture.api)
12+
}
13+
}
14+
}

0 commit comments

Comments
 (0)