Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 80b25fc0 authored by Wolf-Martell Montwé's avatar Wolf-Martell Montwé Committed by GitHub
Browse files

Merge pull request #9848 from wmontwe/feat-core-architecture-add-unified-account-id

feat(architecture): add unified account id
parents 4270a6a4 6a85c8a0
Loading
Loading
Loading
Loading
+43 −0
Original line number Diff line number Diff line
# Core Architecture

High‑level primitives shared across modules in this project.

## ID

Small, cross‑module primitives for strongly‑typed identifiers.

### Core concepts

- Principles:
  - Keep IDs opaque and strongly typed (no raw strings at call sites).
  - Centralize generation/parsing via factories to ensure consistency.
  - Keep the core generic; domain modules extend via typealiases and small factories.
- Building blocks:
  - `Id<T>`: a tiny value type wrapping a UUID (kotlin.uuid.Uuid).
  - `IdFactory<T>`: contract for creating/parsing typed IDs.
  - `BaseIdFactory<T>`: abstract UUID‑based implementation of `IdFactory<T>` for standardized creation and parsing.

Implement custom factories if you need non‑UUID schemes; otherwise prefer BaseIdFactory.

### Usage

Create a typed ID and parse from storage:

```kotlin
// Domain type
data class Project(val id: ProjectId)

// Typed alias
typealias ProjectId = Id<Project>

// Factory
object ProjectIdFactory : BaseIdFactory<Project>()

// Create new ID
val id: ProjectId = ProjectIdFactory.create()

// Persist/restore
val raw: String = id.asRaw()
val parsed: ProjectId = ProjectIdFactory.of(raw)
```
+60 −0
Original line number Diff line number Diff line
# Account API (feature/account/api)

A small, feature‑agnostic API for representing accounts by identity only. It provides:

- Strongly‑typed account identifiers (`AccountId`)
- A minimal Account interface (identity only)
- A unified account sentinel for aggregated views
- Profile types for UI (`AccountProfile`) and an abstraction to fetch/update them (AccountProfileRepository)

This module intentionally avoids feature‑specific fields (like email addresses). Mail, calendar, sync, etc. should
attach their own capability models keyed by `AccountId`.

## Core concepts

- `Account`: Marker interface for an account, identified solely by `AccountId`.
- `AccountId`: Typealias of `Id<Account>` (UUID‑backed inline value class). Prefer this over raw strings.
- `AccountIdFactory`: Utility for creating/parsing `AccountId` values.
- `UnifiedAccountId`: Reserved `AccountId` (UUID nil) used to represent the virtual “Unified” scope across accounts.
  - `AccountId.isUnified`: Shorthand check for unified account id.
  - `AccountId.requireReal()`: Throws if called with the unified ID. Use in repositories/mutation paths.
- `AccountProfile`: Display/profile data for UI (name, color, avatar) keyed by AccountId.
- `AccountProfileRepository`: Abstraction to read/update profiles.

## Usage

Create a new `AccountId` or parse an existing one:

```kotlin
val id = AccountIdFactory.create()            // new random AccountId
val parsed = AccountIdFactory.of(rawString)   // parse from persisted value
```

Detect and guard against the unified account in write paths:

```kotlin
fun AccountId.requireReal(): AccountId // throws IllegalStateException for unified

if (id.isUnified) {
    // route to unified UI/aggregation services instead of repositories
}
```

Fetch/update an account profile:

```kotlin
val profiles: Flow<AccountProfile?> = repo.getById(id)
repo.update(AccountProfile(id, name = "Alice", color = 0xFFAA66, avatar = AccountAvatar.Monogram(value = "A")))
```

## Design guidelines

- Keep Account minimal (identity only). Do not add mail/calendar/sync fields here.
- Feature modules should define their own models keyed by `AccountId`.
- Do not persist data for `UnifiedAccountId`. Compute unified profiles/labels in UI where needed.
- Prefer strong types (`AccountId`) over raw strings for safety and consistency.

## Related types

- `Id<T>`: Generic UUID‑backed identifier (core/architecture/api)
+31 −0
Original line number Diff line number Diff line
package net.thunderbird.feature.account

import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import net.thunderbird.core.architecture.model.Id

/**
 * Constant for the unified account ID.
 *
 * This ID is used to identify the unified account, which is a special account for aggregation purposes.
 *
 * The unified account ID is represented by a nil UUID (all zeros).
 *
 * See [RFC 4122 Section 4.1.7](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.7) for more details on nil UUIDs.
 */
@OptIn(ExperimentalUuidApi::class)
val UnifiedAccountId: AccountId = Id(Uuid.NIL)

/**
 * Extension property to check if an [AccountId] is the unified account ID.
 */
val AccountId.isUnified: Boolean
    get() = this == UnifiedAccountId

/**
 * Ensures that the [AccountId] is not the unified account ID.
 */
fun AccountId.requireReal(): AccountId {
    check(!isUnified) { "Operation not allowed on unified account" }
    return this
}
+41 −0
Original line number Diff line number Diff line
package net.thunderbird.feature.account

import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import kotlin.test.Test

class UnifiedAccountIdTest {

    @Test
    fun `unified account id is nil uuid`() {
        assertThat(UnifiedAccountId.asRaw()).isEqualTo("00000000-0000-0000-0000-000000000000")
    }

    @Test
    fun `isUnified returns true for unified account id`() {
        assertThat(UnifiedAccountId.isUnified).isTrue()
    }

    @Test
    fun `isUnified returns false for non-unified account id`() {
        val nonUnifiedAccountId = AccountIdFactory.of("123e4567-e89b-12d3-a456-426614174000")
        assertThat(nonUnifiedAccountId.isUnified).isFalse()
    }

    @Test
    fun `requireReal returns the same id if not unified`() {
        val nonUnifiedAccountId = AccountIdFactory.of("123e4567-e89b-12d3-a456-426614174000")
        assertThat(nonUnifiedAccountId.requireReal()).isEqualTo(nonUnifiedAccountId)
    }

    @Test
    fun `requireReal throws exception if unified`() {
        assertFailure {
            UnifiedAccountId.requireReal()
        }.hasMessage("Operation not allowed on unified account")
    }
}