fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 1b8fe445f3
commit 9aa7302944
30 changed files with 2614 additions and 386 deletions

View file

@ -14,6 +14,7 @@ import path from 'path'
import * as schema from './schema' import * as schema from './schema'
import { getLogger } from '../services/LoggerService' import { getLogger } from '../services/LoggerService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { emitAccountScopeChanged, LOCAL_SCOPE_ID } from '../services/account-scope'
const logger = getLogger('database') const logger = getLogger('database')
@ -29,7 +30,7 @@ const logger = getLogger('database')
* - `_local`: 무료 익명 사용자 entry point (킬러 피처 — 회원가입 0, 로컬 STT/LLM) * - `_local`: 무료 익명 사용자 entry point (킬러 피처 — 회원가입 0, 로컬 STT/LLM)
* - 실제 uuid: OAuth 로그인 후 클라우드 sync + premium 기능 (유료 SaaS 수익화) * - 실제 uuid: OAuth 로그인 후 클라우드 sync + premium 기능 (유료 SaaS 수익화)
*/ */
export const LOCAL_USER_ID = '_local' export const LOCAL_USER_ID = LOCAL_SCOPE_ID
let currentUserId: string | null = null let currentUserId: string | null = null
let sqlite: Database.Database | null = null let sqlite: Database.Database | null = null
@ -486,6 +487,8 @@ export function openForUser(userId: string): OpenForUserResult {
} }
logger.info(`Database opened for user: ${userId}`) logger.info(`Database opened for user: ${userId}`)
// 계정 범위 저장소(명령·템플릿)도 이 사용자로 바꾼다 — 동기화 엔진이 만들어지기 전에 동기적으로 끝난다.
emitAccountScopeChanged(userId)
return { created: !existed, dbPath } return { created: !existed, dbPath }
} }
@ -650,6 +653,7 @@ export function bindTestDatabase(
): void { ): void {
db = testDb db = testDb
currentUserId = userId currentUserId = userId
emitAccountScopeChanged(userId)
} }
/** Vitest 전용: bindTestDatabase 해제. */ /** Vitest 전용: bindTestDatabase 해제. */

View file

@ -14,7 +14,7 @@ app.commandLine.appendSwitch('disable-gpu')
import { bootstrap } from './bootstrap' import { bootstrap } from './bootstrap'
import { setupLifecycle } from './lifecycle' import { setupLifecycle } from './lifecycle'
import { getMainWindow } from './windows/WindowManager' import { showMainWindow } from './windows/WindowManager'
import { getLogger } from './services/LoggerService' import { getLogger } from './services/LoggerService'
const deepLinkLogger = getLogger('deep-link') const deepLinkLogger = getLogger('deep-link')
@ -137,11 +137,9 @@ if (!gotTheLock) {
} else { } else {
app.on('second-instance', (_event, argv) => { app.on('second-instance', (_event, argv) => {
// 기존 인스턴스의 메인 윈도우를 최상위로 활성화 // 기존 인스턴스의 메인 윈도우를 최상위로 활성화
const mainWindow = getMainWindow() // 창이 닫혀 없으면 다시 만든다 — 창 없는 프로세스로 남지 않게.
const mainWindow = showMainWindow()
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
mainWindow.setAlwaysOnTop(true) mainWindow.setAlwaysOnTop(true)
setTimeout(() => { setTimeout(() => {
try { try {

View file

@ -1,7 +1,7 @@
// src/main/ipc/license-handlers.ts // src/main/ipc/license-handlers.ts
// Phase 11: 라이센스 IPC 핸들러 // Phase 11: 라이센스 IPC 핸들러
import { app, ipcMain, BrowserWindow, shell } from 'electron' import { app, ipcMain, BrowserWindow } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getLicenseService } from '../services/LicenseService' import { getLicenseService } from '../services/LicenseService'
@ -12,6 +12,7 @@ import type {
} from '@d3ro/core/types' } from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types'
import { billingUrl, PUBLIC_SITE_ORIGIN } from '@d3ro/core/web-urls' import { billingUrl, PUBLIC_SITE_ORIGIN } from '@d3ro/core/web-urls'
import { openExternalSafe } from '../windows/web-contents-hardening'
/** 개발 빌드는 로컬 웹앱(`npm run dev --workspace=@d3ro/web`)의 결제 페이지를 연다. */ /** 개발 빌드는 로컬 웹앱(`npm run dev --workspace=@d3ro/web`)의 결제 페이지를 연다. */
const DEV_WEB_ORIGIN = 'http://localhost:3000' const DEV_WEB_ORIGIN = 'http://localhost:3000'
@ -105,7 +106,10 @@ export function registerLicenseHandlers(): void {
ipcMain.handle(IPC_CHANNELS.LICENSE.OPEN_BILLING, async (_event, params: { tier: 'pro' | 'pro_plus' }) => { ipcMain.handle(IPC_CHANNELS.LICENSE.OPEN_BILLING, async (_event, params: { tier: 'pro' | 'pro_plus' }) => {
try { try {
const url = billingUrl({ tier: params.tier }) const url = billingUrl({ tier: params.tier })
await shell.openExternal(app.isPackaged ? url : url.replace(PUBLIC_SITE_ORIGIN, DEV_WEB_ORIGIN)) const target = app.isPackaged ? url : url.replace(PUBLIC_SITE_ORIGIN, DEV_WEB_ORIGIN)
// 개발 빌드만 로컬 웹앱(http://localhost:3000) 오리진을 추가로 허용한다.
const opened = await openExternalSafe(target, app.isPackaged ? {} : { allowOrigins: [DEV_WEB_ORIGIN] })
if (!opened) return ipcError(ErrorCode.UnknownError, 'Failed to open billing: URL not allowed')
return ipcSuccess(undefined) return ipcSuccess(undefined)
} catch (err) { } catch (err) {
return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`) return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`)

View file

@ -1,9 +1,10 @@
// src/main/ipc/window-handlers.ts // src/main/ipc/window-handlers.ts
import { ipcMain, shell } from 'electron' import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess } from '@d3ro/core/errors' import { ErrorCode, ipcError, ipcSuccess } from '@d3ro/core/errors'
import { getMainWindow, hideResultPopup, hideRecordingTip } from '../windows/WindowManager' import { getMainWindow, hideResultPopup, hideRecordingTip } from '../windows/WindowManager'
import { isTrustedIpcSender, openExternalSafe } from '../windows/web-contents-hardening'
export function registerWindowHandlers(): void { export function registerWindowHandlers(): void {
ipcMain.on(IPC_CHANNELS.WINDOW.MINIMIZE, () => { ipcMain.on(IPC_CHANNELS.WINDOW.MINIMIZE, () => {
@ -38,9 +39,15 @@ export function registerWindowHandlers(): void {
hideRecordingTip() hideRecordingTip()
}) })
// 외부 URL 열기 // 외부 URL 열기 — 앱 페이지가 보낸 요청만, 허용 scheme(https/mailto)만 연다.
ipcMain.handle(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, async (_event, params: { url: string }) => { ipcMain.handle(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, async (event, params: { url: string }) => {
await shell.openExternal(params.url) if (!isTrustedIpcSender(event)) {
return ipcError(ErrorCode.ConfigInvalidValue, 'openExternal: untrusted sender')
}
const url = typeof params?.url === 'string' ? params.url : ''
if (!(await openExternalSafe(url))) {
return ipcError(ErrorCode.ConfigInvalidValue, 'openExternal: URL scheme not allowed')
}
return ipcSuccess(undefined) return ipcSuccess(undefined)
}) })
} }

View file

@ -4,7 +4,9 @@
// 실제 동기화는 services/sync/SyncEngine 이 한다 — 이 서비스는 인증·수명주기·트리거를 맡는다. // 실제 동기화는 services/sync/SyncEngine 이 한다 — 이 서비스는 인증·수명주기·트리거를 맡는다.
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { shell, app, safeStorage } from 'electron' import fs from 'fs'
import path from 'path'
import { app, safeStorage } from 'electron'
import { import {
createClient, createClient,
type SupabaseClient, type SupabaseClient,
@ -19,10 +21,14 @@ import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalMode
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LicenseTier } from '@d3ro/core/types' import type { LicenseTier } from '@d3ro/core/types'
import { SyncEngine, type SyncStatus } from './sync/SyncEngine' import { SyncEngine, type SyncStatus } from './sync/SyncEngine'
import { SyncAbortedError } from './sync/sync-adapters'
import { SyncScheduler } from './sync/sync-scheduler'
import { EncryptedFileTokenStore, bindAuthEvents, type SessionTokenStore } from './sync/session-token-store'
import { openExternalSafe } from '../windows/web-contents-hardening'
import { SupabaseSyncRemote } from './sync/supabase-sync-remote' import { SupabaseSyncRemote } from './sync/supabase-sync-remote'
import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox' import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox'
import { checkInDesktopDevice, currentDeviceInfo, unregisterDesktopDevice } from './sync/device-registration' import { checkInDesktopDevice, currentDeviceInfo, unregisterDesktopDevice } from './sync/device-registration'
import type { SyncEntity, SyncRunResult } from './sync/sync-types' import { realtimeTables, type SyncEntity, type SyncRunResult } from './sync/sync-types'
import { nodeRealtimeTransport } from './sync/realtime-transport' import { nodeRealtimeTransport } from './sync/realtime-transport'
import { SETTINGS_ROW_ID, SYNCED_CONFIG_KEYS, isApplyingRemoteSettings } from './sync/settings-sync' import { SETTINGS_ROW_ID, SYNCED_CONFIG_KEYS, isApplyingRemoteSettings } from './sync/settings-sync'
import { AUDIO_BUCKET, listLocalAudioOwners } from './sync/audio-sync' import { AUDIO_BUCKET, listLocalAudioOwners } from './sync/audio-sync'
@ -38,20 +44,12 @@ const HEARTBEAT_MS = 5 * 60_000
/** sync_state 에 저장하는 이 사용자 DB의 등록 기기 id */ /** sync_state 에 저장하는 이 사용자 DB의 등록 기기 id */
const DEVICE_STATE_KEY = 'device:id' const DEVICE_STATE_KEY = 'device:id'
/** Realtime 변경 감시 대상. sync_tombstones = 다른 기기의 삭제 */ /** 로그아웃·계정 전환 때 진행 중인 동기화가 멈추기를 기다리는 최대 시간 */
const REALTIME_TABLES = [ const ENGINE_STOP_TIMEOUT_MS = 5_000
'history', /** 로그인 버튼을 누른 뒤 deep link 콜백을 받아들이는 시간 — 요청하지 않은 콜백(login CSRF)을 거른다 */
'dictionary', const SIGN_IN_WINDOW_MS = 10 * 60_000
'meetings', /** refresh token 파일 이름(userData 아래) */
'meeting_memos', const TOKEN_FILE = 'cloud-sync.token'
'meeting_documents',
'memo_tags',
'custom_instructions',
'user_templates',
'knowledge_documents',
'user_settings',
'sync_tombstones',
] as const
// ============================================================ // ============================================================
// 타입 // 타입
@ -103,9 +101,17 @@ class CloudSyncService extends EventEmitter {
private _initialized = false private _initialized = false
private _realtimeChannel: RealtimeChannel | null = null private _realtimeChannel: RealtimeChannel | null = null
private _engine: SyncEngine | null = null private _engine: SyncEngine | null = null
private _flushTimer: NodeJS.Timeout | null = null /** 로그인 시작(startSignIn) 후 콜백을 받아들이는 기한. null이면 진행 중인 로그인이 없다 */
private _pullTimer: NodeJS.Timeout | null = null private _pendingSignInUntil: number | null = null
private _heartbeatTimer: NodeJS.Timeout | null = null private _tokenStore: SessionTokenStore | null = null
private _unbindAuthEvents: (() => void) | null = null
private readonly _scheduler = new SyncScheduler({
flushDelayMs: FLUSH_DEBOUNCE_MS,
pullDelayMs: PULL_DEBOUNCE_MS,
heartbeatMs: HEARTBEAT_MS,
run: (kind) => void this._runEngine(kind).catch(() => undefined),
onHeartbeat: () => this._onHeartbeat(),
})
/** /**
* 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원. * 초기화 — Supabase 클라이언트 생성, 저장된 세션 복원.
@ -129,6 +135,18 @@ class CloudSyncService extends EventEmitter {
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
// supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을
// 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다.
const client = this._client
this._unbindAuthEvents = bindAuthEvents<Session>(
(callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription,
this._tokens(),
() => this._session?.user.id ?? null,
(session) => {
this._session = session
}
)
// 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외). // 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외).
onConfigChanged((event) => { onConfigChanged((event) => {
if (isApplyingRemoteSettings()) return if (isApplyingRemoteSettings()) return
@ -201,9 +219,17 @@ class CloudSyncService extends EventEmitter {
session: Session, session: Session,
opts: { reason: 'restore' | 'signin' } opts: { reason: 'restore' | 'signin' }
): Promise<void> { ): Promise<void> {
this._session = session
const userId = session.user.id const userId = session.user.id
// 0) 이전 계정의 동기화를 먼저 멈춘다 — 새 DB가 열린 뒤 이전 엔진이 그 DB에 쓰지 않게.
const previous = this._engine
this._engine = null
if (previous) {
previous.dispose()
await previous.whenIdle(ENGINE_STOP_TIMEOUT_MS)
}
this._session = session
// 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리) // 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리)
try { try {
const { created, dbPath } = openForUser(userId) const { created, dbPath } = openForUser(userId)
@ -245,9 +271,8 @@ class CloudSyncService extends EventEmitter {
) )
} }
// 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다. // 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다(엔진은 이 DB에 묶인다).
this._engine?.dispose() // 이전 엔진은 0)에서 이미 해제했다.
this._engine = null
if (this._client) { if (this._client) {
const engine = new SyncEngine({ remote: new SupabaseSyncRemote(this._client), userId }) const engine = new SyncEngine({ remote: new SupabaseSyncRemote(this._client), userId })
engine.on('progress', (payload) => this.emit('sync-progress', payload)) engine.on('progress', (payload) => this.emit('sync-progress', payload))
@ -358,7 +383,8 @@ class CloudSyncService extends EventEmitter {
// 2) Realtime 구독·주기 작업 종료, 엔진 해제 // 2) Realtime 구독·주기 작업 종료, 엔진 해제
await this.stopRealtime() await this.stopRealtime()
this._stopHeartbeat() this._stopHeartbeat()
this._engine?.dispose() const engine = this._engine
engine?.dispose()
this._engine = null this._engine = null
// 3) 사용자가 직접 로그아웃하면 기기 목록에서도 빠진다(원격 해제로 로그아웃될 때는 이미 해제됨). // 3) 사용자가 직접 로그아웃하면 기기 목록에서도 빠진다(원격 해제로 로그아웃될 때는 이미 해제됨).
@ -392,6 +418,11 @@ class CloudSyncService extends EventEmitter {
// 6) 사용자 DB 닫고 로컬 모드 DB로 복귀 // 6) 사용자 DB 닫고 로컬 모드 DB로 복귀
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학) // — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
// 진행 중이던 pull이 끝나기를(최대 ENGINE_STOP_TIMEOUT_MS) 기다린다. 해제된 엔진은 다음 await에서 멈추고,
// 시간이 넘어도 사용자 DB 검사가 로컬 모드 DB에 쓰는 것을 막는다.
if (engine && !(await engine.whenIdle(ENGINE_STOP_TIMEOUT_MS))) {
logger.warn('[signOut] Sync engine did not stop in time — continuing (writes are blocked by user DB guard)')
}
closeCurrent() closeCurrent()
try { try {
const { dbPath } = openLocal() const { dbPath } = openLocal()
@ -587,19 +618,22 @@ class CloudSyncService extends EventEmitter {
const userId = this._session.user.id const userId = this._session.user.id
// persistSession: false 에서는 Supabase realtime 클라이언트가 auth state change를 // postgres_changes 채널이 RLS를 통과하도록 access token을 realtime에 넣는다(TIMED_OUT 버그 픽스).
// 자동 추적하지 않는다. access_token을 명시적으로 realtime에 주입해서 // 로그인 시점의 토큰은 1시간 뒤 만료되므로, 클라이언트의 현재 세션(필요하면 갱신)에서 꺼낸다 —
// postgres_changes 채널이 RLS를 통과하도록 한다. (TIMED_OUT 버그 픽스) // 만료된 토큰으로 재구독하면 CHANNEL_ERROR만 반복한다.
try { try {
this._client.realtime.setAuth(this._session.access_token) const { data } = await this._client.auth.getSession()
if (!this._session || !this._client) return
await this._client.realtime.setAuth(data.session?.access_token ?? this._session.access_token)
} catch (err) { } catch (err) {
logger.warn( logger.warn(
`Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}` `Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}`
) )
} }
if (!this._session || !this._client) return
let channel = this._client.channel(`cloud-sync:${userId}`) let channel = this._client.channel(`cloud-sync:${userId}`)
for (const table of REALTIME_TABLES) { for (const table of realtimeTables()) {
channel = channel.on( channel = channel.on(
'postgres_changes', 'postgres_changes',
{ event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` }, { event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` },
@ -710,8 +744,29 @@ class CloudSyncService extends EventEmitter {
) )
} }
// 이 앱이 요청한 로그인만 받아들인다: 콜백은 이 시각 이후 한 번만 유효하다.
this._pendingSignInUntil = Date.now() + SIGN_IN_WINDOW_MS
logger.info(`Opening OAuth URL in external browser: ${provider}`) logger.info(`Opening OAuth URL in external browser: ${provider}`)
await shell.openExternal(data.url) if (!(await openExternalSafe(data.url))) {
this._pendingSignInUntil = null
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'OAuth URL is not an https URL')
}
}
/**
* deep link 로그인 콜백을 받아들일지. 사용자가 방금 로그인을 시작했고(startSignIn) 이미 로그인된 상태가
* 아닐 때만 한 번 허용한다 — 아무 웹페이지나 d3ro-voice://auth-callback#access_token=... 으로 앱을
* 공격자 계정에 로그인시켜 로컬 기록을 그 계정으로 올리게 하는 것(login CSRF)을 막는다.
*/
private _consumePendingSignIn(): void {
const until = this._pendingSignInUntil
this._pendingSignInUntil = null
if (until === null || Date.now() > until) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ignored sign-in callback: no sign-in was started from this app')
}
if (this._session) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ignored sign-in callback: already signed in — sign out first')
}
} }
/** /**
@ -723,6 +778,7 @@ class CloudSyncService extends EventEmitter {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다') throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
} }
this._consumePendingSignIn()
const { data, error } = await this._client.auth.exchangeCodeForSession(code) const { data, error } = await this._client.auth.exchangeCodeForSession(code)
if (error || !data.session) { if (error || !data.session) {
throw new D3ROError( throw new D3ROError(
@ -754,6 +810,7 @@ class CloudSyncService extends EventEmitter {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다') throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
} }
this._consumePendingSignIn()
const { data, error } = await this._client.auth.setSession({ const { data, error } = await this._client.auth.setSession({
access_token: params.accessToken, access_token: params.accessToken,
refresh_token: params.refreshToken, refresh_token: params.refreshToken,
@ -805,20 +862,12 @@ class CloudSyncService extends EventEmitter {
this._scheduleFlush() this._scheduleFlush()
} }
private _scheduleFlush(delayMs = FLUSH_DEBOUNCE_MS): void { private _scheduleFlush(): void {
if (this._flushTimer) return this._scheduler.scheduleFlush()
this._flushTimer = setTimeout(() => {
this._flushTimer = null
void this._runEngine('flush').catch(() => undefined)
}, delayMs)
} }
private _schedulePull(delayMs = PULL_DEBOUNCE_MS): void { private _schedulePull(): void {
if (this._pullTimer) return this._scheduler.schedulePull()
this._pullTimer = setTimeout(() => {
this._pullTimer = null
void this._runEngine('pull').catch(() => undefined)
}, delayMs)
} }
/** 수동 "업로드": 보관된 실패 항목도 다시 시도한다. */ /** 수동 "업로드": 보관된 실패 항목도 다시 시도한다. */
@ -862,6 +911,11 @@ class CloudSyncService extends EventEmitter {
return result return result
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
if (err instanceof SyncAbortedError || engine !== this._engine) {
// 로그아웃·계정 전환으로 멈춘 동기화 — 사용자에게 알릴 오류가 아니다.
logger.info(`Sync ${kind} stopped: ${message}`)
throw err
}
logger.error(`Sync ${kind} failed: ${message}`) logger.error(`Sync ${kind} failed: ${message}`)
this.emit('sync-error', { error: message }) this.emit('sync-error', { error: message })
throw err throw err
@ -905,33 +959,23 @@ class CloudSyncService extends EventEmitter {
} }
private _startHeartbeat(): void { private _startHeartbeat(): void {
this._stopHeartbeat() this._scheduler.startHeartbeat()
this._heartbeatTimer = setInterval(() => { }
if (!this._session) return
void this._checkInDevice('heartbeat') private _onHeartbeat(): void {
// 재구독을 포기한 채널(3회 실패)을 되살린다 — 네트워크가 돌아온 뒤에도 실시간이 끊겨 있지 않게. if (!this._session) return
if (this._realtimeChannel?.state !== 'joined') { void this._checkInDevice('heartbeat')
this._realtimeRetryCount = 0 // 재구독을 포기한 채널(3회 실패)을 되살린다 — 네트워크가 돌아온 뒤에도 실시간이 끊겨 있지 않게.
void this.startRealtime().catch(() => undefined) if (this._realtimeChannel?.state !== 'joined') {
} this._realtimeRetryCount = 0
// Realtime 이벤트를 놓쳤을 때의 안전망 void this.startRealtime().catch(() => undefined)
void this._runEngine('full').catch(() => undefined) }
}, HEARTBEAT_MS) // Realtime 이벤트를 놓쳤을 때의 안전망
void this._runEngine('full').catch(() => undefined)
} }
private _stopHeartbeat(): void { private _stopHeartbeat(): void {
if (this._heartbeatTimer) { this._scheduler.stop()
clearInterval(this._heartbeatTimer)
this._heartbeatTimer = null
}
if (this._flushTimer) {
clearTimeout(this._flushTimer)
this._flushTimer = null
}
if (this._pullTimer) {
clearTimeout(this._pullTimer)
this._pullTimer = null
}
} }
/** /**
@ -966,48 +1010,44 @@ class CloudSyncService extends EventEmitter {
} }
} }
// ── 토큰 영속화 (electron safeStorage) ───────────────── // ── 토큰 영속화 (electron safeStorage) — SessionTokenStore 에 위임 ─────
private _tokens(): SessionTokenStore {
if (!this._tokenStore) {
this._tokenStore = new EncryptedFileTokenStore(
path.join(app.getPath('userData'), TOKEN_FILE),
safeStorage,
{
existsSync: (p) => fs.existsSync(p),
readFileSync: (p) => fs.readFileSync(p),
writeFileSync: (p, data) => fs.writeFileSync(p, data),
unlinkSync: (p) => fs.unlinkSync(p),
},
logger
)
}
return this._tokenStore
}
private _saveRefreshToken(token: string): void { private _saveRefreshToken(token: string): void {
try { this._tokens().save(token)
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(token)
const fs = require('fs') as typeof import('fs')
const path = require('path') as typeof import('path')
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
fs.writeFileSync(tokenPath, encrypted)
}
} catch (err) {
logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
}
} }
private _loadStoredRefreshToken(): string | null { private _loadStoredRefreshToken(): string | null {
try { return this._tokens().load()
const fs = require('fs') as typeof import('fs')
const path = require('path') as typeof import('path')
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token')
if (!fs.existsSync(tokenPath)) return null
if (!safeStorage.isEncryptionAvailable()) return null
const encrypted = fs.readFileSync(tokenPath)
return safeStorage.decryptString(encrypted)
} catch (err) {
logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
return null
}
} }
private _clearStoredRefreshToken(): void { private _clearStoredRefreshToken(): void {
try { this._tokens().clear()
const fs = require('fs') as typeof import('fs') }
const path = require('path') as typeof import('path')
const tokenPath = path.join(app.getPath('userData'), 'cloud-sync.token') /** 테스트 전용: 타이머·auth 구독을 해제한다. */
if (fs.existsSync(tokenPath)) { disposeForTests(): void {
fs.unlinkSync(tokenPath) this._scheduler.stop()
} this._unbindAuthEvents?.()
} catch { this._unbindAuthEvents = null
// ignore void this._client?.auth.stopAutoRefresh().catch(() => undefined)
} this.removeAllListeners()
} }
// ── EventEmitter 타입 오버라이드 ─────────────────────── // ── EventEmitter 타입 오버라이드 ───────────────────────
@ -1029,7 +1069,7 @@ class CloudSyncService extends EventEmitter {
let instance: CloudSyncService | null = null let instance: CloudSyncService | null = null
export function resetCloudSyncServiceForTests(): void { export function resetCloudSyncServiceForTests(): void {
if (instance) instance.removeAllListeners() if (instance) instance.disposeForTests()
instance = null instance = null
} }

View file

@ -1,10 +1,18 @@
// src/main/services/CustomInstructionService.ts // src/main/services/CustomInstructionService.ts
// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조. // 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조.
// electron-store에 저장, 프리셋 5개 기본 제공. // 계정 범위 저장소(users/<id>/custom-instructions.json)에 저장, 프리셋 5개 기본 제공.
// 계정이 바뀌면(로그인·로그아웃) 그 계정의 목록으로 다시 읽는다 — 다른 계정의 명령이 보이거나 올라가지 않게.
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService' import { configGet } from './ConfigService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import {
AccountScopedList,
currentAccountScope,
electronStoreCollectionFactory,
onAccountScopeChanged,
type UserScopedCollectionFactory,
} from './account-scope'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { CustomInstruction } from '@d3ro/core/types' import type { CustomInstruction } from '@d3ro/core/types'
@ -68,44 +76,107 @@ const BUILTIN_INSTRUCTIONS: ReadonlyArray<Omit<CustomInstruction, 'createdAt' |
] ]
// ============================================================ // ============================================================
// 저장소 (electron-store 사용) // 길이 제한 — 서버 custom_instructions CHECK 와 같은 값(마이그레이션 20260821000005)
// ============================================================ // ============================================================
// electron-store 대신 간단한 JSON 파일 사용 (ConfigService와 별도) export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const
// 실제로는 electron-store의 별도 인스턴스를 사용하지만,
// Phase 6에서는 메모리 + configGet/configSet 패턴으로 단순화 export type InstructionLimitField = keyof typeof INSTRUCTION_LIMITS
/** Postgres char_length 와 같은 코드 포인트 수 */
function charLength(value: string): number {
return [...value].length
}
/** 서버 제한을 넘는 첫 필드. 없으면 null. (이름·프롬프트는 앞뒤 공백을 빼고 센다 — 서버 btrim 과 같다) */
export function instructionLimitViolation(input: {
name?: string
description?: string
prompt?: string
}): InstructionLimitField | null {
if (input.name !== undefined && charLength(input.name.trim()) > INSTRUCTION_LIMITS.name) return 'name'
if (input.description !== undefined && charLength(input.description.trim()) > INSTRUCTION_LIMITS.description) {
return 'description'
}
if (input.prompt !== undefined && charLength(input.prompt.trim()) > INSTRUCTION_LIMITS.prompt) return 'prompt'
return null
}
function assertWithinLimits(input: { name?: string; description?: string; prompt?: string }): void {
const field = instructionLimitViolation(input)
if (field !== null) {
throw new D3ROError(
ErrorCode.ConfigInvalidValue,
`Instruction ${field} is too long (max ${INSTRUCTION_LIMITS[field]} characters)`,
{ field, max: INSTRUCTION_LIMITS[field] }
)
}
}
// ============================================================
// 저장소 — 계정 범위 목록 (AccountScopedList)
// ============================================================
let instructions: CustomInstruction[] = [] let instructions: CustomInstruction[] = []
let initialized = false let initialized = false
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
let store: AccountScopedList<CustomInstruction> | null = null
let unsubscribeScope: (() => void) | null = null
function loadInstructions(): CustomInstruction[] { /** 계정 범위가 생기기 전(전역 config)의 목록 — 로컬 모드 범위로 한 번 옮긴다. */
// electron-store에서 로드 시도 function readLegacyInstructions(): CustomInstruction[] | null {
try { try {
const stored = configGet('customInstructions') as CustomInstruction[] | undefined const stored = configGet('customInstructions') as CustomInstruction[] | undefined
if (Array.isArray(stored) && stored.length > 0) { return Array.isArray(stored) && stored.length > 0 ? stored : null
return stored
}
} catch { } catch {
// 첫 실행 시 키가 없을 수 있음 return null
} }
}
// 프리셋으로 초기화 function builtinDefaults(now: number): CustomInstruction[] {
return BUILTIN_INSTRUCTIONS.map((b) => ({ ...b, createdAt: now, updatedAt: now }))
}
function getStore(): AccountScopedList<CustomInstruction> {
if (!store) {
store = new AccountScopedList<CustomInstruction>({
name: 'custom-instructions',
factory: collectionFactory,
readLegacy: readLegacyInstructions,
})
}
return store
}
function loadInstructions(scopeId: string): CustomInstruction[] {
let stored: CustomInstruction[] | null = null
try {
stored = getStore().open(scopeId)
} catch (error) {
logger.warn(`Failed to load instructions: ${error instanceof Error ? error.message : String(error)}`)
}
const now = Date.now() const now = Date.now()
return BUILTIN_INSTRUCTIONS.map((b) => ({ if (!stored || stored.length === 0) return builtinDefaults(now)
...b, // 가져온 목록에 프리셋이 빠져 있으면 채운다(프리셋은 지울 수 없다).
createdAt: now, const ids = new Set(stored.map((i) => i.id))
updatedAt: now return [...stored, ...builtinDefaults(now).filter((b) => !ids.has(b.id))]
}))
} }
function saveInstructions(): void { function saveInstructions(): void {
try { try {
configSet('customInstructions', instructions) getStore().save(instructions)
} catch (error) { } catch (error) {
logger.warn(`Failed to save instructions: ${error instanceof Error ? error.message : String(error)}`) logger.warn(`Failed to save instructions: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
/** 계정이 바뀌면 그 계정의 목록으로 다시 읽는다(동기화 엔진이 만들어지기 전에 끝난다). */
function reloadForScope(scopeId: string): void {
if (!initialized) return
instructions = loadInstructions(scopeId)
logger.info(`CustomInstructionService reloaded for account scope (${instructions.length} instructions)`)
}
// ============================================================ // ============================================================
// CustomInstructionService // CustomInstructionService
// ============================================================ // ============================================================
@ -113,8 +184,9 @@ function saveInstructions(): void {
class CustomInstructionService { class CustomInstructionService {
initialize(): void { initialize(): void {
if (initialized) return if (initialized) return
instructions = loadInstructions() instructions = loadInstructions(currentAccountScope())
initialized = true initialized = true
unsubscribeScope ??= onAccountScopeChanged(reloadForScope)
logger.info(`CustomInstructionService initialized (${instructions.length} instructions)`) logger.info(`CustomInstructionService initialized (${instructions.length} instructions)`)
} }
@ -133,6 +205,8 @@ class CustomInstructionService {
if (!input.prompt.trim()) { if (!input.prompt.trim()) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty') throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty')
} }
// 서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
assertWithinLimits(input)
const now = Date.now() const now = Date.now()
const instruction: CustomInstruction = { const instruction: CustomInstruction = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -158,6 +232,7 @@ class CustomInstructionService {
if (index === -1) return null if (index === -1) return null
const existing = instructions[index] const existing = instructions[index]
assertWithinLimits(existing.isBuiltin ? { prompt: data.prompt } : data)
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다 // 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
if (existing.isBuiltin) { if (existing.isBuiltin) {
@ -285,8 +360,14 @@ export function getCustomInstructionService(): CustomInstructionService {
return instance return instance
} }
export function resetCustomInstructionServiceForTests(): void { export function resetCustomInstructionServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
): void {
instance = null instance = null
initialized = false initialized = false
instructions = [] instructions = []
unsubscribeScope?.()
unsubscribeScope = null
store = null
collectionFactory = factory
} }

View file

@ -6,6 +6,13 @@ import { EventEmitter } from 'events'
import Store from 'electron-store' import Store from 'electron-store'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import {
AccountScopedList,
currentAccountScope,
electronStoreCollectionFactory,
onAccountScopeChanged,
type UserScopedCollectionFactory,
} from './account-scope'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -67,31 +74,28 @@ const BUILTIN_TEMPLATES: DictationTemplate[] = [
}, },
] ]
type TemplateStoreSchema = {
templates: DictationTemplate[]
}
class DictationTemplateService extends EventEmitter { class DictationTemplateService extends EventEmitter {
private _store: Store<TemplateStoreSchema> /** 계정 범위 저장소(users/<id>/dictation-templates.json) — 계정이 바뀌면 다시 읽는다 */
private readonly _list: AccountScopedList<DictationTemplate>
private _items: DictationTemplate[] = []
private readonly _unsubscribeScope: () => void
private _session: TemplateSessionInfo | null = null private _session: TemplateSessionInfo | null = null
constructor() { constructor(factory: UserScopedCollectionFactory = collectionFactory) {
super() super()
this._store = new Store<TemplateStoreSchema>({ this._list = new AccountScopedList<DictationTemplate>({
name: 'dictation-templates', name: 'dictation-templates',
defaults: { factory,
templates: [...BUILTIN_TEMPLATES], readLegacy: readLegacyTemplates,
},
}) })
this._loadScope(currentAccountScope())
// 프리셋이 없으면 추가 this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
this._ensureBuiltins()
} }
// ── CRUD ── // ── CRUD ──
getAll(): DictationTemplate[] { getAll(): DictationTemplate[] {
return this._store.get('templates', []) return [...this._items]
} }
getById(id: string): DictationTemplate | null { getById(id: string): DictationTemplate | null {
@ -113,7 +117,7 @@ class DictationTemplateService extends EventEmitter {
const templates = this.getAll() const templates = this.getAll()
templates.push(template) templates.push(template)
this._store.set('templates', templates) this._save(templates)
getCloudSyncService().pushOne('user_templates', template.id) getCloudSyncService().pushOne('user_templates', template.id)
return template return template
@ -137,7 +141,7 @@ class DictationTemplateService extends EventEmitter {
} }
templates[idx] = updated templates[idx] = updated
this._store.set('templates', templates) this._save(templates)
if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id) if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id)
return updated return updated
} }
@ -152,10 +156,7 @@ class DictationTemplateService extends EventEmitter {
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Cannot delete builtin template') throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Cannot delete builtin template')
} }
this._store.set( this._save(templates.filter((t) => t.id !== id))
'templates',
templates.filter((t) => t.id !== id),
)
getCloudSyncService().pushDelete('user_templates', id) getCloudSyncService().pushDelete('user_templates', id)
} }
@ -166,7 +167,7 @@ class DictationTemplateService extends EventEmitter {
if (idx === -1) templates.push(template) if (idx === -1) templates.push(template)
else if (!templates[idx].isBuiltin) templates[idx] = template else if (!templates[idx].isBuiltin) templates[idx] = template
else return else return
this._store.set('templates', templates) this._save(templates)
} }
/** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */ /** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */
@ -174,7 +175,7 @@ class DictationTemplateService extends EventEmitter {
const templates = this.getAll() const templates = this.getAll()
const target = templates.find((t) => t.id === id) const target = templates.find((t) => t.id === id)
if (!target || target.isBuiltin) return false if (!target || target.isBuiltin) return false
this._store.set('templates', templates.filter((t) => t.id !== id)) this._save(templates.filter((t) => t.id !== id))
return true return true
} }
@ -307,15 +308,50 @@ class DictationTemplateService extends EventEmitter {
templates.push(builtin) templates.push(builtin)
} }
} }
this._store.set('templates', templates) this._save(templates)
}
/** 계정 범위를 열고 그 계정의 템플릿으로 바꾼다. 없으면 프리셋으로 시작한다. */
private _loadScope(scopeId: string): void {
this.cancelSession()
try {
this._items = this._list.open(scopeId) ?? [...BUILTIN_TEMPLATES]
} catch (err) {
logger.warn(`Template store open failed: ${err instanceof Error ? err.message : String(err)}`)
this._items = [...BUILTIN_TEMPLATES]
}
this._ensureBuiltins()
}
private _save(templates: DictationTemplate[]): void {
this._items = [...templates]
try {
this._list.save(this._items)
} catch (err) {
logger.warn(`Template save failed: ${err instanceof Error ? err.message : String(err)}`)
}
} }
dispose(): void { dispose(): void {
this._unsubscribeScope()
this.cancelSession() this.cancelSession()
this.removeAllListeners() this.removeAllListeners()
} }
} }
/** 계정 범위가 생기기 전의 기계 전역 저장소(dictation-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */
function readLegacyTemplates(): DictationTemplate[] | null {
try {
const legacy = new Store<{ templates?: DictationTemplate[] }>({ name: 'dictation-templates' })
const templates = legacy.get('templates')
return Array.isArray(templates) && templates.length > 0 ? templates : null
} catch {
return null
}
}
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
// ── 싱글톤 ── // ── 싱글톤 ──
let instance: DictationTemplateService | null = null let instance: DictationTemplateService | null = null
@ -326,6 +362,10 @@ export function getDictationTemplateService(): DictationTemplateService {
return instance return instance
} }
export function resetDictationTemplateServiceForTests(): void { export function resetDictationTemplateServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
): void {
instance?.dispose()
instance = null instance = null
collectionFactory = factory
} }

View file

@ -3,11 +3,13 @@
import { eq, desc, like, and, sql, count } from 'drizzle-orm' import { eq, desc, like, and, sql, count } from 'drizzle-orm'
import { getDatabase } from '../db' import { getDatabase } from '../db'
import { history, memoTags, stats } from '../db/schema' import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema' import type { History, NewHistory } from '../db/schema'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import { getInputTelemetryService } from './InputTelemetryService' import { getInputTelemetryService } from './InputTelemetryService'
import { deleteHistoryLocally } from './history-deletion'
import { computeStreakDays } from './history-stats'
import type { import type {
HistoryEntry, HistoryEntry,
HistoryQueryParams, HistoryQueryParams,
@ -126,11 +128,10 @@ class HistoryService {
} }
delete(id: string): boolean { delete(id: string): boolean {
const db = getDatabase() // 태그·행과 함께 이 기록의 녹음 WAV(userData/recordings)도 지운다.
db.delete(memoTags).where(eq(memoTags.historyId, id)).run() const { deleted } = deleteHistoryLocally({ ids: [id] })
const result = db.delete(history).where(eq(history.id, id)).run() if (deleted > 0) getCloudSyncService().pushDelete('history', id)
if (result.changes > 0) getCloudSyncService().pushDelete('history', id) return deleted > 0
return result.changes > 0
} }
/** /**
@ -138,10 +139,8 @@ class HistoryService {
* 로컬에 있던 행만 지운다 — 아직 내려받지 않은 원격 행까지 일괄 삭제하지 않는다. * 로컬에 있던 행만 지운다 — 아직 내려받지 않은 원격 행까지 일괄 삭제하지 않는다.
*/ */
deleteAll(): void { deleteAll(): void {
const db = getDatabase() // 개인정보 삭제: 행뿐 아니라 녹음 파일까지 지운다.
const ids = db.select({ id: history.id }).from(history).all().map((r) => r.id) const { ids } = deleteHistoryLocally({ all: true })
db.delete(memoTags).run()
db.delete(history).run()
const sync = getCloudSyncService() const sync = getCloudSyncService()
for (const id of ids) sync.pushDelete('history', id) for (const id of ids) sync.pushDelete('history', id)
logger.info('All history entries deleted') logger.info('All history entries deleted')
@ -161,9 +160,27 @@ class HistoryService {
return row ? this._toEntry(row) : null return row ? this._toEntry(row) : null
} }
/**
* 누적·오늘·연속 사용일 모두 history 테이블에서 계산한다(완료 세션만). 저장된 카운터는 로컬 생성만
* 반영해 동기화·가져오기·삭제와 어긋났고, streak은 쓰는 곳이 없어 늘 0이었다.
*/
getStats(): StatsSummary { getStats(): StatsSummary {
const db = getDatabase() const db = getDatabase()
const row = db.select().from(stats).where(eq(stats.id, 1)).get() const completed = eq(history.status, 'completed')
const totals = db
.select({
sessions: count(),
duration: sql<number>`COALESCE(SUM(duration), 0)`,
words: sql<number>`COALESCE(SUM(word_count), 0)`
})
.from(history)
.where(completed)
.get()
const days = db
.select({ day: sql<string>`DISTINCT date(created_at / 1000, 'unixepoch', 'localtime')` })
.from(history)
.where(completed)
.all()
const todayStart = new Date() const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0) todayStart.setHours(0, 0, 0, 0)
@ -185,13 +202,13 @@ class HistoryService {
.get() .get()
return { return {
totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000, totalRecordingTimeMs: (totals?.duration ?? 0) * 1000,
totalWordCount: row?.totalWords ?? 0, totalWordCount: totals?.words ?? 0,
totalSessionCount: row?.sessionCount ?? 0, totalSessionCount: totals?.sessions ?? 0,
todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000, todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000,
todayWordCount: todayResult?.words ?? 0, todayWordCount: todayResult?.words ?? 0,
todaySessionCount: todayResult?.sessions ?? 0, todaySessionCount: todayResult?.sessions ?? 0,
streakDays: row?.streakDays ?? 0 streakDays: computeStreakDays((days ?? []).map((d) => d.day).filter((d): d is string => typeof d === 'string'))
} }
} }
@ -199,13 +216,12 @@ class HistoryService {
* 보존 정책에 따라 오래된 항목 정리 (30일 초과) * 보존 정책에 따라 오래된 항목 정리 (30일 초과)
*/ */
runRetentionCleanup(): number { runRetentionCleanup(): number {
const db = getDatabase()
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000 const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
const result = db.delete(history).where(sql`created_at < ${cutoff}`).run() const { deleted } = deleteHistoryLocally({ olderThan: cutoff })
if (result.changes > 0) { if (deleted > 0) {
logger.info(`Retention cleanup: ${result.changes} old entries removed`) logger.info(`Retention cleanup: ${deleted} old entries removed`)
} }
return result.changes return deleted
} }
dispose(): void { dispose(): void {

View file

@ -5,6 +5,13 @@ import { EventEmitter } from 'events'
import Store from 'electron-store' import Store from 'electron-store'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import {
AccountScopedList,
currentAccountScope,
electronStoreCollectionFactory,
onAccountScopeChanged,
type UserScopedCollectionFactory,
} from './account-scope'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
MeetingDocTemplate, MeetingDocTemplate,
@ -146,28 +153,27 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
}, },
] ]
type MeetingDocTemplateStoreSchema = {
templates: MeetingDocTemplate[]
}
class MeetingDocTemplateService extends EventEmitter { class MeetingDocTemplateService extends EventEmitter {
private _store: Store<MeetingDocTemplateStoreSchema> /** 계정 범위 저장소(users/<id>/meeting-doc-templates.json) — 계정이 바뀌면 다시 읽는다 */
private readonly _list: AccountScopedList<MeetingDocTemplate>
private _items: MeetingDocTemplate[] = []
private readonly _unsubscribeScope: () => void
constructor() { constructor(factory: UserScopedCollectionFactory = collectionFactory) {
super() super()
this._store = new Store<MeetingDocTemplateStoreSchema>({ this._list = new AccountScopedList<MeetingDocTemplate>({
name: 'meeting-doc-templates', name: 'meeting-doc-templates',
defaults: { factory,
templates: [...BUILTIN_TEMPLATES], readLegacy: readLegacyTemplates,
},
}) })
this._ensureBuiltins() this._loadScope(currentAccountScope())
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
} }
// ── CRUD ── // ── CRUD ──
getAll(): MeetingDocTemplate[] { getAll(): MeetingDocTemplate[] {
return this._store.get('templates', []) return [...this._items]
} }
getById(id: string): MeetingDocTemplate | null { getById(id: string): MeetingDocTemplate | null {
@ -188,7 +194,7 @@ class MeetingDocTemplateService extends EventEmitter {
const templates = this.getAll() const templates = this.getAll()
templates.push(template) templates.push(template)
this._store.set('templates', templates) this._save(templates)
getCloudSyncService().pushOne('user_templates', template.id) getCloudSyncService().pushOne('user_templates', template.id)
logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`) logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`)
return template return template
@ -214,7 +220,7 @@ class MeetingDocTemplateService extends EventEmitter {
} }
templates[idx] = updated templates[idx] = updated
this._store.set('templates', templates) this._save(templates)
if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id) if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id)
logger.info(`회의 문서 템플릿 수정: ${params.id}`) logger.info(`회의 문서 템플릿 수정: ${params.id}`)
return updated return updated
@ -236,10 +242,7 @@ class MeetingDocTemplateService extends EventEmitter {
) )
} }
this._store.set( this._save(templates.filter((t) => t.id !== id))
'templates',
templates.filter((t) => t.id !== id),
)
getCloudSyncService().pushDelete('user_templates', id) getCloudSyncService().pushDelete('user_templates', id)
logger.info(`회의 문서 템플릿 삭제: ${id}`) logger.info(`회의 문서 템플릿 삭제: ${id}`)
} }
@ -251,7 +254,7 @@ class MeetingDocTemplateService extends EventEmitter {
if (idx === -1) templates.push(template) if (idx === -1) templates.push(template)
else if (!templates[idx].isBuiltin) templates[idx] = template else if (!templates[idx].isBuiltin) templates[idx] = template
else return else return
this._store.set('templates', templates) this._save(templates)
} }
/** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */ /** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */
@ -259,7 +262,7 @@ class MeetingDocTemplateService extends EventEmitter {
const templates = this.getAll() const templates = this.getAll()
const target = templates.find((t) => t.id === id) const target = templates.find((t) => t.id === id)
if (!target || target.isBuiltin) return false if (!target || target.isBuiltin) return false
this._store.set('templates', templates.filter((t) => t.id !== id)) this._save(templates.filter((t) => t.id !== id))
return true return true
} }
@ -273,15 +276,49 @@ class MeetingDocTemplateService extends EventEmitter {
} }
} }
if (changed) { if (changed) {
this._store.set('templates', templates) this._save(templates)
}
}
/** 계정 범위를 열고 그 계정의 템플릿으로 바꾼다. 없으면 프리셋으로 시작한다. */
private _loadScope(scopeId: string): void {
try {
this._items = this._list.open(scopeId) ?? [...BUILTIN_TEMPLATES]
} catch (err) {
logger.warn(`Template store open failed: ${err instanceof Error ? err.message : String(err)}`)
this._items = [...BUILTIN_TEMPLATES]
}
this._ensureBuiltins()
}
private _save(templates: MeetingDocTemplate[]): void {
this._items = [...templates]
try {
this._list.save(this._items)
} catch (err) {
logger.warn(`Template save failed: ${err instanceof Error ? err.message : String(err)}`)
} }
} }
dispose(): void { dispose(): void {
this._unsubscribeScope()
this.removeAllListeners() this.removeAllListeners()
} }
} }
/** 계정 범위가 생기기 전의 기계 전역 저장소(meeting-doc-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */
function readLegacyTemplates(): MeetingDocTemplate[] | null {
try {
const legacy = new Store<{ templates?: MeetingDocTemplate[] }>({ name: 'meeting-doc-templates' })
const templates = legacy.get('templates')
return Array.isArray(templates) && templates.length > 0 ? templates : null
} catch {
return null
}
}
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
// ── 싱글톤 ── // ── 싱글톤 ──
let instance: MeetingDocTemplateService | null = null let instance: MeetingDocTemplateService | null = null
@ -292,6 +329,10 @@ export function getMeetingDocTemplateService(): MeetingDocTemplateService {
return instance return instance
} }
export function resetMeetingDocTemplateServiceForTests(): void { export function resetMeetingDocTemplateServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
): void {
instance?.dispose()
instance = null instance = null
collectionFactory = factory
} }

View file

@ -0,0 +1,197 @@
// src/main/services/account-scope.ts
// 계정 범위(account scope) 전환 알림과 계정별 목록 저장소 포트.
//
// SQLite만 사용자별(users/<id>/d3ro.db)로 나뉘고 명령·템플릿은 기계 전역 저장소에 있으면, 계정을 바꿀 때마다
// 서비스 N곳이 따로 격리를 기억해야 한다(잊으면 계정 간 누출·동기화 오염). 그래서:
// - db/index.ts 가 사용자 DB를 열 때 한 번 알린다(emitAccountScopeChanged).
// - 서비스는 UserScopedCollection 포트로만 저장하고, 알림을 받으면 그 계정 범위로 다시 읽는다.
// 이 모듈은 DB·서비스를 import하지 않는다(순환 방지).
import { app } from 'electron'
import path from 'path'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
const logger = getLogger('AccountScope')
/** 익명 로컬 모드 범위. db/index.ts LOCAL_USER_ID 와 같은 값이다. */
export const LOCAL_SCOPE_ID = '_local'
// ── 전환 알림 ────────────────────────────────────────────
export type AccountScopeListener = (scopeId: string) => void
const listeners = new Set<AccountScopeListener>()
let currentScope: string = LOCAL_SCOPE_ID
/** 현재 열린 계정 범위(사용자 DB id). 아직 아무 DB도 열리지 않았으면 로컬 모드. */
export function currentAccountScope(): string {
return currentScope
}
export function onAccountScopeChanged(listener: AccountScopeListener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** 사용자 DB가 바뀐 직후 부른다. 리스너 예외는 DB 전환을 막지 않는다. */
export function emitAccountScopeChanged(scopeId: string): void {
if (scopeId === currentScope) return
currentScope = scopeId
for (const listener of [...listeners]) {
try {
listener(scopeId)
} catch (err) {
logger.warn(`Account scope listener failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
/** 테스트 전용 */
export function resetAccountScopeForTests(): void {
listeners.clear()
currentScope = LOCAL_SCOPE_ID
}
// ── 계정별 목록 저장소 포트 ──────────────────────────────
export interface UserScopedCollection<T> {
/** 저장된 목록. 한 번도 저장한 적 없으면 null */
load(): T[] | null
save(items: T[]): void
getMeta(key: string): string | null
setMeta(key: string, value: string): void
}
export type UserScopedCollectionFactory = <T>(name: string, scopeId: string) => UserScopedCollection<T>
function assertScopeId(scopeId: string): void {
if (!scopeId || scopeId.includes('/') || scopeId.includes('\\') || scopeId.includes('..')) {
throw new Error(`Invalid account scope id: "${scopeId}"`)
}
}
interface ScopedStoreSchema<T> {
items: T[] | null
meta: Record<string, string>
}
/** 기본 구현: userData/users/<scopeId>/<name>.json (사용자 DB와 같은 폴더) */
export const electronStoreCollectionFactory: UserScopedCollectionFactory = <T>(name: string, scopeId: string) => {
assertScopeId(scopeId)
const store = new Store<ScopedStoreSchema<T>>({
name,
cwd: path.join(app.getPath('userData'), 'users', scopeId),
defaults: { items: null, meta: {} },
})
return {
load: () => {
const items = store.get('items', null)
return Array.isArray(items) ? items : null
},
save: (items) => store.set('items', items),
getMeta: (key) => store.get('meta', {})[key] ?? null,
setMeta: (key, value) => store.set('meta', { ...store.get('meta', {}), [key]: value }),
}
}
/** 테스트용 메모리 구현. 같은 (name, scope)는 같은 저장소를 돌려준다. */
export function createMemoryCollectionFactory(): UserScopedCollectionFactory {
const data = new Map<string, { items: unknown[] | null; meta: Record<string, string> }>()
return <T>(name: string, scopeId: string): UserScopedCollection<T> => {
assertScopeId(scopeId)
const key = `${scopeId}/${name}`
let entry = data.get(key)
if (!entry) {
entry = { items: null, meta: {} }
data.set(key, entry)
}
const slot = entry
return {
load: () => (slot.items ? ([...slot.items] as T[]) : null),
save: (items) => {
slot.items = [...items]
},
getMeta: (k) => slot.meta[k] ?? null,
setMeta: (k, v) => {
slot.meta[k] = v
},
}
}
}
// ── 계정별 목록 (레거시 이관 + 로컬 모드 가져오기) ────────
const META_LEGACY_MIGRATED = 'legacyMigrated'
const META_LOCAL_IMPORTED = 'localImported'
const META_CLAIMED_BY = 'claimedBy'
export interface AccountScopedListOptions<T> {
/** 저장 파일 이름 */
name: string
factory: UserScopedCollectionFactory
/** 계정 범위가 생기기 전의 기계 전역 저장소 값(최초 1회 로컬 모드 범위로 옮긴다) */
readLegacy: () => T[] | null
}
/**
* 계정 범위별 목록. 규칙은 db/index.ts importLocalModeData 와 같다:
* - 옛 전역 값은 로컬 모드(_local) 범위로 한 번 옮긴다.
* - 로컬 모드에서 만든 사용자 항목은 처음 로그인한 계정 하나만 가져간다(claimedBy). 원본은 지우지 않는다.
* - 다른 계정은 서로의 항목을 보지 못한다.
*/
export class AccountScopedList<T extends { id: string; isBuiltin: boolean }> {
private collection: UserScopedCollection<T> | null = null
private scope: string | null = null
constructor(private readonly options: AccountScopedListOptions<T>) {}
get scopeId(): string | null {
return this.scope
}
/** 범위를 열고 저장된 목록을 돌려준다(없으면 null — 호출자가 프리셋으로 채운다). */
open(scopeId: string): T[] | null {
const collection = this.openCollection(scopeId)
if (scopeId !== LOCAL_SCOPE_ID && collection.getMeta(META_LOCAL_IMPORTED) !== '1') {
this.importFromLocal(collection, scopeId)
collection.setMeta(META_LOCAL_IMPORTED, '1')
}
this.collection = collection
this.scope = scopeId
return collection.load()
}
save(items: T[]): void {
if (!this.collection) throw new Error(`${this.options.name}: account scope is not open`)
this.collection.save(items)
}
private openCollection(scopeId: string): UserScopedCollection<T> {
const collection = this.options.factory<T>(this.options.name, scopeId)
if (scopeId === LOCAL_SCOPE_ID && collection.getMeta(META_LEGACY_MIGRATED) !== '1') {
if (collection.load() === null) {
const legacy = this.options.readLegacy()
if (legacy && legacy.length > 0) collection.save(legacy)
}
collection.setMeta(META_LEGACY_MIGRATED, '1')
}
return collection
}
private importFromLocal(target: UserScopedCollection<T>, scopeId: string): void {
const local = this.openCollection(LOCAL_SCOPE_ID)
const claimedBy = local.getMeta(META_CLAIMED_BY)
if (claimedBy && claimedBy !== scopeId) return
const imported = (local.load() ?? []).filter((item) => !item.isBuiltin)
if (imported.length > 0) {
const own = target.load() ?? []
const ownIds = new Set(own.map((item) => item.id))
target.save([...own, ...imported.filter((item) => !ownIds.has(item.id))])
logger.info(`${this.options.name}: imported ${imported.length} local-mode item(s) into ${scopeId}`)
}
local.setMeta(META_CLAIMED_BY, scopeId)
}
}

View file

@ -0,0 +1,95 @@
// src/main/services/history-deletion.ts
// 이력 로컬 삭제의 단일 경로: 태그·행을 지우고, 앱이 만든 녹음 WAV(userData/recordings)도 지운다.
// HistoryService(단건·전체·보존 정리)와 동기화의 원격 삭제 반영(historyAdapter.deleteLocal)이 함께 쓴다.
// 서버 전파(outbox)는 호출자가 결정한다 — 원격 삭제 반영은 다시 올리면 안 된다.
import { app } from 'electron'
import { rm } from 'fs/promises'
import path from 'path'
import { inArray, sql } from 'drizzle-orm'
import { getDatabase } from '../db'
import { history, memoTags } from '../db/schema'
import { getLogger } from './LoggerService'
const logger = getLogger('HistoryDeletion')
/** 앱이 녹음 파일을 쓰는 곳. VoiceModeService._saveAudioFile 과 같은 경로다. */
export function recordingsDir(): string {
return path.join(app.getPath('userData'), 'recordings')
}
/**
* path가 녹음 디렉터리 안의 파일인지. 파일 전사는 audioLocalPath 에 사용자가 고른 원본 경로를 넣으므로
* 그 밖의 경로는 절대 지우지 않는다.
*/
export function isAppRecordingPath(filePath: string | null | undefined, dir = recordingsDir()): filePath is string {
if (typeof filePath !== 'string' || filePath.trim() === '') return false
const normalize = (p: string): string => {
const resolved = path.resolve(p)
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
}
const base = normalize(dir) + path.sep
const target = normalize(filePath)
return target.startsWith(base) && target.length > base.length
}
/** 녹음 파일을 지운다(최선 노력, 없으면 무시). 로컬 DB 삭제를 막지 않도록 기다리지 않아도 된다. */
export async function removeRecordingFiles(paths: ReadonlyArray<string | null | undefined>): Promise<number> {
const dir = recordingsDir()
let removed = 0
for (const filePath of paths) {
if (!isAppRecordingPath(filePath, dir)) continue
try {
await rm(filePath, { force: true })
removed++
} catch (err) {
logger.warn(`Recording delete failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
return removed
}
export interface HistoryDeletionResult {
/** 지운 이력 행 수 */
deleted: number
/** 지운 행의 id (선택 대상이 있었을 때) */
ids: string[]
}
type Target = { ids: readonly string[] } | { all: true } | { olderThan: number }
/**
* 이력 행과 태그를 지우고 녹음 파일 정리를 예약한다. 녹음 정리는 DB 삭제가 끝난 뒤 비동기로 한다.
*/
export function deleteHistoryLocally(target: Target): HistoryDeletionResult {
const db = getDatabase()
const where =
'ids' in target
? target.ids.length > 0
? inArray(history.id, [...target.ids])
: null
: 'olderThan' in target
? sql`created_at < ${target.olderThan}`
: undefined
if (where === null) return { deleted: 0, ids: [] }
const selected =
(where === undefined
? db.select({ id: history.id, audioLocalPath: history.audioLocalPath }).from(history).all()
: db.select({ id: history.id, audioLocalPath: history.audioLocalPath }).from(history).where(where).all()) ?? []
const selectedIds = selected.map((r) => r.id)
if (where === undefined) {
db.delete(memoTags).run()
} else if (selectedIds.length > 0) {
db.delete(memoTags).where(inArray(memoTags.historyId, selectedIds)).run()
}
const result = where === undefined ? db.delete(history).run() : db.delete(history).where(where).run()
const deleted = result?.changes ?? 0
const audioPaths = selected.map((r) => r.audioLocalPath)
if (deleted > 0 && audioPaths.some((p) => p)) {
void removeRecordingFiles(audioPaths)
}
return { deleted, ids: selectedIds }
}

View file

@ -0,0 +1,31 @@
// src/main/services/history-stats.ts
// 대시보드 통계 정책(순수 함수). 누적 값은 저장된 카운터가 아니라 history 테이블에서 계산한다 —
// 동기화·로컬 모드 가져오기·삭제로 들어오고 나간 행도 그대로 반영되도록.
/** 로컬 날짜 키(YYYY-MM-DD). SQLite date(created_at/1000,'unixepoch','localtime') 와 같은 형식. */
export function localDayKey(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/**
* 연속 사용일. 오늘(또는 오늘 아직 기록이 없으면 어제)부터 거꾸로 하루도 빠짐없이 기록이 있는 날 수.
* @param dayKeys 기록이 있는 로컬 날짜 키들(중복·순서 무관)
*/
export function computeStreakDays(dayKeys: Iterable<string>, today: Date = new Date()): number {
const days = new Set(dayKeys)
if (days.size === 0) return 0
const cursor = new Date(today.getFullYear(), today.getMonth(), today.getDate())
if (!days.has(localDayKey(cursor))) {
cursor.setDate(cursor.getDate() - 1)
if (!days.has(localDayKey(cursor))) return 0
}
let streak = 0
while (days.has(localDayKey(cursor))) {
streak++
cursor.setDate(cursor.getDate() - 1)
}
return streak
}

View file

@ -5,27 +5,26 @@
// - 로컬 변경은 outbox에 쌓였다가 push된다(오프라인·재시작에도 유실 없음). // - 로컬 변경은 outbox에 쌓였다가 push된다(오프라인·재시작에도 유실 없음).
// - pull은 서버 시각(updated_at) keyset 커서로 가져온다. 로컬 시계를 쓰지 않는다. // - pull은 서버 시각(updated_at) keyset 커서로 가져온다. 로컬 시계를 쓰지 않는다.
// - 아직 올라가지 않은 로컬 변경이 있는 행은 pull이 덮지 않는다. // - 아직 올라가지 않은 로컬 변경이 있는 행은 pull이 덮지 않는다.
// - 삭제는 sync_tombstones로 양방향 전파된다. // - 삭제는 sync_tombstones로 양방향 전파된다. 원격 삭제는 push보다 먼저 반영한다(좀비 행 방지).
// - 커서·플래그는 사용자 DB 안(sync_state)에 있어 계정별로 분리된다. // - 커서·플래그는 사용자 DB 안(sync_state)에 있어 계정별로 분리된다.
// - 엔진은 만들어질 때 열린 사용자 DB에 묶인다. 해제되거나 DB가 바뀌면 await 뒤마다 멈추고 아무것도 쓰지 않는다.
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { getLogger } from '../LoggerService' import { getLogger } from '../LoggerService'
import { TABLE_ADAPTERS, adapterFor, type PushContext, type SyncAdapter } from './sync-adapters' import { getCurrentUserId } from '../../db'
import { import {
fetchRemoteMemoTagKeys, SyncAbortedError,
listLocalMemoTagKeys, TABLE_ADAPTERS,
pushMemoTagAdds, adapterFor,
pushMemoTagRemovals, type ApplyResult,
reconcileMemoTags, type PushContext,
} from './memo-tag-sync' type SyncAdapter,
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync' } from './sync-adapters'
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync' import { fetchRemoteMemoTagKeys, listLocalMemoTagKeys, reconcileMemoTags } from './memo-tag-sync'
import { import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners } from './audio-sync'
applyRemoteBuiltins, import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings } from './settings-sync'
editedBuiltinsDifferingFrom, import { applyRemoteBuiltins, editedBuiltinsDifferingFrom, fetchRemoteBuiltins } from './builtin-instruction-sync'
fetchRemoteBuiltins, import { deleteOrder, upsertOrder } from './sync-registry'
pushBuiltinPrompts,
} from './builtin-instruction-sync'
import { import {
completeEntry, completeEntry,
dropEntry, dropEntry,
@ -42,6 +41,7 @@ import {
import { import {
emptyRunResult, emptyRunResult,
isSyncEntity, isSyncEntity,
parentOf,
type PushOutcome, type PushOutcome,
type RemoteCursor, type RemoteCursor,
type RemoteRow, type RemoteRow,
@ -62,6 +62,12 @@ const BACKFILL_FLAG = 'backfill:v1'
/** 되감은 커서의 id 하한. 행 테이블은 uuid, sync_tombstones는 bigserial이다. */ /** 되감은 커서의 id 하한. 행 테이블은 uuid, sync_tombstones는 bigserial이다. */
const MIN_UUID = '00000000-0000-0000-0000-000000000000' const MIN_UUID = '00000000-0000-0000-0000-000000000000'
const MIN_SERIAL = '0' const MIN_SERIAL = '0'
/** 반영을 미룬 행(부모 없음·일시 오류)을 다시 가져와 보는 최대 횟수. 넘으면 포기한다(남의 팀 회의 메모 등). */
export const MAX_DEFERRED_ATTEMPTS = 5
/** 엔티티마다 기억하는 미룬 행 수 상한 */
const MAX_DEFERRED_IDS = 500
/** 한 번의 pull에서 다시 가져와 보는 미룬 행 수 */
const DEFERRED_RETRY_BATCH = 50
export interface SyncEngineOptions { export interface SyncEngineOptions {
remote: SyncRemote remote: SyncRemote
@ -83,6 +89,10 @@ function cursorKey(entity: SyncEntity | 'tombstones'): string {
return `cursor:${entity}` return `cursor:${entity}`
} }
function deferredKey(entity: SyncEntity): string {
return `deferred:${entity}`
}
function parseCursor(value: string | null): RemoteCursor | null { function parseCursor(value: string | null): RemoteCursor | null {
if (!value) return null if (!value) return null
try { try {
@ -93,6 +103,20 @@ function parseCursor(value: string | null): RemoteCursor | null {
} }
} }
function parseDeferred(value: string | null): Map<string, number> {
if (!value) return new Map()
try {
const parsed = JSON.parse(value) as unknown
if (typeof parsed !== 'object' || parsed === null) return new Map()
const entries = Object.entries(parsed as Record<string, unknown>).filter(
(e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1])
)
return new Map(entries)
} catch {
return new Map()
}
}
/** 커서를 CURSOR_OVERLAP_MS만큼 되감은 시작점. */ /** 커서를 CURSOR_OVERLAP_MS만큼 되감은 시작점. */
function rewind(cursor: RemoteCursor | null, minId: string): RemoteCursor | null { function rewind(cursor: RemoteCursor | null, minId: string): RemoteCursor | null {
if (!cursor) return null if (!cursor) return null
@ -127,10 +151,16 @@ function rowCursor(row: RemoteRow, column: string): RemoteCursor | null {
return { ts, id: String(id) } return { ts, id: String(id) }
} }
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
export class SyncEngine extends EventEmitter { export class SyncEngine extends EventEmitter {
private readonly remote: SyncRemote private readonly remote: SyncRemote
private readonly userId: string private readonly userId: string
private readonly now: () => number private readonly now: () => number
/** 엔진이 만들어질 때 열려 있던 사용자 DB. 다른 DB가 열리면 쓰지 않는다. */
private readonly boundDbUserId: string | null
/** flush/pull/backfill을 한 줄로 세운다 — 서로 끼어들면 pending 판정이 흔들린다. */ /** flush/pull/backfill을 한 줄로 세운다 — 서로 끼어들면 pending 판정이 흔들린다. */
private queue: Promise<unknown> = Promise.resolve() private queue: Promise<unknown> = Promise.resolve()
private disposed = false private disposed = false
@ -140,6 +170,7 @@ export class SyncEngine extends EventEmitter {
this.remote = options.remote this.remote = options.remote
this.userId = options.userId this.userId = options.userId
this.now = options.now ?? Date.now this.now = options.now ?? Date.now
this.boundDbUserId = getCurrentUserId()
} }
dispose(): void { dispose(): void {
@ -147,18 +178,37 @@ export class SyncEngine extends EventEmitter {
this.removeAllListeners() this.removeAllListeners()
} }
/** 해제되지 않았고, 이 엔진의 사용자 DB가 아직 열려 있는지. */
isCurrent(): boolean {
return !this.disposed && getCurrentUserId() === this.boundDbUserId
}
/** 진행 중·대기 중인 작업이 끝날 때까지(최대 timeoutMs) 기다린다. 끝났으면 true. */
async whenIdle(timeoutMs: number): Promise<boolean> {
let timer: NodeJS.Timeout | null = null
const timeout = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs)
})
try {
return await Promise.race([this.queue.then(() => true), timeout])
} finally {
if (timer) clearTimeout(timer)
}
}
getStatus(): SyncStatus { getStatus(): SyncStatus {
const counts = outboxCounts() const counts = outboxCounts()
const lastPull = getSyncState('lastPullAt') const lastPull = getSyncState('lastPullAt')
return { ...counts, lastPullAt: lastPull ? Number(lastPull) : null } return { ...counts, lastPullAt: lastPull ? Number(lastPull) : null }
} }
/** 로그인 직후/복원 직후: 최초 1회 대조 → push → pull. */ /** 로그인 직후/복원 직후: 원격 삭제 반영 → 최초 1회 대조 → push → pull. */
runFullSync(): Promise<SyncRunResult> { runFullSync(): Promise<SyncRunResult> {
return this.serialize(async () => { return this.serialize(async () => {
const result = emptyRunResult() const result = emptyRunResult()
await this.pullTombstonesBeforePush(result, true)
await this.backfillIfNeeded() await this.backfillIfNeeded()
await this.flushInner(result) await this.flushInner(result, true)
await this.pullInner(result) await this.pullInner(result)
return result return result
}) })
@ -168,8 +218,10 @@ export class SyncEngine extends EventEmitter {
return this.serialize(async () => { return this.serialize(async () => {
if (options.releaseParked) releaseParkedEntries() if (options.releaseParked) releaseParkedEntries()
const result = emptyRunResult() const result = emptyRunResult()
const backfillPending = getSyncState(BACKFILL_FLAG) !== 'done'
if (backfillPending) await this.pullTombstonesBeforePush(result, true)
await this.backfillIfNeeded() await this.backfillIfNeeded()
await this.flushInner(result) await this.flushInner(result, backfillPending)
return result return result
}) })
} }
@ -183,72 +235,80 @@ export class SyncEngine extends EventEmitter {
} }
private serialize<T>(task: () => Promise<T>): Promise<T> { private serialize<T>(task: () => Promise<T>): Promise<T> {
const run = this.queue.then( const guarded = (): Promise<T> => {
() => task(), // 해제 뒤에 줄을 선 작업은 시작하지 않는다(로그아웃 뒤 로컬 모드 DB에 쓰지 않게).
() => task() if (!this.isCurrent()) return Promise.reject(new SyncAbortedError())
) return task()
}
const run = this.queue.then(guarded, guarded)
this.queue = run.catch(() => undefined) this.queue = run.catch(() => undefined)
return run return run
} }
/** await 뒤마다 부른다: 해제됐거나 사용자 DB가 바뀌었으면 중단한다. */
private checkpoint(): void {
if (!this.isCurrent()) throw new SyncAbortedError()
}
private context(): PushContext {
return { remote: this.remote, userId: this.userId, isCurrent: () => this.isCurrent() }
}
// ── 최초 대조 ───────────────────────────────────────── // ── 최초 대조 ─────────────────────────────────────────
/** /**
* 이 사용자 DB에서 처음 동기화할 때 한 번: 서버에 없거나 로컬이 더 새로운 행을 outbox에 넣는다. * 이 사용자 DB에서 처음 동기화할 때 한 번: 서버에 없거나 로컬이 더 새로운 행을 outbox에 넣는다.
* 서버에 같은 판이 이미 있으면 다시 올리지 않는다(다른 기기의 편집을 덮지 않는다). * 서버에 같은 판이 이미 있으면 다시 올리지 않는다(다른 기기의 편집을 덮지 않는다).
* 호출 전에 원격 삭제(tombstone)가 반영돼 있어야 한다 — 지운 행을 되살리지 않도록.
*/ */
private async backfillIfNeeded(): Promise<void> { private async backfillIfNeeded(): Promise<void> {
if (getSyncState(BACKFILL_FLAG) === 'done') return if (getSyncState(BACKFILL_FLAG) === 'done') return
const now = this.now() const now = this.now()
let queued = 0 const ctx = this.context()
const queue: Array<{ entity: SyncEntity; id: string }> = []
for (const adapter of TABLE_ADAPTERS) { for (const adapter of TABLE_ADAPTERS) {
const local = adapter.listLocalVersions() const local = adapter.listLocalVersions()
if (local.length === 0) continue if (local.length === 0) continue
const remoteVersions = await this.fetchRemoteVersions(adapter) const remoteVersions = await this.fetchRemoteVersions(adapter)
this.checkpoint()
for (const row of local) { for (const row of local) {
const remoteUpdatedAt = remoteVersions.get(row.id) const remoteUpdatedAt = remoteVersions.get(row.id)
if (remoteUpdatedAt === undefined || row.updatedAt > remoteUpdatedAt) { if (remoteUpdatedAt === undefined || row.updatedAt > remoteUpdatedAt) {
enqueueChange(adapter.entity, row.id, 'upsert', now) queue.push({ entity: adapter.entity, id: row.id })
queued++
} }
} }
} }
const localTags = listLocalMemoTagKeys() const localTags = listLocalMemoTagKeys()
if (localTags.size > 0) { if (localTags.size > 0) {
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId) const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
this.checkpoint()
for (const key of localTags) { for (const key of localTags) {
if (!remoteTags.has(key)) { if (!remoteTags.has(key)) queue.push({ entity: 'memo_tags', id: key })
enqueueChange('memo_tags', key, 'upsert', now)
queued++
}
} }
} }
const ctx: PushContext = { remote: this.remote, userId: this.userId }
// 설정: 서버에 행이 없을 때만 로컬 값을 올린다. 있으면 다른 기기의 선택이 우선(pull이 반영). // 설정: 서버에 행이 없을 때만 로컬 값을 올린다. 있으면 다른 기기의 선택이 우선(pull이 반영).
if (!(await fetchRemoteSettings(ctx))) { const remoteSettings = await fetchRemoteSettings(ctx)
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now) this.checkpoint()
queued++ if (!remoteSettings) queue.push({ entity: 'user_settings', id: SETTINGS_ROW_ID })
}
// 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다). // 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다).
for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) { const remoteBuiltins = await fetchRemoteBuiltins(ctx)
enqueueChange('builtin_instructions', id, 'upsert', now) this.checkpoint()
queued++ for (const id of editedBuiltinsDifferingFrom(remoteBuiltins)) queue.push({ entity: 'builtin_instructions', id })
}
if (isAudioSyncEnabled()) { if (isAudioSyncEnabled()) {
for (const owner of ['history', 'meeting'] as const) { for (const owner of ['history', 'meeting'] as const) {
const local = listLocalAudioOwners(owner) const local = listLocalAudioOwners(owner)
if (local.length === 0) continue if (local.length === 0) continue
const remote = await fetchRemoteAudioOwners(this.remote, this.userId, owner) const remote = await fetchRemoteAudioOwners(this.remote, this.userId, owner)
this.checkpoint()
for (const id of local) { for (const id of local) {
if (!remote.has(id)) { if (!remote.has(id)) queue.push({ entity: owner === 'history' ? 'history_audio' : 'meeting_audio', id })
enqueueChange(owner === 'history' ? 'history_audio' : 'meeting_audio', id, 'upsert', now)
queued++
}
} }
} }
} }
this.checkpoint()
for (const item of queue) enqueueChange(item.entity, item.id, 'upsert', now)
setSyncState(BACKFILL_FLAG, 'done', now) setSyncState(BACKFILL_FLAG, 'done', now)
logger.info(`Backfill queued ${queued} local change(s) for ${this.userId}`) logger.info(`Backfill queued ${queue.length} local change(s) for ${this.userId}`)
} }
private async fetchRemoteVersions(adapter: SyncAdapter): Promise<Map<string, number>> { private async fetchRemoteVersions(adapter: SyncAdapter): Promise<Map<string, number>> {
@ -264,6 +324,7 @@ export class SyncEngine extends EventEmitter {
columns: 'id,updated_at', columns: 'id,updated_at',
filters: adapter.pull?.filters, filters: adapter.pull?.filters,
}) })
this.checkpoint()
for (const row of page) { for (const row of page) {
if (typeof row.id === 'string' && typeof row.updated_at === 'string') { if (typeof row.id === 'string' && typeof row.updated_at === 'string') {
const at = Date.parse(row.updated_at) const at = Date.parse(row.updated_at)
@ -280,10 +341,29 @@ export class SyncEngine extends EventEmitter {
// ── push ────────────────────────────────────────────── // ── push ──────────────────────────────────────────────
private async flushInner(result: SyncRunResult): Promise<void> { /**
* push 전에 원격 삭제를 반영한다: 폰에서 지운 행의 로컬 대기 upsert가 서버에 행을 되살리지 않게
* outbox 항목을 버리고 로컬 행을 지운다. always=false 면 대기 upsert가 있을 때만 가져온다.
*/
private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise<void> {
if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return
const changed = new Set<SyncEntity>(result.changed)
try {
result.deleted += await this.pullTombstones(changed)
} catch (err) {
if (err instanceof SyncAbortedError) throw err
const message = errorMessage(err)
result.errors.push(`tombstones: ${message}`)
logger.warn(`Pull tombstones before push failed: ${message}`)
}
result.changed = [...changed]
}
private async flushInner(result: SyncRunResult, tombstonesPulled: boolean): Promise<void> {
if (!tombstonesPulled) await this.pullTombstonesBeforePush(result, false)
const due = listDueEntries(this.now()) const due = listDueEntries(this.now())
if (due.length === 0) return if (due.length === 0) return
const ctx: PushContext = { remote: this.remote, userId: this.userId } const ctx = this.context()
const byEntity = new Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>() const byEntity = new Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>()
for (const entry of due) { for (const entry of due) {
if (!isSyncEntity(entry.entity)) continue if (!isSyncEntity(entry.entity)) continue
@ -292,60 +372,22 @@ export class SyncEngine extends EventEmitter {
byEntity.set(entry.entity, bucket) byEntity.set(entry.entity, bucket)
} }
const order: SyncEntity[] = [...TABLE_ADAPTERS.map((a) => a.entity), 'memo_tags']
// 부모 먼저 upsert: history → … → memo_tags(이력 필요)
const upsertOrder: SyncEntity[] = [
'history',
'dictionary',
'meetings',
'meeting_memos',
'meeting_documents',
'custom_instructions',
'builtin_instructions',
'user_settings',
'user_templates',
'knowledge_documents',
'memo_tags',
'history_audio',
'meeting_audio',
]
let networkDown = false let networkDown = false
for (const entity of upsertOrder) { // 부모 먼저 upsert (순서는 sync-types SYNC_ENTITY_SPECS 가 정본)
const bucket = byEntity.get(entity) for (const handler of upsertOrder()) {
const bucket = byEntity.get(handler.entity)
if (!bucket || bucket.upserts.length === 0 || networkDown) continue if (!bucket || bucket.upserts.length === 0 || networkDown) continue
const ids = bucket.upserts.map((e) => e.rowId) const outcomes = await handler.push(ctx, bucket.upserts.map((e) => e.rowId))
const outcomes = await this.pushUpserts(ctx, entity, ids) this.checkpoint()
networkDown = this.settle(bucket.upserts, outcomes, result, entity) networkDown = this.settle(bucket.upserts, outcomes, result, handler.entity)
} }
// 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다) // 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다)
for (const entity of [...order].reverse()) { for (const handler of deleteOrder()) {
const bucket = byEntity.get(entity) const bucket = byEntity.get(handler.entity)
if (!bucket || bucket.deletes.length === 0 || networkDown) continue if (!bucket || bucket.deletes.length === 0 || networkDown || !handler.pushDeletes) continue
const ids = bucket.deletes.map((e) => e.rowId) const outcomes = await handler.pushDeletes(ctx, bucket.deletes.map((e) => e.rowId))
const outcomes = this.checkpoint()
entity === 'memo_tags' networkDown = this.settle(bucket.deletes, outcomes, result, handler.entity)
? await pushMemoTagRemovals(ctx, ids)
: adapterFor(entity)
? await this.requireAdapter(entity).pushDeletes(ctx, ids)
: ids.map((id) => ({ id, error: null }))
networkDown = this.settle(bucket.deletes, outcomes, result, entity)
}
}
private async pushUpserts(ctx: PushContext, entity: SyncEntity, ids: string[]): Promise<PushOutcome[]> {
switch (entity) {
case 'memo_tags':
return pushMemoTagAdds(ctx, ids)
case 'user_settings':
return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }]
case 'builtin_instructions':
return pushBuiltinPrompts(ctx, ids)
case 'history_audio':
return pushAudio(ctx, 'history', ids)
case 'meeting_audio':
return pushAudio(ctx, 'meeting', ids)
default:
return this.requireAdapter(entity).push(ctx, ids)
} }
} }
@ -371,24 +413,29 @@ export class SyncEngine extends EventEmitter {
return networkDown return networkDown
} }
private requireAdapter(entity: SyncEntity): SyncAdapter {
const adapter = adapterFor(entity)
if (!adapter) throw new Error(`No sync adapter for ${entity}`)
return adapter
}
// ── pull ────────────────────────────────────────────── // ── pull ──────────────────────────────────────────────
private async pullInner(result: SyncRunResult): Promise<void> { private async pullInner(result: SyncRunResult): Promise<void> {
const changed = new Set<SyncEntity>(result.changed) const changed = new Set<SyncEntity>(result.changed)
/** 이번 pull에서 실패한 엔티티 — 자식은 커서를 넘기지 않도록 건너뛴다. */
const failed = new Set<SyncEntity>()
for (const adapter of TABLE_ADAPTERS) { for (const adapter of TABLE_ADAPTERS) {
if (this.disposed || !adapter.pull) continue this.checkpoint()
if (!adapter.pull) continue
const parent = parentOf(adapter.entity)
if (parent && failed.has(parent)) {
failed.add(adapter.entity)
logger.info(`Pull ${adapter.entity} skipped: parent ${parent} failed in this run`)
continue
}
try { try {
const applied = await this.pullEntity(adapter) const applied = await this.pullEntity(adapter)
if (applied > 0) changed.add(adapter.entity) if (applied > 0) changed.add(adapter.entity)
result.pulled += applied result.pulled += applied
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) if (err instanceof SyncAbortedError) throw err
failed.add(adapter.entity)
const message = errorMessage(err)
result.errors.push(`${adapter.entity}: ${message}`) result.errors.push(`${adapter.entity}: ${message}`)
logger.warn(`Pull ${adapter.entity} failed: ${message}`) logger.warn(`Pull ${adapter.entity} failed: ${message}`)
} }
@ -398,60 +445,85 @@ export class SyncEngine extends EventEmitter {
const removed = await this.pullTombstones(changed) const removed = await this.pullTombstones(changed)
result.deleted += removed result.deleted += removed
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) if (err instanceof SyncAbortedError) throw err
result.errors.push(`tombstones: ${message}`) const message = errorMessage(err)
if (!result.errors.includes(`tombstones: ${message}`)) result.errors.push(`tombstones: ${message}`)
logger.warn(`Pull tombstones failed: ${message}`) logger.warn(`Pull tombstones failed: ${message}`)
} }
try { if (!failed.has('history')) {
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId) try {
const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now()) const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
if (tagChanges > 0) { this.checkpoint()
changed.add('memo_tags') const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now())
result.pulled += tagChanges if (tagChanges > 0) {
changed.add('memo_tags')
result.pulled += tagChanges
}
} catch (err) {
if (err instanceof SyncAbortedError) throw err
const message = errorMessage(err)
result.errors.push(`memo_tags: ${message}`)
logger.warn(`Reconcile memo_tags failed: ${message}`)
} }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(`memo_tags: ${message}`)
logger.warn(`Reconcile memo_tags failed: ${message}`)
} }
try { try {
if (!pendingOps('user_settings').has(SETTINGS_ROW_ID)) { if (!pendingOps('user_settings').has(SETTINGS_ROW_ID)) {
const row = await fetchRemoteSettings({ remote: this.remote, userId: this.userId }) const row = await fetchRemoteSettings(this.context())
this.checkpoint()
if (row && applyRemoteSettings(row)) { if (row && applyRemoteSettings(row)) {
changed.add('user_settings') changed.add('user_settings')
result.pulled++ result.pulled++
} }
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) if (err instanceof SyncAbortedError) throw err
const message = errorMessage(err)
result.errors.push(`user_settings: ${message}`) result.errors.push(`user_settings: ${message}`)
logger.warn(`Pull user_settings failed: ${message}`) logger.warn(`Pull user_settings failed: ${message}`)
} }
try { try {
const builtins = await fetchRemoteBuiltins({ remote: this.remote, userId: this.userId }) const builtins = await fetchRemoteBuiltins(this.context())
this.checkpoint()
const applied = applyRemoteBuiltins(builtins, new Set(pendingOps('builtin_instructions').keys())) const applied = applyRemoteBuiltins(builtins, new Set(pendingOps('builtin_instructions').keys()))
if (applied > 0) { if (applied > 0) {
changed.add('custom_instructions') changed.add('custom_instructions')
result.pulled += applied result.pulled += applied
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) if (err instanceof SyncAbortedError) throw err
const message = errorMessage(err)
result.errors.push(`builtin_instructions: ${message}`) result.errors.push(`builtin_instructions: ${message}`)
logger.warn(`Pull builtin_instructions failed: ${message}`) logger.warn(`Pull builtin_instructions failed: ${message}`)
} }
this.checkpoint()
result.changed = [...changed] result.changed = [...changed]
if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now())) if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now()))
} }
/** 행 하나 반영. 'deferred'/예외면 false를 돌려주고 호출자가 미룬 목록에 넣는다. */
private async applyOne(adapter: SyncAdapter, row: RemoteRow, id: string): Promise<ApplyResult> {
this.checkpoint()
try {
return await adapter.applyRemote(row, this.context())
} catch (err) {
if (err instanceof SyncAbortedError) throw err
logger.warn(`Apply ${adapter.entity}/${id} failed: ${errorMessage(err)}`)
return 'deferred'
}
}
private async pullEntity(adapter: SyncAdapter): Promise<number> { private async pullEntity(adapter: SyncAdapter): Promise<number> {
const deferred = parseDeferred(getSyncState(deferredKey(adapter.entity)))
const deferredBefore = JSON.stringify(Object.fromEntries(deferred))
let applied = await this.retryDeferred(adapter, deferred)
const saved = parseCursor(getSyncState(cursorKey(adapter.entity))) const saved = parseCursor(getSyncState(cursorKey(adapter.entity)))
let after = rewind(saved, MIN_UUID) let after = rewind(saved, MIN_UUID)
let newest = saved let newest = saved
let applied = 0
let seen = 0 let seen = 0
for (;;) { for (;;) {
const page = await this.remote.fetchPage({ const page = await this.remote.fetchPage({
@ -463,6 +535,7 @@ export class SyncEngine extends EventEmitter {
columns: adapter.pull?.columns, columns: adapter.pull?.columns,
filters: adapter.pull?.filters, filters: adapter.pull?.filters,
}) })
this.checkpoint()
if (page.length === 0) break if (page.length === 0) break
// 페이지마다 새로 읽는다: 페이지 사이에 사용자가 편집했을 수 있다. // 페이지마다 새로 읽는다: 페이지 사이에 사용자가 편집했을 수 있다.
const pending = pendingOps(adapter.entity) const pending = pendingOps(adapter.entity)
@ -470,12 +543,13 @@ export class SyncEngine extends EventEmitter {
seen++ seen++
const id = typeof row.id === 'string' ? row.id : null const id = typeof row.id === 'string' ? row.id : null
if (id && !pending.has(id)) { if (id && !pending.has(id)) {
try { const outcome = await this.applyOne(adapter, row, id)
if (await adapter.applyRemote(row, { remote: this.remote, userId: this.userId })) applied++ if (outcome === 'deferred') {
} catch (err) { // 커서는 넘기되 id를 기억해 다음 pull에서 다시 가져온다(부모 회의·청크가 늦게 오는 경우).
logger.warn( deferred.set(id, deferred.get(id) ?? 0)
`Apply ${adapter.entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}` } else {
) deferred.delete(id)
if (outcome) applied++
} }
} }
const cursor = rowCursor(row, 'updated_at') const cursor = rowCursor(row, 'updated_at')
@ -487,10 +561,68 @@ export class SyncEngine extends EventEmitter {
if (!next) break if (!next) break
after = next after = next
} }
this.checkpoint()
this.saveDeferred(adapter.entity, deferred, deferredBefore)
if (newest && newest !== saved) setSyncState(cursorKey(adapter.entity), JSON.stringify(newest)) if (newest && newest !== saved) setSyncState(cursorKey(adapter.entity), JSON.stringify(newest))
return applied return applied
} }
/** 지난 pull에서 미룬 행을 id로 다시 가져와 반영해 본다. 반영한 행 수를 돌려준다. */
private async retryDeferred(adapter: SyncAdapter, deferred: Map<string, number>): Promise<number> {
if (deferred.size === 0) return 0
let applied = 0
const pending = pendingOps(adapter.entity)
const batch = [...deferred.keys()].slice(0, DEFERRED_RETRY_BATCH)
for (const id of batch) {
if (pending.has(id)) {
deferred.delete(id)
continue
}
const attempts = (deferred.get(id) ?? 0) + 1
let rows: RemoteRow[]
try {
rows = await this.remote.selectWhere(
adapter.entity,
this.userId,
[{ column: 'id', op: 'eq', value: id }, ...(adapter.pull?.filters ?? [])],
adapter.pull?.columns
)
} catch (err) {
this.checkpoint()
logger.warn(`Retry ${adapter.entity}/${id} fetch failed: ${errorMessage(err)}`)
if (attempts >= MAX_DEFERRED_ATTEMPTS) deferred.delete(id)
else deferred.set(id, attempts)
continue
}
this.checkpoint()
const row = rows.find((r) => r.id === id)
if (!row) {
// 서버에서 지워졌거나 더는 내 행이 아니다.
deferred.delete(id)
continue
}
const outcome = await this.applyOne(adapter, row, id)
if (outcome === 'deferred') {
if (attempts >= MAX_DEFERRED_ATTEMPTS) {
deferred.delete(id)
logger.info(`Giving up on deferred ${adapter.entity}/${id} after ${attempts} attempt(s)`)
} else {
deferred.set(id, attempts)
}
continue
}
deferred.delete(id)
if (outcome) applied++
}
return applied
}
private saveDeferred(entity: SyncEntity, deferred: Map<string, number>, before: string): void {
const trimmed = [...deferred.entries()].slice(-MAX_DEFERRED_IDS)
const next = JSON.stringify(Object.fromEntries(trimmed))
if (next !== before) setSyncState(deferredKey(entity), next, this.now())
}
/** 다른 기기에서 지운 행을 로컬에서도 지운다. 원격 삭제는 로컬 미전송 변경보다 우선한다. */ /** 다른 기기에서 지운 행을 로컬에서도 지운다. 원격 삭제는 로컬 미전송 변경보다 우선한다. */
private async pullTombstones(changed: Set<SyncEntity>): Promise<number> { private async pullTombstones(changed: Set<SyncEntity>): Promise<number> {
const saved = parseCursor(getSyncState(cursorKey('tombstones'))) const saved = parseCursor(getSyncState(cursorKey('tombstones')))
@ -506,6 +638,7 @@ export class SyncEngine extends EventEmitter {
limit: PULL_PAGE_SIZE, limit: PULL_PAGE_SIZE,
columns: 'id,table_name,row_id,deleted_at', columns: 'id,table_name,row_id,deleted_at',
}) })
this.checkpoint()
if (page.length === 0) break if (page.length === 0) break
for (const row of page) { for (const row of page) {
const entity = row.table_name const entity = row.table_name
@ -521,7 +654,7 @@ export class SyncEngine extends EventEmitter {
changed.add(entity) changed.add(entity)
} }
} catch (err) { } catch (err) {
logger.warn(`Tombstone ${entity}/${rowId} failed: ${err instanceof Error ? err.message : String(err)}`) logger.warn(`Tombstone ${entity}/${rowId} failed: ${errorMessage(err)}`)
} }
} }
} }

View file

@ -0,0 +1,101 @@
// src/main/services/sync/session-token-store.ts
// refresh token 영속화(OS 암호화 저장소 + 파일). Supabase·Electron을 직접 알지 않도록 포트로 받는다.
//
// supabase-js는 메인 프로세스에서 refresh token을 약 1시간마다 회전시킨다. 회전된 토큰을 저장하지 않으면
// 재시작 때 이미 쓰인 토큰으로 복원하다 로그아웃된다 — bindAuthEvents 가 회전 이벤트마다 저장한다.
export interface TokenCipher {
isEncryptionAvailable(): boolean
encryptString(plain: string): Buffer
decryptString(encrypted: Buffer): string
}
export interface TokenFileSystem {
existsSync(path: string): boolean
readFileSync(path: string): Buffer
writeFileSync(path: string, data: Buffer): void
unlinkSync(path: string): void
}
export interface SessionTokenStoreLogger {
warn(message: string): void
}
export interface SessionTokenStore {
load(): string | null
save(refreshToken: string): void
clear(): void
}
export class EncryptedFileTokenStore implements SessionTokenStore {
constructor(
private readonly filePath: string,
private readonly cipher: TokenCipher,
private readonly fs: TokenFileSystem,
private readonly logger: SessionTokenStoreLogger
) {}
load(): string | null {
try {
if (!this.fs.existsSync(this.filePath)) return null
if (!this.cipher.isEncryptionAvailable()) return null
return this.cipher.decryptString(this.fs.readFileSync(this.filePath))
} catch (err) {
this.logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
return null
}
}
save(refreshToken: string): void {
try {
if (!refreshToken) return
if (this.cipher.isEncryptionAvailable()) {
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(refreshToken))
}
} catch (err) {
this.logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
clear(): void {
try {
if (this.fs.existsSync(this.filePath)) this.fs.unlinkSync(this.filePath)
} catch {
// 지울 파일이 없거나 잠겨 있어도 로그아웃은 계속한다.
}
}
}
/** supabase auth 이벤트 중 세션 회전을 뜻하는 것 */
const ROTATION_EVENTS = new Set(['TOKEN_REFRESHED', 'SIGNED_IN', 'USER_UPDATED'])
export interface AuthSessionLike {
refresh_token: string
user: { id: string }
}
export type AuthStateSubscribe<S extends AuthSessionLike> = (
callback: (event: string, session: S | null) => void
) => { unsubscribe(): void }
/**
* auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다.
* - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시).
* - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다.
* onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다.
*/
export function bindAuthEvents<S extends AuthSessionLike>(
subscribe: AuthStateSubscribe<S>,
store: SessionTokenStore,
activeUserId: () => string | null,
onSession: (session: S) => void
): () => void {
const subscription = subscribe((event, session) => {
if (!session || !ROTATION_EVENTS.has(event)) return
const userId = activeUserId()
if (!userId || session.user.id !== userId) return
store.save(session.refresh_token)
onSession(session)
})
return () => subscription.unsubscribe()
}

View file

@ -9,14 +9,14 @@ import {
meetingDocuments, meetingDocuments,
meetingMemos, meetingMemos,
meetingSessions, meetingSessions,
memoTags,
ragDocuments, ragDocuments,
} from '../../db/schema' } from '../../db/schema'
import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types' import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types'
import { getCustomInstructionService } from '../CustomInstructionService' import { getCustomInstructionService, instructionLimitViolation } from '../CustomInstructionService'
import { getDictationTemplateService } from '../DictationTemplateService' import { getDictationTemplateService } from '../DictationTemplateService'
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService' import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
import { getRAGService } from '../RAGService' import { getRAGService } from '../RAGService'
import { deleteHistoryLocally } from '../history-deletion'
import { purgeRemoteAudio } from './audio-sync' import { purgeRemoteAudio } from './audio-sync'
import { pushMeetingSegments } from './transcript-sync' import { pushMeetingSegments } from './transcript-sync'
import { dropEntry } from './sync-outbox' import { dropEntry } from './sync-outbox'
@ -33,8 +33,31 @@ import {
export interface PushContext { export interface PushContext {
remote: SyncRemote remote: SyncRemote
userId: string userId: string
/**
* 이 작업이 아직 유효한지(엔진이 해제되지 않았고 같은 사용자 DB가 열려 있는지).
* 네트워크를 기다린 뒤 로컬에 쓰는 어댑터는 쓰기 직전에 확인한다.
*/
isCurrent?: () => boolean
} }
/** 엔진이 해제됐거나 사용자 DB가 바뀌어 작업을 멈춘다 — 로컬에 아무것도 쓰지 않는다. */
export class SyncAbortedError extends Error {
constructor() {
super('Sync aborted: engine disposed or user DB switched')
this.name = 'SyncAbortedError'
}
}
export function assertCurrent(ctx: PushContext): void {
if (ctx.isCurrent && !ctx.isCurrent()) throw new SyncAbortedError()
}
/**
* 원격 행 반영 결과. 'deferred' = 지금은 반영할 수 없지만(부모 회의가 아직 없음, 청크가 아직 없음)
* 나중에 다시 시도해야 한다 — 엔진이 id를 기억했다가 다음 pull에서 다시 가져온다.
*/
export type ApplyResult = boolean | 'deferred'
export interface LocalVersion { export interface LocalVersion {
id: string id: string
updatedAt: number updatedAt: number
@ -48,7 +71,7 @@ export interface SyncAdapter {
push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]> push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]> pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
/** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */ /** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */
applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise<boolean> applyRemote(row: RemoteRow, ctx: PushContext): ApplyResult | Promise<ApplyResult>
/** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */ /** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */
deleteLocal(id: string): boolean deleteLocal(id: string): boolean
} }
@ -192,7 +215,8 @@ export function historyToRemote(r: typeof history.$inferSelect, userId: string):
summary_text: r.summaryText, summary_text: r.summaryText,
is_favorite: r.isFavorite, is_favorite: r.isFavorite,
created_at: iso(r.createdAt), created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt), // updated_at 은 보내지 않는다: 서버 시각(INSERT 기본값·트리거)이 keyset 커서의 기준이다.
// 기기 시계·오프라인 편집 시각을 실으면 다른 기기의 커서 뒤로 들어가 영영 내려받히지 않는다.
} }
} }
@ -252,9 +276,8 @@ const historyAdapter: SyncAdapter = {
return true return true
}, },
deleteLocal(id) { deleteLocal(id) {
const db = getDatabase() // 녹음 WAV까지 함께 지운다(폰에서 지운 기록의 목소리가 디스크에 남지 않게).
db.delete(memoTags).where(eq(memoTags.historyId, id)).run() return deleteHistoryLocally({ ids: [id] }).deleted > 0
return db.delete(history).where(eq(history.id, id)).run().changes > 0
}, },
} }
@ -272,7 +295,6 @@ export function dictionaryToRemote(r: typeof dictionary.$inferSelect, userId: st
usage_count: Math.round(r.usageCount), usage_count: Math.round(r.usageCount),
last_used_at: nullableIso(r.lastUsedAt), last_used_at: nullableIso(r.lastUsedAt),
created_at: iso(r.createdAt), created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt),
} }
} }
@ -413,7 +435,6 @@ export function meetingToRemote(r: typeof meetingSessions.$inferSelect, userId:
llm_latency_ms: int(r.llmLatencyMs), llm_latency_ms: int(r.llmLatencyMs),
error_message: r.errorMessage, error_message: r.errorMessage,
created_at: iso(r.createdAt), created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt),
} }
} }
@ -508,7 +529,6 @@ export function meetingMemoToRemote(r: typeof meetingMemos.$inferSelect, userId:
content: r.content, content: r.content,
timestamp_ms: Math.round(r.timestampMs), timestamp_ms: Math.round(r.timestampMs),
created_at: iso(r.createdAt), created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt ?? r.createdAt),
} }
} }
@ -540,8 +560,10 @@ const meetingMemosAdapter: SyncAdapter = {
const id = row.id const id = row.id
const meetingId = row.meeting_id const meetingId = row.meeting_id
const content = str(row.content) const content = str(row.content)
// 남의 팀 회의에 내가 단 메모는 부모 회의가 로컬에 없다 — 고아 행을 만들지 않는다. if (!isUuid(id) || !isUuid(meetingId) || content === null) return false
if (!isUuid(id) || !isUuid(meetingId) || content === null || !hasLocalMeeting(meetingId)) return false // 부모 회의가 아직 로컬에 없으면 고아 행을 만들지 않고 미룬다(회의 pull이 실패했을 수 있다).
// 남의 팀 회의에 단 내 메모는 끝내 부모가 오지 않는다 — 엔진이 몇 번 재시도 후 포기한다.
if (!hasLocalMeeting(meetingId)) return 'deferred'
const createdAt = ms(row.created_at, Date.now()) const createdAt = ms(row.created_at, Date.now())
const values = { const values = {
sessionId: meetingId, sessionId: meetingId,
@ -577,7 +599,6 @@ export function meetingDocumentToRemote(r: typeof meetingDocuments.$inferSelect,
llm_model: r.llmModel, llm_model: r.llmModel,
llm_latency_ms: int(r.llmLatencyMs), llm_latency_ms: int(r.llmLatencyMs),
created_at: iso(r.createdAt), created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt),
} }
} }
@ -610,7 +631,7 @@ const meetingDocumentsAdapter: SyncAdapter = {
const templateType = oneOf(row.template_type, DOCUMENT_TYPES) const templateType = oneOf(row.template_type, DOCUMENT_TYPES)
const title = str(row.title) const title = str(row.title)
if (!isUuid(id) || !isUuid(meetingId) || templateType === null || title === null) return false if (!isUuid(id) || !isUuid(meetingId) || templateType === null || title === null) return false
if (!hasLocalMeeting(meetingId)) return false if (!hasLocalMeeting(meetingId)) return 'deferred'
const updatedAt = ms(row.updated_at, Date.now()) const updatedAt = ms(row.updated_at, Date.now())
const values = { const values = {
sessionId: meetingId, sessionId: meetingId,
@ -637,13 +658,24 @@ const meetingDocumentsAdapter: SyncAdapter = {
// ── custom_instructions ───────────────────────────────── // ── custom_instructions ─────────────────────────────────
// 데스크톱 프리셋(builtin-*)과 서버 프리셋(builtin_key)은 각자 따로 있다. 사용자가 만든 명령만 맞춘다. // 데스크톱 프리셋(builtin-*)과 서버 프리셋(builtin_key)은 각자 따로 있다. 사용자가 만든 명령만 맞춘다.
/**
* 서버 CHECK(이름 80·설명 240·프롬프트 4000자)를 넘는 명령은 잘라 보내지 않는다 — 잘린 사본이 다음 pull에서
* 원본을 덮는다. 재시도 불가 오류로 돌려 outbox가 보관(parked)하게 한다.
*/
export function instructionPushError(i: CustomInstruction): SyncRemoteError | null {
const field = instructionLimitViolation(i)
return field === null
? null
: new SyncRemoteError(`custom instruction ${field} exceeds the server length limit`, '22001', false)
}
export function instructionToRemote(i: CustomInstruction, userId: string): RemoteRow { export function instructionToRemote(i: CustomInstruction, userId: string): RemoteRow {
return { return {
id: i.id, id: i.id,
user_id: userId, user_id: userId,
name: i.name.trim().slice(0, 80), name: i.name.trim(),
description: (i.description ?? '').trim().slice(0, 240), description: (i.description ?? '').trim(),
prompt: i.prompt.trim().slice(0, 4000), prompt: i.prompt,
icon: (i.icon || 'sparkles').slice(0, 32), icon: (i.icon || 'sparkles').slice(0, 32),
sort_order: Math.max(0, Math.round(i.order)), sort_order: Math.max(0, Math.round(i.order)),
created_at: iso(i.createdAt), created_at: iso(i.createdAt),
@ -665,12 +697,19 @@ const customInstructionsAdapter: SyncAdapter = {
.map((id) => service.getById(id)) .map((id) => service.getById(id))
.filter((i): i is CustomInstruction => i !== null && !i.isBuiltin && isUuid(i.id)) .filter((i): i is CustomInstruction => i !== null && !i.isBuiltin && isUuid(i.id))
const found = new Set(items.map((i) => i.id)) const found = new Set(items.map((i) => i.id))
const tooLong: PushOutcome[] = []
const sendable: CustomInstruction[] = []
for (const item of items) {
const error = instructionPushError(item)
if (error) tooLong.push({ id: item.id, error })
else sendable.push(item)
}
const outcomes = await upsertIsolated( const outcomes = await upsertIsolated(
ctx, ctx,
'custom_instructions', 'custom_instructions',
items.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) })) sendable.map((i) => ({ id: i.id, row: instructionToRemote(i, ctx.userId) }))
) )
return [...outcomes, ...missingAsDone(ids, found)] return [...outcomes, ...tooLong, ...missingAsDone(ids, found)]
}, },
pushDeletes(ctx, ids) { pushDeletes(ctx, ids) {
return deleteRemote(ctx, 'custom_instructions', ids) return deleteRemote(ctx, 'custom_instructions', ids)
@ -767,6 +806,15 @@ export function templateToRpcParams(local: LocalTemplate): Record<string, unknow
} }
} }
/**
* sync_upsert_user_template_v1 의 P0002(template_not_found)는 "이 id의 템플릿이 다른 계정 소유"라는 뜻이다.
* 다시 보내도 같은 결과라 재시도 불가로 바꿔 outbox가 보관하게 한다(전역 P0002는 재시도 가능으로 남긴다).
*/
export function templateUpsertError(err: unknown): SyncRemoteError {
const error = toSyncRemoteError(err)
return error.code === 'P0002' ? new SyncRemoteError(error.message, error.code, false) : error
}
const userTemplatesAdapter: SyncAdapter = { const userTemplatesAdapter: SyncAdapter = {
entity: 'user_templates', entity: 'user_templates',
pull: { filters: [{ column: 'is_builtin', op: 'eq', value: false }] }, pull: { filters: [{ column: 'is_builtin', op: 'eq', value: false }] },
@ -789,7 +837,7 @@ const userTemplatesAdapter: SyncAdapter = {
await ctx.remote.rpc('sync_upsert_user_template_v1', templateToRpcParams(local)) await ctx.remote.rpc('sync_upsert_user_template_v1', templateToRpcParams(local))
outcomes.push({ id, error: null }) outcomes.push({ id, error: null })
} catch (err) { } catch (err) {
outcomes.push({ id, error: toSyncRemoteError(err) }) outcomes.push({ id, error: templateUpsertError(err) })
} }
} }
return outcomes return outcomes
@ -932,7 +980,11 @@ const knowledgeAdapter: SyncAdapter = {
const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get() const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get()
if (exists) return false if (exists) return false
const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index') const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index')
// 네트워크를 기다리는 사이 로그아웃·계정 전환이 있었으면 다른 사용자 DB에 쓰지 않는다.
assertCurrent(ctx)
const chunks = chunkRows.map((c) => str(c.content)).filter((c): c is string => c !== null) const chunks = chunkRows.map((c) => str(c.content)).filter((c): c is string => c !== null)
// 다른 기기가 문서 행을 올리고 청크를 아직 못 올렸을 수 있다 — 다음 pull에서 다시 본다.
if (chunks.length === 0) return 'deferred'
return getRAGService().applyRemoteDocument({ return getRAGService().applyRemoteDocument({
id, id,
fileName: str(row.file_name) ?? str(row.title) ?? 'document', fileName: str(row.file_name) ?? str(row.title) ?? 'document',

View file

@ -0,0 +1,69 @@
// src/main/services/sync/sync-registry.ts
// 엔티티별 push 핸들러 레지스트리. 순서·부모·Realtime 여부는 sync-types.ts 의 SYNC_ENTITY_SPECS 가 정본이다.
// 엔진은 이 레지스트리만 돌며 push/delete 한다 — 엔티티를 추가해도 엔진의 switch·목록을 고치지 않는다(OCP).
//
// sync-adapters 가 memo-tag-sync 등을 런타임 import 하므로, 순환을 피하려고 레지스트리는 별도 파일에 둔다.
import { adapterFor, type PushContext } from './sync-adapters'
import { pushMemoTagAdds, pushMemoTagRemovals } from './memo-tag-sync'
import { SETTINGS_ROW_ID, pushSettings } from './settings-sync'
import { pushBuiltinPrompts } from './builtin-instruction-sync'
import { pushAudio } from './audio-sync'
import { SYNC_ENTITY_SPECS, type PushOutcome, type SyncEntity, type SyncEntitySpec } from './sync-types'
export interface SyncPushHandler {
push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
/** 없으면 삭제를 서버로 보내지 않는다(삭제 대기 항목은 그대로 남는다). */
pushDeletes?(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
}
export type SyncRegistryEntry = SyncEntitySpec & SyncPushHandler
function tableHandler(entity: SyncEntity): SyncPushHandler {
return {
push: (ctx, ids) => requireAdapter(entity).push(ctx, ids),
pushDeletes: (ctx, ids) => requireAdapter(entity).pushDeletes(ctx, ids),
}
}
function requireAdapter(entity: SyncEntity): NonNullable<ReturnType<typeof adapterFor>> {
const adapter = adapterFor(entity)
if (!adapter) throw new Error(`No sync adapter for ${entity}`)
return adapter
}
/** Record 로 두어 엔티티가 빠지면 컴파일 오류가 난다. */
const HANDLERS: Record<SyncEntity, SyncPushHandler> = {
history: tableHandler('history'),
dictionary: tableHandler('dictionary'),
meetings: tableHandler('meetings'),
meeting_memos: tableHandler('meeting_memos'),
meeting_documents: tableHandler('meeting_documents'),
custom_instructions: tableHandler('custom_instructions'),
user_templates: tableHandler('user_templates'),
knowledge_documents: tableHandler('knowledge_documents'),
builtin_instructions: { push: (ctx, ids) => pushBuiltinPrompts(ctx, ids) },
user_settings: { push: async (ctx) => [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }] },
memo_tags: {
push: (ctx, ids) => pushMemoTagAdds(ctx, ids),
pushDeletes: (ctx, ids) => pushMemoTagRemovals(ctx, ids),
},
history_audio: { push: (ctx, ids) => pushAudio(ctx, 'history', ids) },
meeting_audio: { push: (ctx, ids) => pushAudio(ctx, 'meeting', ids) },
}
/** push 순서(부모 먼저)로 정렬된 레지스트리. */
export const SYNC_REGISTRY: readonly SyncRegistryEntry[] = SYNC_ENTITY_SPECS.map((spec) => ({
...(spec as SyncEntitySpec),
...HANDLERS[spec.entity],
}))
/** upsert 순서: 부모 → 자식 */
export function upsertOrder(): readonly SyncRegistryEntry[] {
return SYNC_REGISTRY
}
/** delete 순서: 자식 → 부모. 서버로 삭제를 보내는 엔티티만. */
export function deleteOrder(): readonly SyncRegistryEntry[] {
return [...SYNC_REGISTRY].reverse().filter((entry) => entry.pushDeletes !== undefined)
}

View file

@ -0,0 +1,65 @@
// src/main/services/sync/sync-scheduler.ts
// 동기화 트리거 타이머: 로컬 변경 push 디바운스, Realtime pull 디바운스, 주기 heartbeat.
// 무엇을 실행할지는 콜백으로 받는다 — 타이머만 따로 가짜 시계로 시험할 수 있다.
export type ScheduledRunKind = 'flush' | 'pull'
export interface SyncSchedulerOptions {
flushDelayMs: number
pullDelayMs: number
heartbeatMs: number
/** 디바운스가 끝나면 부른다 */
run: (kind: ScheduledRunKind) => void
/** heartbeat 주기마다 부른다(기기 점검·Realtime 되살리기·전체 동기화는 서비스가 정한다) */
onHeartbeat: () => void
}
export class SyncScheduler {
private flushTimer: ReturnType<typeof setTimeout> | null = null
private pullTimer: ReturnType<typeof setTimeout> | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
constructor(private readonly options: SyncSchedulerOptions) {}
/** 이미 예약돼 있으면 아무것도 하지 않는다(여러 변경을 한 번에 보낸다). */
scheduleFlush(delayMs = this.options.flushDelayMs): void {
if (this.flushTimer) return
this.flushTimer = setTimeout(() => {
this.flushTimer = null
this.options.run('flush')
}, delayMs)
}
schedulePull(delayMs = this.options.pullDelayMs): void {
if (this.pullTimer) return
this.pullTimer = setTimeout(() => {
this.pullTimer = null
this.options.run('pull')
}, delayMs)
}
startHeartbeat(): void {
this.stop()
this.heartbeatTimer = setInterval(() => this.options.onHeartbeat(), this.options.heartbeatMs)
}
/** heartbeat와 대기 중인 디바운스를 모두 멈춘다. */
stop(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.flushTimer) {
clearTimeout(this.flushTimer)
this.flushTimer = null
}
if (this.pullTimer) {
clearTimeout(this.pullTimer)
this.pullTimer = null
}
}
isHeartbeatRunning(): boolean {
return this.heartbeatTimer !== null
}
}

View file

@ -1,27 +1,54 @@
// src/main/services/sync/sync-types.ts // src/main/services/sync/sync-types.ts
// 기기 간 동기화 공통 타입. 데스크톱 SQLite ↔ Supabase(모바일·웹이 직접 읽고 쓰는 정본). // 기기 간 동기화 공통 타입. 데스크톱 SQLite ↔ Supabase(모바일·웹이 직접 읽고 쓰는 정본).
/** 동기화 대상. 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다. */ /**
export const SYNC_ENTITIES = [ * 동기화 대상 명세 — 엔티티 목록·push 순서·부모 관계·Realtime 감시 여부의 단일 정본(SSOT).
'history', * 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다.
'dictionary', *
'meetings', * 배열 순서 = push(upsert) 순서: 부모가 자식보다 먼저 온다. 삭제는 이 순서의 역순(자식 먼저).
'meeting_memos', * 새 엔티티는 여기 한 줄과 sync-registry.ts 의 핸들러 한 개만 추가한다(누락은 타입 오류).
'meeting_documents', */
'memo_tags', export const SYNC_ENTITY_SPECS = [
'custom_instructions', { entity: 'history', realtime: true },
'user_templates', { entity: 'dictionary', realtime: true },
'knowledge_documents', { entity: 'meetings', realtime: true },
/** 단일 행(row_id 'self'): 언어·테마·자동 다듬기·활성 명령 */ { entity: 'meeting_memos', realtime: true, parent: 'meetings' },
'user_settings', { entity: 'meeting_documents', realtime: true, parent: 'meetings' },
/** 녹음 파일 업로드(row_id = history id / meeting id) */ { entity: 'custom_instructions', realtime: true },
'history_audio',
'meeting_audio',
/** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */ /** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */
'builtin_instructions', { entity: 'builtin_instructions', realtime: false },
] as const /** 단일 행(row_id 'self'): 언어·테마·자동 다듬기·활성 명령 */
{ entity: 'user_settings', realtime: true },
{ entity: 'user_templates', realtime: true },
{ entity: 'knowledge_documents', realtime: true },
{ entity: 'memo_tags', realtime: true, parent: 'history' },
/** 녹음 파일 업로드(row_id = history id / meeting id) */
{ entity: 'history_audio', realtime: false, parent: 'history' },
{ entity: 'meeting_audio', realtime: false, parent: 'meetings' },
] as const satisfies ReadonlyArray<{ entity: string; realtime: boolean; parent?: string }>
export type SyncEntity = (typeof SYNC_ENTITIES)[number] export type SyncEntity = (typeof SYNC_ENTITY_SPECS)[number]['entity']
export interface SyncEntitySpec {
entity: SyncEntity
/** Realtime postgres_changes 로 감시하는 테이블인지 */
realtime: boolean
/** 먼저 반영돼야 하는 부모 엔티티 */
parent?: SyncEntity
}
export const SYNC_ENTITIES: readonly SyncEntity[] = SYNC_ENTITY_SPECS.map((s) => s.entity)
/** 엔티티의 부모. 없으면 null */
export function parentOf(entity: SyncEntity): SyncEntity | null {
const spec: SyncEntitySpec | undefined = SYNC_ENTITY_SPECS.find((s) => s.entity === entity)
return spec?.parent ?? null
}
/** Realtime 변경 감시 대상 테이블. sync_tombstones = 다른 기기의 삭제 */
export function realtimeTables(): string[] {
return [...SYNC_ENTITY_SPECS.filter((s) => s.realtime).map((s) => s.entity), 'sync_tombstones']
}
export type SyncOp = 'upsert' | 'delete' export type SyncOp = 'upsert' | 'delete'

View file

@ -1,7 +1,7 @@
// src/main/windows/TrayManager.ts // src/main/windows/TrayManager.ts
import { Tray, Menu, app, nativeImage } from 'electron' import { Tray, Menu, app, nativeImage } from 'electron'
import { getMainWindow } from './WindowManager' import { showMainWindow } from './WindowManager'
import { getLogger } from '../services/LoggerService' import { getLogger } from '../services/LoggerService'
import { getAppIconPath } from '../utils/paths' import { getAppIconPath } from '../utils/paths'
import { setIsQuitting } from '../lifecycle' import { setIsQuitting } from '../lifecycle'
@ -20,11 +20,7 @@ export function createTray(): void {
{ {
label: '표시', label: '표시',
click: () => { click: () => {
const mainWindow = getMainWindow() showMainWindow()
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
} }
}, },
{ type: 'separator' }, { type: 'separator' },
@ -41,11 +37,7 @@ export function createTray(): void {
tray.setContextMenu(contextMenu) tray.setContextMenu(contextMenu)
tray.on('double-click', () => { tray.on('double-click', () => {
const mainWindow = getMainWindow() showMainWindow()
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}) })
logger.info('Tray created') logger.info('Tray created')

View file

@ -1,7 +1,7 @@
// src/main/windows/WindowManager.ts // src/main/windows/WindowManager.ts
// 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈 // 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈
import { BrowserWindow, shell, screen, ipcMain, Menu, clipboard } from 'electron' import { app, BrowserWindow, screen, ipcMain, Menu, clipboard } from 'electron'
import { join } from 'path' import { join } from 'path'
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@d3ro/core/constants' import { WINDOW_SIZE } from '@d3ro/core/constants'
@ -15,7 +15,8 @@ import {
type KeyBindingActionId type KeyBindingActionId
} from '@d3ro/core/keybinding' } from '@d3ro/core/keybinding'
import { getLogger } from '../services/LoggerService' import { getLogger } from '../services/LoggerService'
import { getIsQuitting } from '../lifecycle' import { getIsQuitting, setIsQuitting } from '../lifecycle'
import { hardenWebContents } from './web-contents-hardening'
import { configGet, configSet } from '../services/ConfigService' import { configGet, configSet } from '../services/ConfigService'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars' import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getI18n } from '@d3ro/i18n' import { getI18n } from '@d3ro/i18n'
@ -269,6 +270,25 @@ export function getMainWindow(): BrowserWindow | null {
return mainWindow return mainWindow
} }
/** 부트스트랩이 메인 창을 한 번이라도 만들었는지 — 그 전에는 다시 만들지 않는다(중복 창 방지). */
let mainWindowCreated = false
/**
* 메인 창을 앞으로 가져온다. 이미 한 번 만들어진 뒤 닫혀 없으면 다시 만든다(트레이 '표시'·두 번째 실행).
* 부트스트랩 전이면 null.
*/
export function showMainWindow(): BrowserWindow | null {
let win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
if (!win) {
if (!mainWindowCreated) return null
win = createMainWindow()
}
if (win.isMinimized()) win.restore()
win.show()
win.focus()
return win
}
export function createMainWindow(): BrowserWindow { export function createMainWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay() const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
@ -302,6 +322,7 @@ export function createMainWindow(): BrowserWindow {
} }
}) })
mainWindowCreated = true
mainWindow.setSkipTaskbar(false) mainWindow.setSkipTaskbar(false)
// Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거 // Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거
@ -320,11 +341,18 @@ export function createMainWindow(): BrowserWindow {
}) })
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
if (!getIsQuitting() && configGet('closeToTray')) { if (getIsQuitting()) return
if (configGet('closeToTray')) {
event.preventDefault() event.preventDefault()
mainWindow?.hide() mainWindow?.hide()
logger.info('Main window hidden to tray') logger.info('Main window hidden to tray')
return
} }
// 트레이로 숨기지 않는 설정이면 창 닫기 = 앱 종료. 숨은 팝업이 남아 있어 window-all-closed가
// 오지 않으므로, 그대로 두면 다시 열 수 없는 창 없는 프로세스가 남는다.
setIsQuitting(true)
logger.info('Main window closed with closeToTray=false — quitting')
app.quit()
}) })
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
@ -335,10 +363,8 @@ export function createMainWindow(): BrowserWindow {
logger.info(`[Renderer] [${level}] ${message} (${sourceId}:${line})`) logger.info(`[Renderer] [${level}] ${message} (${sourceId}:${line})`)
}) })
mainWindow.webContents.setWindowOpenHandler((details) => { // 앱 밖 링크는 창 안에서 열지 않고(원격 페이지가 preload API를 얻지 못하게) 허용 scheme만 브라우저로 넘긴다.
shell.openExternal(details.url) hardenWebContents(mainWindow)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
@ -384,6 +410,7 @@ function createRecordingTipWindow(): BrowserWindow {
backgroundThrottling: false backgroundThrottling: false
} }
}) })
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/recording-tip/index.html`) win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/recording-tip/index.html`)
@ -496,6 +523,7 @@ function createResultPopupWindow(): BrowserWindow {
nodeIntegration: false nodeIntegration: false
} }
}) })
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/result-popup/index.html`) win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/result-popup/index.html`)
@ -579,6 +607,7 @@ function createHistoryPopupWindow(): BrowserWindow {
nodeIntegration: false nodeIntegration: false
} }
}) })
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`) win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
@ -671,6 +700,7 @@ function createCommandPopupWindow(): BrowserWindow {
nodeIntegration: false nodeIntegration: false
} }
}) })
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`) win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`)
@ -781,6 +811,7 @@ function createCaptionOverlayWindow(): BrowserWindow {
nodeIntegration: false nodeIntegration: false
} }
}) })
hardenWebContents(win)
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화 // 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
win.setIgnoreMouseEvents(true, { forward: true }) win.setIgnoreMouseEvents(true, { forward: true })
@ -936,6 +967,7 @@ function createSuggestionOverlayWindow(): BrowserWindow {
backgroundThrottling: false backgroundThrottling: false
} }
}) })
hardenWebContents(win)
applySuggestionOverlayMouseMode(win) applySuggestionOverlayMouseMode(win)

View file

@ -0,0 +1,75 @@
// src/main/windows/web-contents-hardening.ts
// 외부 URL·내비게이션 정책(@d3ro/core/url-policy)의 Electron 어댑터.
// - 앱 창은 앱 오리진 밖으로 이동하지 않는다(원격 페이지가 preload electronAPI를 얻지 못하게).
// - 새 창 요청은 허용 scheme일 때만 OS 브라우저로 넘긴다.
// - 모든 shell.openExternal 호출은 openExternalSafe 를 거친다.
import { shell, type BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { isAllowedExternalUrl, isAppOrigin, type ExternalUrlPolicyOptions } from '@d3ro/core/url-policy'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('WebContentsHardening')
/** 앱 렌더러가 올라오는 오리진: 배포본 file://, 개발 서버(ELECTRON_RENDERER_URL). */
export function appOrigins(): string[] {
const origins = ['file://']
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
try {
origins.push(new URL(devUrl).origin)
} catch {
// 잘못된 개발 URL은 무시 — file:// 만 허용한다.
}
}
return origins
}
/** 로그에 토큰·쿼리가 남지 않도록 scheme + host 만 남긴다. */
function redact(url: string): string {
try {
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.host}`
} catch {
return '(unparseable)'
}
}
/** 허용된 URL만 OS 기본 핸들러로 연다. 열었으면 true. */
export async function openExternalSafe(url: string, options: ExternalUrlPolicyOptions = {}): Promise<boolean> {
if (!isAllowedExternalUrl(url, options)) {
logger.warn(`Blocked external URL: ${redact(url)}`)
return false
}
await shell.openExternal(url)
return true
}
/** IPC 호출자가 앱 자신의 페이지인지(원격 페이지가 특권 IPC를 쓰지 못하게). */
export function isTrustedIpcSender(event: Pick<IpcMainInvokeEvent, 'senderFrame'>): boolean {
const url = event.senderFrame?.url
if (typeof url !== 'string') return false
return isAppOrigin(url, appOrigins())
}
/** 창의 webContents에 내비게이션·새 창·webview 가드를 건다. */
export function hardenWebContents(win: BrowserWindow): void {
const contents = win.webContents
const guardNavigation = (event: { preventDefault: () => void }, url: string): void => {
if (isAppOrigin(url, appOrigins())) return
event.preventDefault()
void openExternalSafe(url).catch((err: unknown) => {
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
})
}
contents.on('will-navigate', guardNavigation)
contents.on('will-redirect', guardNavigation)
contents.setWindowOpenHandler((details) => {
void openExternalSafe(details.url).catch((err: unknown) => {
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
})
return { action: 'deny' }
})
contents.on('will-attach-webview', (event) => {
event.preventDefault()
})
}

View file

@ -11,7 +11,28 @@ interface MarkdownRendererProps {
content: string content: string
} }
/**
* 링크는 앱 창 안에서 이동하지 않는다 — 메인 창이 원격 페이지로 바뀌면 preload API가 그 페이지에 노출된다.
* 클릭은 main의 openExternal(허용 scheme만)로 넘겨 OS 브라우저에서 연다.
*/
function openLinkExternally(event: React.MouseEvent<HTMLAnchorElement>, href: string | undefined): void {
event.preventDefault()
if (!href) return
void window.electronAPI.system.openExternal({ url: href })
}
const components: Components = { const components: Components = {
a: ({ children, href }) => (
<Box
component="a"
href={href}
rel="noopener noreferrer"
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => openLinkExternally(event, href)}
sx={{ color: d3roPalette.accent.main, textDecoration: 'underline', cursor: 'pointer' }}
>
{children}
</Box>
),
h1: ({ children }) => ( h1: ({ children }) => (
<Box <Box
component="h1" component="h1"

View file

@ -0,0 +1,133 @@
// 이력 레드팀 r1-0: 삭제 시 녹음 WAV 정리, 대시보드 통계를 history 에서 계산
import fs from 'fs'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestDb } from '../../helpers/createTestDb'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import { getHistoryService, resetHistoryServiceForTests } from '../../../src/main/services/HistoryService'
import { isAppRecordingPath, recordingsDir } from '../../../src/main/services/history-deletion'
import { computeStreakDays, localDayKey } from '../../../src/main/services/history-stats'
import { resetAccountScopeForTests } from '../../../src/main/services/account-scope'
let testDb: ReturnType<typeof createTestDb>
const created: string[] = []
function writeRecording(name: string): string {
const dir = recordingsDir()
fs.mkdirSync(dir, { recursive: true })
const file = path.join(dir, name)
fs.writeFileSync(file, Buffer.from('RIFF'))
created.push(file)
return file
}
function insertHistory(opts: { audio?: string | null; createdAt?: number; status?: 'completed' | 'error'; words?: number; duration?: number }): string {
const id = crypto.randomUUID()
const at = opts.createdAt ?? Date.now()
testDb.db
.insert(history)
.values({
id,
originalText: 'x',
duration: opts.duration ?? 1,
wordCount: opts.words ?? 1,
status: opts.status ?? 'completed',
audioLocalPath: opts.audio ?? null,
createdAt: at,
updatedAt: at,
})
.run()
return id
}
async function waitGone(file: string): Promise<void> {
await vi.waitFor(() => expect(fs.existsSync(file)).toBe(false), { timeout: 2000, interval: 10 })
}
beforeEach(() => {
resetAccountScopeForTests()
testDb = createTestDb()
bindTestDatabase(testDb.db, '_local')
initInMemoryConfig()
resetHistoryServiceForTests()
})
afterEach(() => {
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
for (const f of created.splice(0)) fs.rmSync(f, { force: true })
resetAccountScopeForTests()
})
describe('녹음 파일 정리', () => {
it('단건 삭제는 녹음 WAV도 지운다', async () => {
const wav = writeRecording(`${crypto.randomUUID()}.wav`)
const id = insertHistory({ audio: wav })
expect(getHistoryService().delete(id)).toBe(true)
await waitGone(wav)
})
it('전체 삭제·원격 삭제 반영도 녹음을 지우지만, 녹음 폴더 밖 사용자 원본은 건드리지 않는다', async () => {
const wav1 = writeRecording(`${crypto.randomUUID()}.wav`)
const wav2 = writeRecording(`${crypto.randomUUID()}.wav`)
const outside = path.join(path.dirname(recordingsDir()), `user-source-${crypto.randomUUID()}.wav`)
fs.writeFileSync(outside, Buffer.from('RIFF'))
created.push(outside)
insertHistory({ audio: wav1 })
insertHistory({ audio: wav2 })
insertHistory({ audio: outside })
getHistoryService().deleteAll()
await waitGone(wav1)
await waitGone(wav2)
expect(fs.existsSync(outside)).toBe(true)
expect(testDb.db.select().from(history).all()).toEqual([])
})
it('동기화의 원격 삭제 반영(historyAdapter.deleteLocal)도 녹음을 지운다', async () => {
const { adapterFor } = await import('../../../src/main/services/sync/sync-adapters')
const wav = writeRecording(`${crypto.randomUUID()}.wav`)
const id = insertHistory({ audio: wav })
expect(adapterFor('history')?.deleteLocal(id)).toBe(true)
await waitGone(wav)
})
it('녹음 폴더 경계 판정', () => {
const dir = recordingsDir()
expect(isAppRecordingPath(path.join(dir, 'a.wav'), dir)).toBe(true)
expect(isAppRecordingPath(path.join(dir, '..', 'a.wav'), dir)).toBe(false)
expect(isAppRecordingPath(`${dir}-evil${path.sep}a.wav`, dir)).toBe(false)
expect(isAppRecordingPath(dir, dir)).toBe(false)
expect(isAppRecordingPath(null, dir)).toBe(false)
})
})
describe('대시보드 통계', () => {
it('동기화·가져오기로 들어온 기록도 누적 통계에 들어가고 연속 사용일을 계산한다', () => {
const day = 24 * 60 * 60 * 1000
const now = Date.now()
// create()를 거치지 않은 행(동기화 반영) — 예전 카운터에는 반영되지 않았다
insertHistory({ createdAt: now, words: 5, duration: 2 })
insertHistory({ createdAt: now - day, words: 3, duration: 1 })
insertHistory({ createdAt: now - 2 * day, words: 2, duration: 1 })
insertHistory({ createdAt: now - 2 * day, status: 'error', words: 100, duration: 50 })
const stats = getHistoryService().getStats()
expect(stats.totalSessionCount).toBe(3)
expect(stats.totalWordCount).toBe(10)
expect(stats.totalRecordingTimeMs).toBe(4000)
expect(stats.streakDays).toBe(3)
})
it('computeStreakDays: 오늘 기록이 없으면 어제부터, 끊기면 멈춘다', () => {
const today = new Date(2026, 8, 28, 15)
const key = (offset: number): string => localDayKey(new Date(2026, 8, 28 - offset))
expect(computeStreakDays([], today)).toBe(0)
expect(computeStreakDays([key(0), key(1), key(2)], today)).toBe(3)
expect(computeStreakDays([key(1), key(2)], today)).toBe(2)
expect(computeStreakDays([key(0), key(2)], today)).toBe(1)
expect(computeStreakDays([key(2), key(3)], today)).toBe(0)
})
})

View file

@ -0,0 +1,172 @@
// CloudSyncService 에서 분리한 SessionTokenStore·SyncScheduler, 로그인 콜백 가드 회귀 테스트
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EncryptedFileTokenStore,
bindAuthEvents,
type AuthSessionLike,
type TokenCipher,
type TokenFileSystem,
} from '../../../src/main/services/sync/session-token-store'
import { SyncScheduler } from '../../../src/main/services/sync/sync-scheduler'
function memoryFs(): TokenFileSystem & { files: Map<string, Buffer> } {
const files = new Map<string, Buffer>()
return {
files,
existsSync: (p) => files.has(p),
readFileSync: (p) => {
const data = files.get(p)
if (!data) throw new Error('ENOENT')
return data
},
writeFileSync: (p, data) => {
files.set(p, Buffer.from(data))
},
unlinkSync: (p) => {
files.delete(p)
},
}
}
const reversingCipher: TokenCipher = {
isEncryptionAvailable: () => true,
encryptString: (plain) => Buffer.from([...plain].reverse().join(''), 'utf-8'),
decryptString: (encrypted) => [...encrypted.toString('utf-8')].reverse().join(''),
}
const logger = { warn: vi.fn() }
describe('EncryptedFileTokenStore', () => {
it('암호화해 저장하고 복원하며, clear 는 파일을 지운다', () => {
const fs = memoryFs()
const store = new EncryptedFileTokenStore('/u/cloud-sync.token', reversingCipher, fs, logger)
expect(store.load()).toBeNull()
store.save('rt-1')
expect(fs.files.get('/u/cloud-sync.token')?.toString('utf-8')).toBe('1-tr')
expect(store.load()).toBe('rt-1')
store.clear()
expect(store.load()).toBeNull()
})
it('OS 암호화를 못 쓰면 평문으로 저장하지 않는다', () => {
const fs = memoryFs()
const store = new EncryptedFileTokenStore('/t', { ...reversingCipher, isEncryptionAvailable: () => false }, fs, logger)
store.save('secret')
expect(fs.files.size).toBe(0)
})
})
describe('bindAuthEvents — 회전된 refresh token 저장', () => {
type Session = AuthSessionLike & { access_token: string }
function harness(activeUser: string | null) {
let emit: (event: string, session: Session | null) => void = () => undefined
const unsubscribe = vi.fn()
const fs = memoryFs()
const store = new EncryptedFileTokenStore('/t', reversingCipher, fs, logger)
const onSession = vi.fn()
let active = activeUser
const unbind = bindAuthEvents<Session>(
(cb) => {
emit = cb
return { unsubscribe }
},
store,
() => active,
onSession
)
return {
emit: (event: string, session: Session | null) => emit(event, session),
store,
onSession,
unsubscribe,
unbind,
setActive: (u: string | null) => {
active = u
},
}
}
const session = (rt: string, user = 'u1'): Session => ({ refresh_token: rt, access_token: `at-${rt}`, user: { id: user } })
it('TOKEN_REFRESHED 마다 새 토큰을 저장하고 메모리 세션도 갱신한다', () => {
const h = harness('u1')
h.store.save('rt-old')
h.emit('TOKEN_REFRESHED', session('rt-new'))
expect(h.store.load()).toBe('rt-new')
expect(h.onSession).toHaveBeenCalledWith(expect.objectContaining({ access_token: 'at-rt-new' }))
})
it('로그아웃 뒤·다른 사용자·SIGNED_OUT 이벤트는 무시한다', () => {
const h = harness('u1')
h.store.save('rt-keep')
h.emit('TOKEN_REFRESHED', session('rt-other', 'u2'))
h.emit('SIGNED_OUT', null)
h.emit('INITIAL_SESSION', session('rt-initial'))
expect(h.store.load()).toBe('rt-keep')
h.setActive(null)
h.emit('TOKEN_REFRESHED', session('rt-late'))
expect(h.store.load()).toBe('rt-keep')
expect(h.onSession).not.toHaveBeenCalled()
h.unbind()
expect(h.unsubscribe).toHaveBeenCalled()
})
})
describe('SyncScheduler', () => {
afterEach(() => {
vi.useRealTimers()
})
it('디바운스는 여러 요청을 한 번으로 모은다', () => {
vi.useFakeTimers()
const run = vi.fn()
const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 300_000, run, onHeartbeat: vi.fn() })
scheduler.scheduleFlush()
scheduler.scheduleFlush()
scheduler.schedulePull()
vi.advanceTimersByTime(1499)
expect(run).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(run.mock.calls).toEqual([['flush'], ['pull']])
scheduler.scheduleFlush()
vi.advanceTimersByTime(1500)
expect(run).toHaveBeenCalledTimes(3)
})
it('heartbeat 는 주기마다 부르고 stop 은 대기 중인 디바운스까지 취소한다', () => {
vi.useFakeTimers()
const run = vi.fn()
const onHeartbeat = vi.fn()
const scheduler = new SyncScheduler({ flushDelayMs: 1500, pullDelayMs: 1500, heartbeatMs: 1000, run, onHeartbeat })
scheduler.startHeartbeat()
vi.advanceTimersByTime(3000)
expect(onHeartbeat).toHaveBeenCalledTimes(3)
scheduler.scheduleFlush()
scheduler.stop()
vi.advanceTimersByTime(5000)
expect(onHeartbeat).toHaveBeenCalledTimes(3)
expect(run).not.toHaveBeenCalled()
expect(scheduler.isHeartbeatRunning()).toBe(false)
})
})
describe('로그인 콜백 가드 (login CSRF)', () => {
it('앱이 시작하지 않은 로그인 콜백 토큰은 세션을 바꾸지 않고 거부한다', async () => {
const { getCloudSyncService, resetCloudSyncServiceForTests } = await import('../../../src/main/services/CloudSyncService')
const { initInMemoryConfig, resetInMemoryConfig } = await import('../../../src/main/services/ConfigService')
initInMemoryConfig()
resetCloudSyncServiceForTests()
const svc = getCloudSyncService()
try {
await svc.init()
await expect(
svc.handleAuthTokens({ accessToken: 'attacker.at', refreshToken: 'attacker-rt' })
).rejects.toThrow(/no sign-in was started/)
await expect(svc.handleAuthCallback('attacker-code')).rejects.toThrow(/no sign-in was started/)
expect(svc.isAuthenticated()).toBe(false)
} finally {
resetCloudSyncServiceForTests()
resetInMemoryConfig()
}
})
})

View file

@ -0,0 +1,428 @@
// 동기화 레드팀 r1-0 회귀 테스트
// - 해제된 엔진이 다른 사용자 DB(로컬 모드)에 쓰지 않는다
// - 서버 시각 updated_at (클라이언트 시각을 보내지 않는다)
// - 계정별 명령·템플릿 범위, 템플릿 P0002 보관
// - 미룬 행(부모 없음) 재시도, 부모 실패 시 자식 커서 보존
// - push 전에 원격 삭제 반영(좀비 행 방지)
// - 명령 길이 제한(잘라 올리지 않음)
// - 엔티티 레지스트리 전수·순서
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history, meetingMemos, memoTags } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
INSTRUCTION_LIMITS,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import {
getDictationTemplateService,
resetDictationTemplateServiceForTests,
} from '../../../src/main/services/DictationTemplateService'
import {
getMeetingDocTemplateService,
resetMeetingDocTemplateServiceForTests,
} from '../../../src/main/services/MeetingDocTemplateService'
import {
createMemoryCollectionFactory,
resetAccountScopeForTests,
type UserScopedCollectionFactory,
} from '../../../src/main/services/account-scope'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import {
SyncAbortedError,
dictionaryToRemote,
historyToRemote,
instructionPushError,
meetingDocumentToRemote,
meetingMemoToRemote,
meetingToRemote,
templateUpsertError,
} from '../../../src/main/services/sync/sync-adapters'
import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox'
import { deleteOrder, SYNC_REGISTRY, upsertOrder } from '../../../src/main/services/sync/sync-registry'
import {
SYNC_ENTITIES,
SYNC_ENTITY_SPECS,
SyncRemoteError,
parentOf,
realtimeTables,
toSyncRemoteError,
type RemotePageRequest,
type RemoteRow,
} from '../../../src/main/services/sync/sync-types'
const USER = '11111111-1111-4111-8111-111111111111'
const USER_B = '33333333-3333-4333-8333-333333333333'
function uuid(): string {
return crypto.randomUUID()
}
/** fetchPage 를 테이블별로 가로챌 수 있는 원격 */
class GatedRemote extends FakeSyncRemote {
gate: ((request: RemotePageRequest) => Promise<void> | void) | null = null
override async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
const rows = await super.fetchPage(request)
if (this.gate) await this.gate(request)
return rows
}
}
let dbs: Array<ReturnType<typeof createTestDb>> = []
let factory: UserScopedCollectionFactory
function openDb(userId: string): ReturnType<typeof createTestDb> {
const created = createTestDb()
dbs.push(created)
bindTestDatabase(created.db, userId)
return created
}
beforeEach(() => {
resetAccountScopeForTests()
initInMemoryConfig()
factory = createMemoryCollectionFactory()
resetCustomInstructionServiceForTests(factory)
resetDictationTemplateServiceForTests(factory)
resetMeetingDocTemplateServiceForTests(factory)
})
afterEach(() => {
unbindTestDatabase()
resetInMemoryConfig()
for (const d of dbs) d.close()
dbs = []
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetAccountScopeForTests()
})
describe('해제된 엔진은 다른 사용자 DB에 쓰지 않는다', () => {
it('pull 도중 로그아웃(DB 전환)되면 계정 행이 로컬 모드 DB로 들어가지 않고 로컬 태그도 지우지 않는다', async () => {
const accountDb = openDb(USER)
const remote = new GatedRemote(USER)
for (let i = 0; i < 700; i++) {
remote.mobileInsert('history', { id: uuid(), original_text: `a${i}`, duration: 1, mode: 'dictation', status: 'completed' })
}
const engine = new SyncEngine({ remote, userId: USER })
let release: () => void = () => undefined
let pages = 0
remote.gate = (request) => {
if (request.table !== 'history') return
pages++
if (pages === 2) {
return new Promise<void>((resolve) => {
release = resolve
})
}
}
const running = engine.runFullSync()
// 두 번째 페이지 응답을 기다리는 동안 로그아웃: 엔진 해제 → 로컬 모드 DB로 전환
for (let i = 0; i < 200 && pages < 2; i++) await new Promise((r) => setTimeout(r, 10))
expect(pages).toBe(2)
engine.dispose()
const localDb = openDb('_local')
const localHistory = uuid()
const now = Date.now()
localDb.db.insert(history).values({ id: localHistory, originalText: 'local', duration: 1, createdAt: now, updatedAt: now }).run()
localDb.db.insert(memoTags).values({ id: uuid(), historyId: localHistory, tag: 'mine', createdAt: now }).run()
release()
await expect(running).rejects.toBeInstanceOf(SyncAbortedError)
const localRows = localDb.db.select().from(history).all()
expect(localRows.map((r) => r.id)).toEqual([localHistory])
expect(localDb.db.select().from(memoTags).all()).toHaveLength(1)
// 계정 DB는 첫 페이지까지만 반영됐고 커서도 로컬 DB에 쓰이지 않았다
expect(accountDb.db.select().from(history).all().length).toBeLessThanOrEqual(500)
})
it('해제 뒤에 줄을 선 작업은 시작하지 않는다', async () => {
openDb(USER)
const remote = new FakeSyncRemote(USER)
const engine = new SyncEngine({ remote, userId: USER })
engine.dispose()
await expect(engine.pull()).rejects.toBeInstanceOf(SyncAbortedError)
expect(remote.calls).toEqual([])
await expect(engine.whenIdle(100)).resolves.toBe(true)
})
it('다른 사용자 DB가 열리면 같은 엔진도 멈춘다', async () => {
openDb(USER)
const remote = new FakeSyncRemote(USER)
const engine = new SyncEngine({ remote, userId: USER })
openDb(USER_B)
expect(engine.isCurrent()).toBe(false)
await expect(engine.runFullSync()).rejects.toBeInstanceOf(SyncAbortedError)
})
})
describe('updated_at 은 서버 시각', () => {
it('push 페이로드에 클라이언트 updated_at 을 싣지 않는다', () => {
const at = Date.parse('2026-01-01T00:00:00Z')
const h = historyToRemote(
{
id: uuid(), title: null, originalText: 'x', polishedText: null, focusedApp: null, focusedAppName: null,
focusedAppWindowTitle: null, mode: 'dictation', status: 'completed', errorCode: null, audioLocalPath: null,
duration: 1, detectedLanguage: null, micDevice: null, wordCount: 1, sttModel: null, llmModel: null,
sttLatencyMs: null, llmLatencyMs: null, createdAt: at, updatedAt: at, appVersion: '1', summaryText: null,
isFavorite: false,
} as Parameters<typeof historyToRemote>[0],
USER
)
expect(h).not.toHaveProperty('updated_at')
const d = dictionaryToRemote(
{ id: uuid(), word: 'w', pronunciation: null, category: 'user', usageCount: 0, lastUsedAt: null, createdAt: at, updatedAt: at } as Parameters<typeof dictionaryToRemote>[0],
USER
)
expect(d).not.toHaveProperty('updated_at')
const m = meetingToRemote({ id: uuid(), title: 't', status: 'completed', startedAt: at, endedAt: null, createdAt: at, updatedAt: at } as Parameters<typeof meetingToRemote>[0], USER)
expect(m).not.toHaveProperty('updated_at')
const memo = meetingMemoToRemote({ id: uuid(), sessionId: uuid(), content: 'c', timestampMs: 1, createdAt: at, updatedAt: at } as Parameters<typeof meetingMemoToRemote>[0], USER)
expect(memo).not.toHaveProperty('updated_at')
const doc = meetingDocumentToRemote({ id: uuid(), sessionId: uuid(), templateType: 'minutes', title: 't', content: '', createdAt: at, updatedAt: at } as Parameters<typeof meetingDocumentToRemote>[0], USER)
expect(doc).not.toHaveProperty('updated_at')
})
it('다른 데스크톱이 늦게 올린 오래된 기록도 이미 동기화된 기기가 내려받는다', async () => {
const remote = new FakeSyncRemote(USER)
// 데스크톱 B: 이미 동기화돼 커서가 "지금" 근처
const dbB = openDb(USER)
remote.mobileInsert('history', { id: uuid(), original_text: 'recent', duration: 1, mode: 'dictation', status: 'completed' })
const engineB = new SyncEngine({ remote, userId: USER })
await engineB.runFullSync()
engineB.dispose()
for (let i = 0; i < 120; i++) remote.now() // 서버 시계 2분 경과
// 데스크톱 A: 몇 달 전 로컬 기록을 처음 올린다
const dbA = openDb(USER)
const old = Date.parse('2026-03-01T00:00:00Z')
const oldId = uuid()
dbA.db.insert(history).values({ id: oldId, originalText: 'old recording', duration: 1, createdAt: old, updatedAt: old }).run()
const engineA = new SyncEngine({ remote, userId: USER })
await engineA.runFullSync()
engineA.dispose()
expect(remote.find('history', oldId)).toBeDefined()
// B의 다음 pull 이 받아야 한다
bindTestDatabase(dbB.db, USER)
const engineB2 = new SyncEngine({ remote, userId: USER })
await engineB2.pull()
engineB2.dispose()
expect(dbB.db.select().from(history).where(eq(history.id, oldId)).get()?.originalText).toBe('old recording')
})
})
describe('계정별 명령·템플릿', () => {
it('다른 계정으로 로그인하면 이전 계정의 명령·템플릿이 보이지 않고 backfill 로 올라가지 않는다', async () => {
openDb(USER)
getCustomInstructionService().initialize()
const mine = getCustomInstructionService().create({ name: 'A private', description: '', prompt: 'secret prompt of A' })
const dictation = getDictationTemplateService().create({ name: 'A tpl', description: '', fields: [], outputFormat: 'x' })
const meetingTpl = getMeetingDocTemplateService().create({ name: 'A doc', description: '', systemPrompt: 'p' })
// 로그아웃 → 로컬 모드: A의 항목이 보이지 않는다
openDb('_local')
expect(getCustomInstructionService().getById(mine.id)).toBeNull()
expect(getDictationTemplateService().getById(dictation.id)).toBeNull()
expect(getMeetingDocTemplateService().getById(meetingTpl.id)).toBeNull()
// B 로그인
openDb(USER_B)
const remote = new FakeSyncRemote(USER_B)
const engine = new SyncEngine({ remote, userId: USER_B })
await engine.runFullSync()
engine.dispose()
expect(getCustomInstructionService().getById(mine.id)).toBeNull()
expect(remote.find('custom_instructions', mine.id)).toBeUndefined()
expect(remote.find('user_templates', dictation.id)).toBeUndefined()
expect(remote.find('user_templates', meetingTpl.id)).toBeUndefined()
expect(getCustomInstructionService().getAll().filter((i) => i.isBuiltin)).toHaveLength(5)
// A 다시 로그인: A의 항목이 돌아온다
openDb(USER)
expect(getCustomInstructionService().getById(mine.id)?.prompt).toBe('secret prompt of A')
expect(getDictationTemplateService().getById(dictation.id)).not.toBeNull()
})
it('로컬 모드에서 만든 명령은 처음 로그인한 계정 하나만 가져간다', () => {
openDb('_local')
getCustomInstructionService().initialize()
const local = getCustomInstructionService().create({ name: 'local', description: '', prompt: 'p' })
openDb(USER)
expect(getCustomInstructionService().getById(local.id)).not.toBeNull()
openDb(USER_B)
expect(getCustomInstructionService().getById(local.id)).toBeNull()
})
it('다른 계정 소유 템플릿(P0002)은 재시도 불가로 보관되고, 일반 P0002 는 여전히 재시도한다', () => {
const err = templateUpsertError({ code: 'P0002', message: 'template_not_found' })
expect(err.retryable).toBe(false)
expect(toSyncRemoteError({ code: 'P0002', message: 'history_not_found' }).retryable).toBe(true)
expect(templateUpsertError(new SyncRemoteError('fetch failed', 'network', true)).retryable).toBe(true)
})
})
describe('미룬 행과 커서', () => {
it('부모 회의가 늦게 온 메모는 커서가 지나가도 다음 pull 에서 반영된다', async () => {
openDb(USER)
const remote = new FakeSyncRemote(USER)
const meetingId = uuid()
const memoId = uuid()
// 메모가 먼저 서버에 보이고(회의 행은 아직 없음)
remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'late parent', timestamp_ms: 1 })
for (let i = 0; i < 120; i++) remote.now()
// 더 새 메모(다른 회의)로 memo 커서가 2분 앞으로 간다
const otherMeeting = uuid()
remote.mobileInsert('meetings', { id: otherMeeting, title: 'other', status: 'completed', started_at: remote.now() })
remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: otherMeeting, content: 'newer', timestamp_ms: 1 })
const engine = new SyncEngine({ remote, userId: USER })
const first = await engine.runFullSync()
expect(first.errors).toEqual([])
expect(testMemo(memoId)).toBeUndefined()
// 부모 회의가 도착
remote.mobileInsert('meetings', { id: meetingId, title: 'late', status: 'completed', started_at: remote.now() })
await engine.pull()
engine.dispose()
expect(testMemo(memoId)?.content).toBe('late parent')
})
it('회의 pull 이 실패하면 같은 실행에서 메모 커서를 넘기지 않는다', async () => {
openDb(USER)
const remote = new GatedRemote(USER)
const meetingId = uuid()
remote.mobileInsert('meetings', { id: meetingId, title: 'm', status: 'completed', started_at: remote.now() })
const memoId = uuid()
remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'child', timestamp_ms: 1 })
let failMeetings = true
remote.gate = (request) => {
if (request.table === 'meetings' && failMeetings) throw new SyncRemoteError('fetch failed', 'network', true)
}
const engine = new SyncEngine({ remote, userId: USER })
const failed = await engine.pull()
expect(failed.errors.some((e) => e.startsWith('meetings'))).toBe(true)
expect(remote.calls.filter((c) => c === 'fetch:meeting_memos')).toEqual([])
failMeetings = false
await engine.pull()
engine.dispose()
expect(testMemo(memoId)?.content).toBe('child')
})
})
function testMemo(id: string): { content: string } | undefined {
return dbs[dbs.length - 1].db.select().from(meetingMemos).where(eq(meetingMemos.id, id)).get()
}
describe('push 전에 원격 삭제 반영', () => {
it('폰에서 지운 행의 오프라인 편집이 서버에 행을 되살리지 않는다', async () => {
const d = openDb(USER)
const remote = new FakeSyncRemote(USER)
const id = uuid()
remote.mobileInsert('history', { id, original_text: 'orig', duration: 1, mode: 'dictation', status: 'completed' })
const engine = new SyncEngine({ remote, userId: USER })
await engine.runFullSync()
expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeDefined()
// 폰이 지우고, 데스크톱은 오프라인에서 편집해 대기 upsert 가 있다
remote.mobileDelete('history', id)
d.db.update(history).set({ title: 'edited offline', updatedAt: Date.now() }).where(eq(history.id, id)).run()
enqueueChange('history', id, 'upsert')
const result = await engine.flush()
engine.dispose()
expect(remote.find('history', id)).toBeUndefined()
expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined()
expect(pendingOps('history').has(id)).toBe(false)
expect(result.deleted).toBe(1)
})
it('최초 대조(backfill)는 서버에서 지워진 행을 다시 올리지 않는다', async () => {
const d = openDb(USER)
const remote = new FakeSyncRemote(USER)
const id = uuid()
remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' })
remote.mobileDelete('history', id)
const at = Date.now()
d.db.insert(history).values({ id, originalText: 'x', duration: 1, createdAt: at, updatedAt: at }).run()
const engine = new SyncEngine({ remote, userId: USER })
await engine.runFullSync()
engine.dispose()
expect(remote.find('history', id)).toBeUndefined()
expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined()
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
})
})
describe('명령 길이 제한', () => {
it('서버 제한을 넘는 명령은 만들거나 고칠 수 없다', () => {
openDb(USER)
const svc = getCustomInstructionService()
svc.initialize()
expect(() => svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/)
expect(() => svc.create({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1), description: '', prompt: 'p' })).toThrow(/too long/)
const ok = svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt) })
expect(() => svc.update(ok.id, { prompt: 'y'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/)
expect(svc.getById(ok.id)?.prompt).toHaveLength(INSTRUCTION_LIMITS.prompt)
})
it('이미 저장된 긴 명령은 잘라 올리지 않고 재시도 불가 오류로 보관된다', async () => {
openDb(USER)
const svc = getCustomInstructionService()
svc.initialize()
const longPrompt = 'z'.repeat(6000)
const id = uuid()
svc.applyRemote({ id, name: 'legacy', description: '', prompt: longPrompt, icon: 'x', isBuiltin: false, order: 9, createdAt: 1, updatedAt: 1 })
const instruction = svc.getById(id)
expect(instruction).not.toBeNull()
expect(instructionPushError(instruction!)?.retryable).toBe(false)
const remote = new FakeSyncRemote(USER)
const engine = new SyncEngine({ remote, userId: USER })
await engine.runFullSync()
engine.dispose()
expect(remote.find('custom_instructions', id)).toBeUndefined()
expect(svc.getById(id)?.prompt).toBe(longPrompt)
})
})
describe('엔티티 레지스트리', () => {
it('모든 엔티티가 정확히 한 번 등록되고 부모가 자식보다 먼저 push 된다', () => {
const entities = SYNC_REGISTRY.map((e) => e.entity)
expect([...entities].sort()).toEqual([...SYNC_ENTITIES].sort())
expect(new Set(entities).size).toBe(entities.length)
for (const spec of SYNC_ENTITY_SPECS) {
const parent = parentOf(spec.entity)
if (!parent) continue
expect(SYNC_ENTITIES).toContain(parent)
expect(entities.indexOf(parent)).toBeLessThan(entities.indexOf(spec.entity))
}
})
it('push·delete 순서와 Realtime 대상이 이전과 같다', () => {
expect(upsertOrder().map((e) => e.entity)).toEqual([
'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'custom_instructions',
'builtin_instructions', 'user_settings', 'user_templates', 'knowledge_documents', 'memo_tags',
'history_audio', 'meeting_audio',
])
expect(deleteOrder().map((e) => e.entity)).toEqual([
'memo_tags', 'knowledge_documents', 'user_templates', 'custom_instructions', 'meeting_documents',
'meeting_memos', 'meetings', 'dictionary', 'history',
])
expect([...realtimeTables()].sort()).toEqual(
[
'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'memo_tags',
'custom_instructions', 'user_templates', 'knowledge_documents', 'user_settings', 'sync_tombstones',
].sort()
)
})
})

View file

@ -0,0 +1,136 @@
// closeToTray=false 로 메인 창을 닫으면 창 없는 프로세스로 남지 않고 종료한다. 창이 사라진 뒤 트레이
// '표시'·두 번째 실행은 창을 다시 만든다.
import { beforeEach, describe, expect, it, vi } from 'vitest'
type Listener = (...args: unknown[]) => void
const state = vi.hoisted(() => ({
windows: [] as Array<{ listeners: Map<string, Listener>; destroyed: boolean }>,
quit: vi.fn(),
}))
vi.unmock('../../../src/main/windows/WindowManager')
vi.mock('electron', () => {
const quit = state.quit
class FakeBrowserWindow {
listeners = new Map<string, Listener>()
destroyed = false
webContents = {
on: vi.fn(),
setWindowOpenHandler: vi.fn(),
openDevTools: vi.fn(),
insertCSS: vi.fn(async () => ''),
send: vi.fn(),
}
constructor() {
state.windows.push(this)
}
on(event: string, listener: Listener): this {
this.listeners.set(event, listener)
return this
}
once(event: string, listener: Listener): this {
return this.on(event, listener)
}
setSkipTaskbar = vi.fn()
setMenu = vi.fn()
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
center = vi.fn()
restore = vi.fn()
show = vi.fn()
focus = vi.fn()
hide = vi.fn()
setAlwaysOnTop = vi.fn()
flashFrame = vi.fn()
isMinimized = vi.fn(() => false)
isDestroyed(): boolean {
return this.destroyed
}
}
return {
app: { quit, getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {
getPrimaryDisplay: vi.fn(() => ({ workAreaSize: { width: 1920, height: 1080 }, workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })),
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
},
ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() },
clipboard: { writeText: vi.fn() },
shell: { openExternal: vi.fn(async () => undefined) },
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
Tray: vi.fn(),
dialog: { showErrorBox: vi.fn() },
}
})
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
vi.mock('../../../src/main/lifecycle', () => {
let quitting = false
return {
getIsQuitting: () => quitting,
setIsQuitting: (value: boolean) => {
quitting = value
},
}
})
const config: Record<string, unknown> = { closeToTray: false, theme: 'dark', language: 'ko' }
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => config[key],
configSet: (key: string, value: unknown) => {
config[key] = value
},
}))
beforeEach(async () => {
state.windows.length = 0
state.quit.mockClear()
const lifecycle = await import('../../../src/main/lifecycle')
lifecycle.setIsQuitting(false)
})
describe('main window close', () => {
it('closeToTray=false 이면 창 닫기가 앱 종료로 이어진다', async () => {
config.closeToTray = false
const { createMainWindow } = await import('../../../src/main/windows/WindowManager')
createMainWindow()
const win = state.windows[0]
const event = { preventDefault: vi.fn() }
win.listeners.get('close')?.(event)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(state.quit).toHaveBeenCalledTimes(1)
})
it('closeToTray=true 이면 숨기기만 하고 종료하지 않는다', async () => {
vi.resetModules()
config.closeToTray = true
const { createMainWindow } = await import('../../../src/main/windows/WindowManager')
createMainWindow()
const win = state.windows[0]
const event = { preventDefault: vi.fn() }
win.listeners.get('close')?.(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(state.quit).not.toHaveBeenCalled()
})
it('창이 파괴된 뒤 showMainWindow 는 창을 다시 만든다(부트스트랩 전에는 만들지 않는다)', async () => {
vi.resetModules()
const { createMainWindow, showMainWindow, getMainWindow } = await import('../../../src/main/windows/WindowManager')
expect(showMainWindow()).toBeNull()
expect(state.windows).toHaveLength(0)
createMainWindow()
const first = state.windows[0]
first.destroyed = true
first.listeners.get('closed')?.()
expect(getMainWindow()).toBeNull()
expect(showMainWindow()).not.toBeNull()
expect(state.windows).toHaveLength(2)
})
})

View file

@ -0,0 +1,100 @@
// 내비게이션·외부 URL 가드 회귀 테스트
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ipcMain, shell } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
hardenWebContents,
isTrustedIpcSender,
openExternalSafe,
} from '../../../src/main/windows/web-contents-hardening'
type Handler = (...args: unknown[]) => unknown
function fakeWindow() {
const listeners = new Map<string, Handler>()
let openHandler: ((details: { url: string }) => { action: string }) | null = null
const webContents = {
on: vi.fn((event: string, listener: Handler) => {
listeners.set(event, listener)
}),
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
openHandler = handler
}),
}
return {
win: { webContents } as unknown as Parameters<typeof hardenWebContents>[0],
fire: (event: string, url: string) => {
const preventDefault = vi.fn()
listeners.get(event)?.({ preventDefault }, url)
return preventDefault
},
open: (url: string) => openHandler?.({ url }),
listeners,
}
}
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear()
})
describe('hardenWebContents', () => {
it('앱 창이 원격 페이지로 이동하지 못하게 막고 https 는 브라우저로 넘긴다', async () => {
const w = fakeWindow()
hardenWebContents(w.win)
const prevented = w.fire('will-navigate', 'https://evil.example/phish')
expect(prevented).toHaveBeenCalled()
await vi.waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://evil.example/phish'))
})
it('앱 자신의 페이지(file://)로의 이동은 허용한다', () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.fire('will-navigate', 'file:///C:/app/out/renderer/index.html#/x')).not.toHaveBeenCalled()
})
it('새 창은 항상 거부하고 file:/UNC/사용자 정의 scheme 은 OS로 넘기지 않는다', async () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.open('file:///C:/Windows/System32/calc.exe')).toEqual({ action: 'deny' })
expect(w.open('search-ms:query=x')).toEqual({ action: 'deny' })
expect(w.fire('will-navigate', 'ms-msdt:/id PCWDiagnostic')).toHaveBeenCalled()
await new Promise((r) => setTimeout(r, 10))
expect(shell.openExternal).not.toHaveBeenCalled()
expect(w.listeners.has('will-attach-webview')).toBe(true)
})
})
describe('openExternalSafe / isTrustedIpcSender', () => {
it('허용 scheme 만 연다', async () => {
expect(await openExternalSafe('file:///etc/passwd')).toBe(false)
expect(await openExternalSafe('https://d3ro.chanpaca.net/')).toBe(true)
expect(shell.openExternal).toHaveBeenCalledTimes(1)
})
it('원격 페이지가 보낸 IPC 는 신뢰하지 않는다', () => {
expect(isTrustedIpcSender({ senderFrame: { url: 'file:///C:/app/index.html' } } as never)).toBe(true)
expect(isTrustedIpcSender({ senderFrame: { url: 'https://evil.example/' } } as never)).toBe(false)
expect(isTrustedIpcSender({ senderFrame: null } as never)).toBe(false)
})
})
describe('SYSTEM.OPEN_EXTERNAL IPC', () => {
it('file:·원격 발신자 요청을 거부하고 https 는 연다', async () => {
const handlers = new Map<string, Handler>()
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => {
handlers.set(channel, handler)
})
const { registerWindowHandlers } = await import('../../../src/main/ipc/window-handlers')
registerWindowHandlers()
const handler = handlers.get(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL)!
const appEvent = { senderFrame: { url: 'file:///C:/app/index.html' } }
const remoteEvent = { senderFrame: { url: 'https://evil.example/' } }
await expect(handler(appEvent, { url: 'file:///C:/Windows/System32/calc.exe' })).resolves.toMatchObject({ success: false })
await expect(handler(remoteEvent, { url: 'https://example.com' })).resolves.toMatchObject({ success: false })
expect(shell.openExternal).not.toHaveBeenCalled()
await expect(handler(appEvent, { url: 'https://example.com' })).resolves.toMatchObject({ success: true })
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com')
})
})

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isAllowedExternalUrl, isAppOrigin } from '../src/url-policy'
describe('isAllowedExternalUrl', () => {
it('allows https and mailto by default', () => {
expect(isAllowedExternalUrl('https://d3ro.chanpaca.net/app/billing')).toBe(true)
expect(isAllowedExternalUrl('mailto:help@example.com')).toBe(true)
})
it.each([
'file:///C:/Windows/System32/calc.exe',
'javascript:alert(1)',
'search-ms:query=x&crumb=location:\\\\evil\\share',
'ms-msdt:/id PCWDiagnostic',
'\\\\evil.example\\share\\payload.exe',
'//evil.example/x',
'd3ro-voice://auth-callback#access_token=x',
'http://example.com',
'https://user:pass@example.com',
'',
'not a url',
])('rejects %s', (url) => {
expect(isAllowedExternalUrl(url)).toBe(false)
})
it('allows extra exact origins (dev web app) without widening schemes', () => {
const options = { allowOrigins: ['http://localhost:3000'] }
expect(isAllowedExternalUrl('http://localhost:3000/app/billing?tier=pro', options)).toBe(true)
expect(isAllowedExternalUrl('http://localhost:3001/app', options)).toBe(false)
expect(isAllowedExternalUrl('http://evil.example', options)).toBe(false)
})
})
describe('isAppOrigin', () => {
it('matches the packaged file renderer and the dev server origin only', () => {
const origins = ['file://', 'http://localhost:5173']
expect(isAppOrigin('file:///C:/app/resources/app.asar/out/renderer/index.html#/meetings', origins)).toBe(true)
expect(isAppOrigin('http://localhost:5173/#/settings', origins)).toBe(true)
expect(isAppOrigin('http://localhost:5174/', origins)).toBe(false)
expect(isAppOrigin('https://evil.example/', origins)).toBe(false)
})
it('does not treat file: as app origin unless allowed', () => {
expect(isAppOrigin('file:///C:/x.html', ['http://localhost:5173'])).toBe(false)
})
})

View file

@ -0,0 +1,59 @@
// packages/core/src/url-policy.ts
// 외부 URL·내비게이션 정책 SSOT. 순수 함수만 둔다 — Electron 어댑터는 데스크톱 main이 맡는다.
//
// - 앱 밖으로 넘기는 URL(OS 기본 핸들러)은 허용 scheme만 연다. file:, UNC, ms-*, search-ms: 같은
// OS 핸들러는 원격 코드 실행 경로가 되므로 막는다.
// - 앱 창은 앱 자신의 오리진 밖으로 이동하지 않는다(원격 페이지가 preload API를 얻지 못하게).
export interface ExternalUrlPolicyOptions {
/** 허용할 scheme(콜론 포함, 소문자). 기본 https:·mailto: */
allowSchemes?: readonly string[]
/** 추가로 허용할 정확한 오리진(예: 개발용 http://localhost:3000). */
allowOrigins?: readonly string[]
}
export const DEFAULT_EXTERNAL_SCHEMES: readonly string[] = ['https:', 'mailto:']
function parse(url: string): URL | null {
if (typeof url !== 'string' || url.trim() === '' || url.length > 8192) return null
// 백슬래시로 시작하는 UNC(\\server\share)는 URL 파서가 상대 경로로 보지 않도록 먼저 거른다.
if (url.startsWith('\\\\') || url.startsWith('//')) return null
try {
return new URL(url)
} catch {
return null
}
}
/** OS 기본 핸들러(브라우저·메일)로 넘겨도 되는 URL인지. */
export function isAllowedExternalUrl(url: string, options: ExternalUrlPolicyOptions = {}): boolean {
const parsed = parse(url)
if (!parsed) return false
const origin = parsed.origin
if (options.allowOrigins?.some((allowed) => allowed === origin)) return true
const schemes = options.allowSchemes ?? DEFAULT_EXTERNAL_SCHEMES
if (!schemes.includes(parsed.protocol.toLowerCase())) return false
if (parsed.protocol === 'https:') {
// 자격 증명이 박힌 URL(https://user@evil)이나 호스트 없는 URL은 열지 않는다.
if (!parsed.hostname || parsed.username || parsed.password) return false
}
return true
}
/**
* URL이 앱 자신의 페이지인지. appOrigins는 'file://' 또는 'http://localhost:5173' 같은 오리진.
* file: 은 오리진이 'null'이라 scheme으로 비교한다.
*/
export function isAppOrigin(url: string, appOrigins: readonly string[]): boolean {
const parsed = parse(url)
if (!parsed) return false
for (const allowed of appOrigins) {
if (allowed === 'file://') {
if (parsed.protocol === 'file:') return true
continue
}
const allowedParsed = parse(allowed)
if (allowedParsed && allowedParsed.origin === parsed.origin) return true
}
return false
}

View file

@ -0,0 +1,33 @@
-- 20260928010000_sync_server_stamped_updated_at.sql
-- 동기화 커서는 서버 시각(updated_at)에 기대는데, 기존 moddatetime 트리거는 BEFORE UPDATE 뿐이라
-- INSERT 때는 클라이언트가 보낸 updated_at(오프라인 편집 시각·느린 시계·backfill된 옛 행)이 그대로 남았다.
-- 그러면 이미 동기화된 다른 기기의 keyset 커서(updated_at > cursor - 60s) 뒤로 들어가 영영 내려받히지 않는다.
-- 데스크톱은 이제 updated_at 을 보내지 않지만, 옛 클라이언트·다른 경로도 막기 위해 INSERT 에도 서버 시각을 찍는다.
--
-- public.moddatetime() 은 이 프로젝트의 plpgsql 함수(NEW.updated_at = now())라 INSERT 에도 쓸 수 있다.
-- 적용 전 검토 필요: 운영 DB에 직접 적용하지 말 것(릴리스 절차로 배포).
DROP TRIGGER IF EXISTS set_updated_at_on_insert_history ON public.history;
CREATE TRIGGER set_updated_at_on_insert_history
BEFORE INSERT ON public.history
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
DROP TRIGGER IF EXISTS set_updated_at_on_insert_dictionary ON public.dictionary;
CREATE TRIGGER set_updated_at_on_insert_dictionary
BEFORE INSERT ON public.dictionary
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
DROP TRIGGER IF EXISTS set_updated_at_on_insert_meetings ON public.meetings;
CREATE TRIGGER set_updated_at_on_insert_meetings
BEFORE INSERT ON public.meetings
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
DROP TRIGGER IF EXISTS set_updated_at_on_insert_meeting_memos ON public.meeting_memos;
CREATE TRIGGER set_updated_at_on_insert_meeting_memos
BEFORE INSERT ON public.meeting_memos
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
DROP TRIGGER IF EXISTS set_updated_at_on_insert_meeting_documents ON public.meeting_documents;
CREATE TRIGGER set_updated_at_on_insert_meeting_documents
BEFORE INSERT ON public.meeting_documents
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();