Skip to content

Commit 80b25fc

Browse files
authored
Merge pull request #9848 from wmontwe/feat-core-architecture-add-unified-account-id
feat(architecture): add unified account id
2 parents 4270a6a + 6a85c8a commit 80b25fc

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
lines changed

core/architecture/api/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Core Architecture
2+
3+
High‑level primitives shared across modules in this project.
4+
5+
## ID
6+
7+
Small, cross‑module primitives for strongly‑typed identifiers.
8+
9+
### Core concepts
10+
11+
- Principles:
12+
- Keep IDs opaque and strongly typed (no raw strings at call sites).
13+
- Centralize generation/parsing via factories to ensure consistency.
14+
- Keep the core generic; domain modules extend via typealiases and small factories.
15+
- Building blocks:
16+
- `Id<T>`: a tiny value type wrapping a UUID (kotlin.uuid.Uuid).
17+
- `IdFactory<T>`: contract for creating/parsing typed IDs.
18+
- `BaseIdFactory<T>`: abstract UUID‑based implementation of `IdFactory<T>` for standardized creation and parsing.
19+
20+
Implement custom factories if you need non‑UUID schemes; otherwise prefer BaseIdFactory.
21+
22+
### Usage
23+
24+
Create a typed ID and parse from storage:
25+
26+
```kotlin
27+
// Domain type
28+
data class Project(val id: ProjectId)
29+
30+
// Typed alias
31+
typealias ProjectId = Id<Project>
32+
33+
// Factory
34+
object ProjectIdFactory : BaseIdFactory<Project>()
35+
36+
// Create new ID
37+
val id: ProjectId = ProjectIdFactory.create()
38+
39+
// Persist/restore
40+
val raw: String = id.asRaw()
41+
val parsed: ProjectId = ProjectIdFactory.of(raw)
42+
```
43+

feature/account/api/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Account API (feature/account/api)
2+
3+
A small, feature‑agnostic API for representing accounts by identity only. It provides:
4+
5+
- Strongly‑typed account identifiers (`AccountId`)
6+
- A minimal Account interface (identity only)
7+
- A unified account sentinel for aggregated views
8+
- Profile types for UI (`AccountProfile`) and an abstraction to fetch/update them (AccountProfileRepository)
9+
10+
This module intentionally avoids feature‑specific fields (like email addresses). Mail, calendar, sync, etc. should
11+
attach their own capability models keyed by `AccountId`.
12+
13+
## Core concepts
14+
15+
- `Account`: Marker interface for an account, identified solely by `AccountId`.
16+
- `AccountId`: Typealias of `Id<Account>` (UUID‑backed inline value class). Prefer this over raw strings.
17+
- `AccountIdFactory`: Utility for creating/parsing `AccountId` values.
18+
- `UnifiedAccountId`: Reserved `AccountId` (UUID nil) used to represent the virtual “Unified” scope across accounts.
19+
- `AccountId.isUnified`: Shorthand check for unified account id.
20+
- `AccountId.requireReal()`: Throws if called with the unified ID. Use in repositories/mutation paths.
21+
- `AccountProfile`: Display/profile data for UI (name, color, avatar) keyed by AccountId.
22+
- `AccountProfileRepository`: Abstraction to read/update profiles.
23+
24+
## Usage
25+
26+
Create a new `AccountId` or parse an existing one:
27+
28+
```kotlin
29+
val id = AccountIdFactory.create() // new random AccountId
30+
val parsed = AccountIdFactory.of(rawString) // parse from persisted value
31+
```
32+
33+
Detect and guard against the unified account in write paths:
34+
35+
```kotlin
36+
fun AccountId.requireReal(): AccountId // throws IllegalStateException for unified
37+
38+
if (id.isUnified) {
39+
// route to unified UI/aggregation services instead of repositories
40+
}
41+
```
42+
43+
Fetch/update an account profile:
44+
45+
```kotlin
46+
val profiles: Flow<AccountProfile?> = repo.getById(id)
47+
repo.update(AccountProfile(id, name = "Alice", color = 0xFFAA66, avatar = AccountAvatar.Monogram(value = "A")))
48+
```
49+
50+
## Design guidelines
51+
52+
- Keep Account minimal (identity only). Do not add mail/calendar/sync fields here.
53+
- Feature modules should define their own models keyed by `AccountId`.
54+
- Do not persist data for `UnifiedAccountId`. Compute unified profiles/labels in UI where needed.
55+
- Prefer strong types (`AccountId`) over raw strings for safety and consistency.
56+
57+
## Related types
58+
59+
- `Id<T>`: Generic UUID‑backed identifier (core/architecture/api)
60+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package net.thunderbird.feature.account
2+
3+
import kotlin.uuid.ExperimentalUuidApi
4+
import kotlin.uuid.Uuid
5+
import net.thunderbird.core.architecture.model.Id
6+
7+
/**
8+
* Constant for the unified account ID.
9+
*
10+
* This ID is used to identify the unified account, which is a special account for aggregation purposes.
11+
*
12+
* The unified account ID is represented by a nil UUID (all zeros).
13+
*
14+
* See [RFC 4122 Section 4.1.7](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.7) for more details on nil UUIDs.
15+
*/
16+
@OptIn(ExperimentalUuidApi::class)
17+
val UnifiedAccountId: AccountId = Id(Uuid.NIL)
18+
19+
/**
20+
* Extension property to check if an [AccountId] is the unified account ID.
21+
*/
22+
val AccountId.isUnified: Boolean
23+
get() = this == UnifiedAccountId
24+
25+
/**
26+
* Ensures that the [AccountId] is not the unified account ID.
27+
*/
28+
fun AccountId.requireReal(): AccountId {
29+
check(!isUnified) { "Operation not allowed on unified account" }
30+
return this
31+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package net.thunderbird.feature.account
2+
3+
import assertk.assertFailure
4+
import assertk.assertThat
5+
import assertk.assertions.hasMessage
6+
import assertk.assertions.isEqualTo
7+
import assertk.assertions.isFalse
8+
import assertk.assertions.isTrue
9+
import kotlin.test.Test
10+
11+
class UnifiedAccountIdTest {
12+
13+
@Test
14+
fun `unified account id is nil uuid`() {
15+
assertThat(UnifiedAccountId.asRaw()).isEqualTo("00000000-0000-0000-0000-000000000000")
16+
}
17+
18+
@Test
19+
fun `isUnified returns true for unified account id`() {
20+
assertThat(UnifiedAccountId.isUnified).isTrue()
21+
}
22+
23+
@Test
24+
fun `isUnified returns false for non-unified account id`() {
25+
val nonUnifiedAccountId = AccountIdFactory.of("123e4567-e89b-12d3-a456-426614174000")
26+
assertThat(nonUnifiedAccountId.isUnified).isFalse()
27+
}
28+
29+
@Test
30+
fun `requireReal returns the same id if not unified`() {
31+
val nonUnifiedAccountId = AccountIdFactory.of("123e4567-e89b-12d3-a456-426614174000")
32+
assertThat(nonUnifiedAccountId.requireReal()).isEqualTo(nonUnifiedAccountId)
33+
}
34+
35+
@Test
36+
fun `requireReal throws exception if unified`() {
37+
assertFailure {
38+
UnifiedAccountId.requireReal()
39+
}.hasMessage("Operation not allowed on unified account")
40+
}
41+
}

0 commit comments

Comments
 (0)