feat(desktop): SaaS [6] 로컬 우선 sync + login_required UI (빅뱅 Phase 3)
Phase 3 재정의: Supabase = source of truth 방향 철회. 로컬 entry point 철학 하에 "로컬이 SoT, 클라우드는 로그인 시 mirror"로 확정. CloudSyncService._initialSync(): - _onAuthenticated 끝에 fire-and-forget 호출 - pushAll → pullAll 순차 실행 - signin: 익명 로컬 데이터를 사용자 계정으로 업로드 - restore: 다른 기기 변경 반영 - 실패 시 warn만, 수동 Sync 버튼으로 재시도 가능 types.ts: - FeatureAccess.reason: 'login_required' 추가 - UpgradePromptEvent.reason: 'login_required' 추가 LicenseService: - consumeQuota(): login_required 케이스 — promptUpgrade + D3ROError(TierRequired) - promptUpgrade(): login_required 시 requiredTier='free' (로그인만 하면 free로 바로 사용 가능) - quota_exceeded 시 현재 tier 상위로 승격 제안 UpgradePromptModal: - isLoginRequired 분기 — title/desc 교체 - Primary button: "로그인하기" → d3ro:open-settings event (tab=cloud) i18n ko/en: - license.loginRequired.title/desc/signIn 추가
This commit is contained in:
parent
46b77f1118
commit
ceadf1b6f3
7 changed files with 103 additions and 6 deletions
|
|
@ -173,6 +173,36 @@ class CloudSyncService extends EventEmitter {
|
|||
`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
})
|
||||
|
||||
// 5) 초기 bi-directional sync (빅뱅 Phase 3 — local-first mirror)
|
||||
// 로그인 직후 기존 로컬 데이터를 한 번 push하고, 원격 변경을 pull.
|
||||
// fire-and-forget — UI는 이미 해제됨, sync는 백그라운드에서 진행.
|
||||
void this._initialSync(opts.reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* 빅뱅 Phase 3: 로그인 직후 기존 로컬 데이터를 한 번 push + 원격 변경 pull.
|
||||
* 데스크톱은 로컬-first이므로, 클라우드는 mirror 역할.
|
||||
* - signin: 이전 익명 로컬 세션에 쌓인 데이터를 사용자 계정으로 업로드
|
||||
* - restore: 다른 기기에서 추가된 변경사항을 가져오기
|
||||
* 실패해도 warn만 찍고 앱은 계속 동작 — 수동 Sync 버튼으로 재시도 가능.
|
||||
*/
|
||||
private async _initialSync(reason: 'restore' | 'signin'): Promise<void> {
|
||||
try {
|
||||
logger.info(`[auth:${reason}] Initial sync starting — push then pull`)
|
||||
const pushResult = await this.pushAll()
|
||||
logger.info(
|
||||
`[auth:${reason}] Initial push done: pushed=${pushResult.pushed}, errors=${pushResult.errors.length}`
|
||||
)
|
||||
const pullResult = await this.pullAll()
|
||||
logger.info(
|
||||
`[auth:${reason}] Initial pull done: applied=${pullResult.pushed}, errors=${pullResult.errors.length}`
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[auth:${reason}] Initial sync failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -359,6 +359,14 @@ class LicenseService extends EventEmitter {
|
|||
{ feature, requiredTier: access.requiredTier }
|
||||
)
|
||||
}
|
||||
if (access.reason === 'login_required') {
|
||||
this.promptUpgrade(feature, 'login_required')
|
||||
throw new D3ROError(
|
||||
ErrorCode.TierRequired,
|
||||
`Feature ${feature} requires sign in`,
|
||||
{ feature }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼터 있는 기능만 DB에 기록
|
||||
|
|
@ -481,7 +489,15 @@ class LicenseService extends EventEmitter {
|
|||
/** 업그레이드 유도 이벤트 발생 */
|
||||
promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void {
|
||||
const minTier = FEATURE_MIN_TIER[feature]
|
||||
const requiredTier: LicenseTier = reason === 'quota_exceeded' ? 'pro' : minTier
|
||||
// 쿼터 초과: 현재 tier 상위. login_required: free(로그인하면 바로 사용 가능). 그 외: feature의 최소 tier.
|
||||
const requiredTier: LicenseTier =
|
||||
reason === 'quota_exceeded'
|
||||
? this._info.tier === 'free'
|
||||
? 'pro'
|
||||
: 'pro_plus'
|
||||
: reason === 'login_required'
|
||||
? 'free'
|
||||
: minTier
|
||||
|
||||
const event: UpgradePromptEvent = {
|
||||
feature,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue