Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions Projects/App/Sources/AlarmSessionStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import CoreStorage
import Domain
import Foundation
import os

/// 알람 세션을 소유하는 **유일한 지점**. 읽기도 쓰기도 여기를 통한다.
///
/// 이전에는 하나의 세션이 8군데에 복제돼 있었다 — 서버, `lastInfo`(replay 버퍼 겸
/// diff 이전 값 겸 만료 판정 대상), 파생 메모리 5종, 스냅샷, AlarmKit 레코드,
/// AlarmKit, ActivityKit, 그리고 홈 ViewModel 사본. 이 중 OS·원격이 소유한
/// 4개(서버·AlarmKit 레코드·AlarmKit·ActivityKit)는 본질적 미러라 없앨 수 없지만,
/// **앱이 소유한 나머지는 하나로 모을 수 있다.** 이 타입이 그 하나다.
///
/// `@MainActor` 클래스인 이유: `actor`로 만들면 `current`가 `async`가 되어 홈 렌더
/// 경로마다 `await`가 붙는다. 세션 쓰기는 동기화 트리거 4경로에서만 일어나고 그
/// 경로가 이미 `inFlight` 합류로 직렬화되므로, 메인 액터 격리만으로 단일 writer가
/// 보장된다.
@MainActor
final class AlarmSessionStore {
private static let logger = Logger(subsystem: "com.atcha.alarm", category: "session")

/// 새 키다 — 기존 `alarm.sessionSnapshot`과 스키마가 다르다. V2가 미출시이므로
/// 마이그레이션을 두지 않고, 구 키는 전환이 끝난 뒤 정리한다.
static let storageKey = "alarm.session"

private let document: DocumentStore<AlarmSession>
private var subscribers: [UUID: AsyncStream<AlarmSession?>.Continuation] = [:]

/// 메모리 캐시 = 동기 읽기의 근거. 디스크 로드는 `bootstrap()`에서 1회만 한다.
private(set) var current: AlarmSession?
private var didBootstrap = false

init(store: any KeyValueStore) {
document = DocumentStore(store: store, key: Self.storageKey)
}

/// 앱 시작 직후 1회. **sync보다 먼저 불러야 한다** — 그래야 오프라인 콜드스타트에서도
/// 홈이 배너·카드를 즉시 그린다. 이전 구조는 시딩 게이트가 `sync()` 안에 있어서
/// sync가 돌지 않으면 복원도 되지 않았다.
func bootstrap() async {
guard !didBootstrap else { return }
didBootstrap = true
current = await document.load()
if let current {
Self.logger.info(
"""
세션 복원: route=\(current.server.lastRouteId, privacy: .public) \
lifecycle=\(current.lifecycle.rawValue, privacy: .public)
"""
)
}
// 톰스톤이어도 방출한다 — 홈이 "지난 막차" 카드를 그릴 근거이고,
// 무엇보다 구독자가 "아직 로드 전"과 "세션 없음"을 구분할 수 있어야 한다.
broadcast()
}

/// Reconciler 판정을 그대로 반영한다. **세션 상태가 바뀌는 유일한 경로**다.
func apply(_ outcome: AlarmSessionReconciler.Outcome) async {
switch outcome {
case let .refreshed(session):
await persist(session)

case let .expired(session):
// clear가 아니라 톰스톤을 남긴다 — 지워 버리면 다음 실행에서 같은 과거
// 세션의 refresh가 배너·재부착을 되살린다.
Self.logger.info(
"세션 만료 확정: route=\(session.server.lastRouteId, privacy: .public)"
)
await persist(session)

case .ended:
await clear()

case .ignoredStaleEcho:
// 아무것도 하지 않는다. 방출도 하지 않아야 한다 — 같은 값을 다시 흘리면
// 구독자가 불필요하게 다시 렌더한다.
break
}
}

/// stopIntent("확인") 수신 — LA 재시작 금지 상태로 전이한다.
func acknowledge() async {
guard let session = current, session.lifecycle == .active else { return }
await persist(session.with(lifecycle: .acknowledged))
}

/// 등록 성공 직후. 서버가 주지 않는 로컬 사실(도보 초·표시명·수단)의 유일한 기록
/// 시점이라, **서버 등록 직후·로컬 스케줄 전에** 불러야 한다. 스케줄이 실패해
/// 앱이 죽어도 다음 sync가 도보 초를 복원할 수 있다(그러지 않으면 알람이 도보
/// 시간만큼 늦게 울린다).
func register(session: AlarmSession) async {
await persist(session)
}

func clear() async {
try? await document.clear()
current = nil
broadcast()
}

/// 로그아웃·탈퇴 — 로컬 기록을 비우고 구독자에게도 알린다.
func reset() async {
await clear()
}

// MARK: - 구독

/// 구독자마다 독립 스트림. **replay-1** — 구독 전에 끝난 부트스트랩·동기화를
/// 놓치지 않아야 한다. (변경 *사건*은 replay하지 않는 별도 채널이 맡는다.)
func updates() -> AsyncStream<AlarmSession?> {
let id = UUID()
return AsyncStream { continuation in
subscribers[id] = continuation
continuation.yield(current)
continuation.onTermination = { [weak self] _ in
Task { @MainActor [weak self] in self?.subscribers[id] = nil }
}
}
}

// MARK: -

private func persist(_ session: AlarmSession) async {
// 저장 실패를 흡수하는 이유: 정본은 서버다. 디스크 기록은 재실행 브리지이므로
// 실패해도 이번 실행의 동작을 막아서는 안 된다(다음 sync가 자가치유).
try? await document.save(session)
current = session
broadcast()
}

private func broadcast() {
for continuation in subscribers.values {
continuation.yield(current)
}
}
}
226 changes: 226 additions & 0 deletions Projects/App/Tests/AlarmSessionStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
@testable import AtchaV2
import CoreStorage
import Domain
import Foundation
import Synchronization
import Testing

