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:
윤찬 2026-04-11 10:35:17 +09:00
parent 46b77f1118
commit ceadf1b6f3
7 changed files with 103 additions and 6 deletions

View file

@ -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)}`
)
}
}
/**

View file

@ -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,

View file

@ -44,9 +44,15 @@ export function UpgradePromptModal(): React.ReactElement {
if (!event) return <></>
const isQuota = event.reason === 'quota_exceeded'
const isLoginRequired = event.reason === 'login_required'
const featureLabel = t(`license.feature.${event.feature}`)
const tierLabel = event.requiredTier === 'pro_plus' ? t('license.proPlus') : t('license.pro')
const openCloudSyncSettings = (): void => {
setOpen(false)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } }))
}
return (
<Dialog
open={open}
@ -77,7 +83,9 @@ export function UpgradePromptModal(): React.ReactElement {
<LockIcon sx={{ fontSize: 20 }} />
{isQuota
? t('license.quotaExceeded.title', { feature: featureLabel })
: t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })}
: isLoginRequired
? t('license.loginRequired.title', { feature: featureLabel })
: t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })}
</DialogTitle>
<DialogContent>
@ -91,7 +99,9 @@ export function UpgradePromptModal(): React.ReactElement {
>
{isQuota
? t('license.quotaExceeded.desc')
: t('license.tierRequired.desc', { tier: tierLabel })}
: isLoginRequired
? t('license.loginRequired.desc')
: t('license.tierRequired.desc', { tier: tierLabel })}
</Typography>
{/* 쿼터 바 */}
@ -155,7 +165,7 @@ export function UpgradePromptModal(): React.ReactElement {
</Button>
<Button
variant="contained"
onClick={handleLearnMore}
onClick={isLoginRequired ? openCloudSyncSettings : handleLearnMore}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
@ -170,7 +180,7 @@ export function UpgradePromptModal(): React.ReactElement {
},
}}
>
{t('license.learnMore')}
{isLoginRequired ? t('license.loginRequired.signIn') : t('license.learnMore')}
</Button>
</DialogActions>
</Dialog>