Skip to content

Commit b33d66c

Browse files
Adronclaude
andcommitted
Wave 2 (M1): InterlinedDomain slice — models, MessagesService, SessionService, EntitlementsService
- Domain models (Message/UserSummary/CurrentUser/Visibility/TimelineScope) + DTO mappers; no DTO leaks, no SwiftUI. - MessagesService: timeline(scope/tag/paging) + stream (stale-while-revalidate), message(id), replies; consumes Paginated via PaginatedDecoder. - SessionService: restore/signIn/register/reset/signOut over AuthService + GET /api/user; SessionState stream. - EntitlementsService: customerStatus -> Feature gating switch. - MessageStore cache port + InMemoryMessageStore default. 50 BDD tests passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 688ab39 commit b33d66c

22 files changed

Lines changed: 1959 additions & 56 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
3+
/// An in-memory `MessageStore`, used as the default cache in tests and in any
4+
/// context that has no persistence layer wired up yet. The real, durable
5+
/// implementation is the SwiftData store in `InterlinedPersistence`.
6+
///
7+
/// Implemented as an `actor` so its mutable state is safe under Swift 6 strict
8+
/// concurrency without manual locking.
9+
public actor InMemoryMessageStore: MessageStore {
10+
11+
/// Cache key for a timeline slice: a scope combined with an optional tag.
12+
/// The two together identify a distinct cached feed.
13+
private struct TimelineKey: Hashable {
14+
let scope: TimelineScope
15+
let tag: String?
16+
}
17+
18+
private var timelines: [TimelineKey: [Message]] = [:]
19+
private var messagesByID: [String: Message] = [:]
20+
21+
public init() {}
22+
23+
public func cachedTimeline(scope: TimelineScope, tag: String?) async -> [Message] {
24+
timelines[TimelineKey(scope: scope, tag: tag)] ?? []
25+
}
26+
27+
public func replaceTimeline(_ messages: [Message], scope: TimelineScope, tag: String?) async {
28+
timelines[TimelineKey(scope: scope, tag: tag)] = messages
29+
for message in messages {
30+
messagesByID[message.id] = message
31+
}
32+
}
33+
34+
public func cachedMessage(id: String) async -> Message? {
35+
messagesByID[id]
36+
}
37+
38+
public func upsert(_ messages: [Message]) async {
39+
for message in messages {
40+
messagesByID[message.id] = message
41+
}
42+
}
43+
44+
public func clear() async {
45+
timelines.removeAll()
46+
messagesByID.removeAll()
47+
}
48+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
/// The cache port the persistence layer implements (PLAN.md §5 — timeline and
4+
/// lists read from a SwiftData cache with stale-while-revalidate). The
5+
/// SwiftData-backed conformance lives in `InterlinedPersistence`; the domain
6+
/// layer depends only on this protocol so its services can be tested without a
7+
/// database and run cache-less when no store is injected.
8+
///
9+
/// All methods are `async` so a real implementation can hop to a database
10+
/// actor; they are non-throwing because a cache miss or write failure must
11+
/// never break a live fetch — the service treats the cache as best-effort.
12+
public protocol MessageStore: Sendable {
13+
/// The cached messages for a given timeline scope + tag filter, or `[]`
14+
/// when nothing is cached.
15+
func cachedTimeline(scope: TimelineScope, tag: String?) async -> [Message]
16+
17+
/// Replaces the cached messages for a scope + tag with a fresh page.
18+
func replaceTimeline(_ messages: [Message], scope: TimelineScope, tag: String?) async
19+
20+
/// A single cached message by id, or `nil` when not cached.
21+
func cachedMessage(id: String) async -> Message?
22+
23+
/// Inserts or updates messages in the by-id cache.
24+
func upsert(_ messages: [Message]) async
25+
26+
/// Clears all cached state. Called on sign-out.
27+
func clear() async
28+
}
Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
// InterlinedDomain
22
//
3-
// Domain layer for InterlinedList — app-facing models and services that
4-
// depend on InterlinedKit protocols. M0 placeholder; real services
5-
// (MessagesService, ListsService, DocumentsService, SocialService,
6-
// OrgService, NotificationsService, EntitlementsService) are built in
7-
// later waves per PLAN.md §3 and §6.
3+
// Business-logic layer for InterlinedList — app-facing models and services that
4+
// depend on InterlinedKit protocols (PLAN.md §3). UI-agnostic: this package
5+
// must never import SwiftUI or AppKit. DTOs from InterlinedKit are mapped to
6+
// domain models at the boundary (`Models/Mappers.swift`) and never escape this
7+
// package.
8+
//
9+
// Models/ — Message, UserSummary, CurrentUser, Visibility, TimelineScope,
10+
// TimelinePage, and the DTO → domain mappers.
11+
// Services/ — MessagesService, SessionService, EntitlementsService.
12+
// Caching/ — the MessageStore cache port + an in-memory implementation.
13+
//
14+
// The login + timeline vertical slice (M1) lives here; Lists / Social / Orgs /
15+
// Documents services arrive in their later milestones.
816

917
import Foundation
1018
import InterlinedKit
1119

1220
/// Namespace marker for the InterlinedDomain module.
1321
public enum InterlinedDomain {
14-
/// References the underlying kit schema so the domain layer can assert
15-
/// it is built against a compatible InterlinedKit version.
16-
public static let kitSchemaVersion: String = InterlinedKit.schemaVersion
22+
/// The version of the kit this domain layer was built against. Surfaced so
23+
/// accidental local-package version skew is visible at a glance.
24+
public static let builtAgainstKitVersion: String = InterlinedKit.schemaVersion
1725
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Foundation
2+
3+
/// A typed reading of the API's free-form `customerStatus` string.
4+
///
5+
/// The wire field is an open `String` (`UserDTO.customerStatus`) so the kit
6+
/// stays a faithful mirror. The domain layer narrows it to the cases the app
7+
/// gates on, preserving any unknown value under `.other` so an unexpected
8+
/// status never silently reads as "free" or crashes a switch.
9+
public enum CustomerStatus: Sendable, Equatable, Hashable {
10+
/// An active paid subscriber.
11+
case subscriber
12+
/// A free account with no active subscription.
13+
case free
14+
/// A status string the client does not yet recognise. Treated as
15+
/// non-subscriber for gating but preserved for display / telemetry.
16+
case other(String)
17+
18+
/// Maps the raw wire string to a case. The set of "subscriber" values is
19+
/// kept deliberately small and explicit; anything else is `.other`.
20+
public init(raw: String) {
21+
switch raw.lowercased() {
22+
case "subscriber", "active", "subscribed", "paid":
23+
self = .subscriber
24+
case "free", "none", "inactive", "", "canceled", "cancelled":
25+
self = .free
26+
default:
27+
self = .other(raw)
28+
}
29+
}
30+
31+
/// Whether this status grants subscriber-only features.
32+
public var isSubscriber: Bool {
33+
self == .subscriber
34+
}
35+
36+
/// The original wire value, for display or round-tripping.
37+
public var rawValue: String {
38+
switch self {
39+
case .subscriber: return "subscriber"
40+
case .free: return "free"
41+
case .other(let raw): return raw
42+
}
43+
}
44+
}
45+
46+
/// The full signed-in account (PLAN.md §3, §1 "Subscriber gating").
47+
///
48+
/// Maps from `UserDTO`. Carries the author identity (`summary`) so the same
49+
/// projection used on message cards is reused for the current user, plus the
50+
/// account-only fields the app needs: email, subscriber status, and the
51+
/// optional counters the API exposes.
52+
public struct CurrentUser: Sendable, Equatable, Identifiable {
53+
/// The author identity (id, username, display name, avatar).
54+
public let summary: UserSummary
55+
public let email: String
56+
public let customerStatus: CustomerStatus
57+
public let isEmailVerified: Bool
58+
public let isPrivateAccount: Bool
59+
public let createdAt: Date
60+
61+
public var id: String { summary.id }
62+
public var username: String { summary.username }
63+
public var displayName: String { summary.displayName }
64+
public var avatarURL: URL? { summary.avatarURL }
65+
66+
public init(
67+
summary: UserSummary,
68+
email: String,
69+
customerStatus: CustomerStatus,
70+
isEmailVerified: Bool,
71+
isPrivateAccount: Bool,
72+
createdAt: Date
73+
) {
74+
self.summary = summary
75+
self.email = email
76+
self.customerStatus = customerStatus
77+
self.isEmailVerified = isEmailVerified
78+
self.isPrivateAccount = isPrivateAccount
79+
self.createdAt = createdAt
80+
}
81+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import Foundation
2+
import InterlinedKit
3+
4+
// MARK: - DTO → domain mapping
5+
//
6+
// One file owns every kit-DTO → domain-model translation so the boundary is
7+
// auditable in a single place (PLAN.md §3 "DTOs never cross into the UI;
8+
// domain models do"). Mappers are pure, total functions implemented as
9+
// `init(from:)` so call sites read as plain conversions.
10+
11+
extension UserSummary {
12+
/// Maps the embedded author summary. Display name falls back to the
13+
/// username when the API omits it; the avatar string is parsed into a URL
14+
/// and silently dropped if it is not a valid URL.
15+
public init(from dto: UserSummaryDTO) {
16+
self.init(
17+
id: dto.id,
18+
username: dto.username,
19+
displayName: dto.displayName ?? dto.username,
20+
avatarURL: dto.avatar.flatMap(URL.init(string:))
21+
)
22+
}
23+
}
24+
25+
extension CurrentUser {
26+
/// Maps the full account from the `GET /api/user` payload (the nested
27+
/// `UserDTO`). `customerStatus` is narrowed to the typed `CustomerStatus`;
28+
/// nullable account flags collapse to `false` defaults.
29+
public init(from dto: UserDTO) {
30+
self.init(
31+
summary: UserSummary(
32+
id: dto.id,
33+
username: dto.username,
34+
displayName: dto.displayName ?? dto.username,
35+
avatarURL: dto.avatar.flatMap(URL.init(string:))
36+
),
37+
email: dto.email,
38+
customerStatus: CustomerStatus(raw: dto.customerStatus),
39+
isEmailVerified: dto.emailVerified,
40+
isPrivateAccount: dto.isPrivateAccount ?? false,
41+
createdAt: dto.createdAt
42+
)
43+
}
44+
}
45+
46+
extension Message {
47+
/// Maps a message. Nullable `tags` collapses to `[]`; `publiclyVisible`
48+
/// becomes a `Visibility`; the nested repost target (when present) is
49+
/// mapped recursively. `replyCount` is left `nil` because `MessageDTO`
50+
/// carries no reply count — the replies endpoint reports its own total.
51+
public init(from dto: MessageDTO) {
52+
self.init(
53+
id: dto.id,
54+
author: UserSummary(from: dto.user),
55+
text: dto.content,
56+
createdAt: dto.createdAt,
57+
updatedAt: dto.updatedAt,
58+
tags: dto.tags ?? [],
59+
visibility: Visibility(publiclyVisible: dto.publiclyVisible),
60+
digCount: dto.digCount,
61+
didDig: dto.dugByMe,
62+
repostCount: dto.pushCount,
63+
replyCount: nil,
64+
parentID: dto.parentId,
65+
repost: dto.pushedMessage.map { box in
66+
Repost.message(Message(from: box.message))
67+
},
68+
scheduledAt: dto.scheduledAt
69+
)
70+
}
71+
}
72+
73+
extension TimelinePage {
74+
/// Builds a page from the kit's `Paginated<MessageDTO>` envelope, mapping
75+
/// each DTO and deriving the next-page cursor from the pagination block.
76+
public init(from paginated: Paginated<MessageDTO>) {
77+
let messages = paginated.items.map(Message.init(from:))
78+
let info = paginated.pagination
79+
self.init(
80+
messages: messages,
81+
hasMore: info.hasMore,
82+
nextOffset: info.hasMore ? info.offset + info.limit : nil
83+
)
84+
}
85+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import Foundation
2+
3+
/// A single timeline message (post), as the UI consumes it (PLAN.md §1, §3).
4+
///
5+
/// This is the domain projection of `MessageDTO`: optional/nullable wire
6+
/// fields are resolved into sensible non-optional defaults where the UI always
7+
/// needs a value (`tags` defaults to `[]`), the `publiclyVisible` boolean
8+
/// becomes a `Visibility`, and the recursively-nested repost target is carried
9+
/// as an indirect `repost`. No `MessageDTO` ever escapes this package.
10+
public struct Message: Sendable, Equatable, Identifiable {
11+
public let id: String
12+
/// The author identity for the card / thread row.
13+
public let author: UserSummary
14+
/// The message body. Markdown source is authored inline here; the renderer
15+
/// in the App layer turns it into attributed text.
16+
public let text: String
17+
public let createdAt: Date
18+
public let updatedAt: Date
19+
public let tags: [String]
20+
public let visibility: Visibility
21+
22+
/// "I Dig!" reaction count and whether the signed-in user has dug it.
23+
public let digCount: Int
24+
public let didDig: Bool
25+
26+
/// Repost ("push") count.
27+
public let repostCount: Int
28+
29+
/// Number of direct replies, when the payload carries it. `nil` when the
30+
/// message endpoint did not include a reply count (the list endpoint does
31+
/// not; the replies endpoint reports its own `total`). Kept optional rather
32+
/// than defaulted to `0` so the UI can distinguish "no replies" from
33+
/// "unknown".
34+
public let replyCount: Int?
35+
36+
/// The id of the parent message when this is a reply.
37+
public let parentID: String?
38+
39+
/// The original message this post reposted, if any. `indirect` because a
40+
/// `Message` can contain another `Message`.
41+
public let repost: Repost?
42+
43+
/// When set, the message is scheduled for future publication at this time.
44+
public let scheduledAt: Date?
45+
46+
public init(
47+
id: String,
48+
author: UserSummary,
49+
text: String,
50+
createdAt: Date,
51+
updatedAt: Date,
52+
tags: [String] = [],
53+
visibility: Visibility,
54+
digCount: Int,
55+
didDig: Bool,
56+
repostCount: Int,
57+
replyCount: Int? = nil,
58+
parentID: String? = nil,
59+
repost: Repost? = nil,
60+
scheduledAt: Date? = nil
61+
) {
62+
self.id = id
63+
self.author = author
64+
self.text = text
65+
self.createdAt = createdAt
66+
self.updatedAt = updatedAt
67+
self.tags = tags
68+
self.visibility = visibility
69+
self.digCount = digCount
70+
self.didDig = didDig
71+
self.repostCount = repostCount
72+
self.replyCount = replyCount
73+
self.parentID = parentID
74+
self.repost = repost
75+
self.scheduledAt = scheduledAt
76+
}
77+
}
78+
79+
/// Indirection box for a reposted message. A value type cannot contain itself
80+
/// by value, so the nested original message is held behind `indirect`.
81+
public indirect enum Repost: Sendable, Equatable {
82+
case message(Message)
83+
84+
/// The reposted original.
85+
public var original: Message {
86+
switch self {
87+
case .message(let message): return message
88+
}
89+
}
90+
}
91+
92+
/// One page of a timeline read: the messages plus the cursor needed to ask for
93+
/// the next page. Maps the kit's `PaginationInfo` envelope into the two values
94+
/// the UI's infinite scroll actually needs.
95+
public struct TimelinePage: Sendable, Equatable {
96+
public let messages: [Message]
97+
/// Whether the server reports more messages beyond this page.
98+
public let hasMore: Bool
99+
/// The `offset` to pass for the next page. `nil` when `hasMore` is false.
100+
public let nextOffset: Int?
101+
102+
public init(messages: [Message], hasMore: Bool, nextOffset: Int?) {
103+
self.messages = messages
104+
self.hasMore = hasMore
105+
self.nextOffset = nextOffset
106+
}
107+
108+
/// An empty page with no further results — the boundary value used when a
109+
/// scope has no messages.
110+
public static let empty = TimelinePage(messages: [], hasMore: false, nextOffset: nil)
111+
}

0 commit comments

Comments
 (0)