private final class InMemoryKeyValueStore: KeyValueStore {
private let storage = Mutex<[String: Data]>([:])

func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } }
func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } }
func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } }
}

@MainActor
struct AlarmSessionStoreTests {
private let now = Date(timeIntervalSince1970: 1_700_000_000)
private let store = InMemoryKeyValueStore()

private func makeSUT() -> AlarmSessionStore {
AlarmSessionStore(store: store)
}

private func session(
route: String = "R1",
departureOffset: TimeInterval = 900,
walkSeconds: Int? = 300,
lifecycle: AlarmSession.Lifecycle = .active
) -> AlarmSession {
AlarmSession(
server: AlarmInfo(
lastRouteId: route,
departureTime: now.addingTimeInterval(departureOffset),
updatedAt: now,
isReal: true
),
local: .init(
firstWalkSeconds: walkSeconds,
routeDisplayName: "6411번 버스",
transportMode: .bus
),
lifecycle: lifecycle,
syncedAt: now
)
}

// MARK: - 부트스트랩

@Test
func bootstrap_emptyStore_currentIsNil() async {
let sut = makeSUT()

await sut.bootstrap()

#expect(sut.current == nil)
}

/// 앱 시작 직후 디스크에서 복원해야 한다 — 이전 구조는 시딩이 `sync()` 안에 있어서
/// sync가 돌지 않으면(오프라인 콜드스타트) 복원도 되지 않았다.
@Test
func bootstrap_restoresPersistedSession() async {
let saved = session()
try? store.setValue(saved, forKey: AlarmSessionStore.storageKey)
let sut = makeSUT()

await sut.bootstrap()

#expect(sut.current == saved)
}

@Test
func bootstrap_calledTwice_loadsOnce() async {
try? store.setValue(session(), forKey: AlarmSessionStore.storageKey)
let sut = makeSUT()
await sut.bootstrap()

// 두 번째 호출 전에 디스크를 비워도 캐시가 유지돼야 한다(재로드 금지).
try? store.removeValue(forKey: AlarmSessionStore.storageKey)
await sut.bootstrap()

#expect(sut.current != nil)
}

// MARK: - Reconciler outcome 반영

@Test
func apply_refreshed_persistsAndExposes() async {
let sut = makeSUT()
await sut.bootstrap()
let next = session()

await sut.apply(.refreshed(next))

#expect(sut.current == next)
#expect((try? store.value(AlarmSession.self, forKey: AlarmSessionStore.storageKey)) == next)
}

/// 만료는 **지우지 않고 톰스톤을 남긴다** — 지우면 다음 실행에서 같은 과거 세션의
/// refresh가 배너를 되살린다.
@Test
func apply_expired_keepsTombstoneOnDisk() async {
let sut = makeSUT()
await sut.bootstrap()
let dead = session(lifecycle: .ended)

await sut.apply(.expired(dead))

#expect(sut.current?.lifecycle == .ended)
let persisted = try? store.value(AlarmSession.self, forKey: AlarmSessionStore.storageKey)
#expect(persisted?.lifecycle == .ended)
}

@Test
func apply_ended_clearsEverything() async {
let sut = makeSUT()
await sut.register(session: session())

await sut.apply(.ended)

#expect(sut.current == nil)
let persisted = try? store.value(AlarmSession.self, forKey: AlarmSessionStore.storageKey)
#expect(persisted == nil)
}

/// 메아리는 상태를 바꾸지 않아야 한다.
@Test
func apply_ignoredStaleEcho_leavesStateUntouched() async {
let sut = makeSUT()
let existing = session()
await sut.register(session: existing)

await sut.apply(.ignoredStaleEcho)

#expect(sut.current == existing)
}

// MARK: - 전이

@Test
func acknowledge_activeSession_transitionsToAcknowledged() async {
let sut = makeSUT()
await sut.register(session: session(lifecycle: .active))

await sut.acknowledge()

#expect(sut.current?.lifecycle == .acknowledged)
}

/// 이미 끝난 세션은 확인으로 되살아나면 안 된다.
@Test
func acknowledge_endedSession_isNoop() async {
let sut = makeSUT()
await sut.register(session: session(lifecycle: .ended))

await sut.acknowledge()

#expect(sut.current?.lifecycle == .ended)
}

/// 등록은 로컬 사실(도보 초)의 유일한 기록 시점이다.
@Test
func register_recordsLocalFacts() async {
let sut = makeSUT()

await sut.register(session: session(walkSeconds: 420))

#expect(sut.current?.local.firstWalkSeconds == 420)
// fireDate가 도보 초를 반영해야 한다 — 이게 없으면 알람이 늦게 울린다.
#expect(
sut.current?.fireDate
== now.addingTimeInterval(900 - 420 - AlarmTiming.bufferSeconds)
)
}

@Test
func reset_clearsSession() async {
let sut = makeSUT()
await sut.register(session: session())

await sut.reset()

#expect(sut.current == nil)
}

// MARK: - 구독 (replay-1)

/// 구독 전에 끝난 부트스트랩을 놓치지 않아야 한다.
@Test
func updates_replaysCurrentValueOnSubscribe() async {
let sut = makeSUT()
let existing = session()
await sut.register(session: existing)

var iterator = sut.updates().makeAsyncIterator()
let first = await iterator.next()

#expect(first == existing)
}

@Test
func updates_emitsOnApply() async {
let sut = makeSUT()
await sut.bootstrap()
var iterator = sut.updates().makeAsyncIterator()
_ = await iterator.next() // replay(nil)

let next = session()
await sut.apply(.refreshed(next))

#expect(await iterator.next() == next)
}

@Test
func updates_emitsNilOnClear() async {
let sut = makeSUT()
await sut.register(session: session())
var iterator = sut.updates().makeAsyncIterator()
_ = await iterator.next() // replay(session)

await sut.clear()

let received = await iterator.next()
#expect(received == AlarmSession?.none)
}
}
Loading
Loading