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

@ -4,7 +4,9 @@
// 실제 동기화는 services/sync/SyncEngine 이 한다 — 이 서비스는 인증·수명주기·트리거를 맡는다.
import { EventEmitter } from 'events'
import { shell, app, safeStorage } from 'electron'
import fs from 'fs'
import path from 'path'
import { app, safeStorage } from 'electron'
import {
createClient,
type SupabaseClient,
@ -19,10 +21,14 @@ import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalMode
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LicenseTier } from '@d3ro/core/types'
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 { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox'
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 { SETTINGS_ROW_ID, SYNCED_CONFIG_KEYS, isApplyingRemoteSettings } from './sync/settings-sync'
import { AUDIO_BUCKET, listLocalAudioOwners } from './sync/audio-sync'
@ -38,20 +44,12 @@ const HEARTBEAT_MS = 5 * 60_000
/** sync_state 에 저장하는 이 사용자 DB의 등록 기기 id */
const DEVICE_STATE_KEY = 'device:id'
/** Realtime 변경 감시 대상. sync_tombstones = 다른 기기의 삭제 */
const REALTIME_TABLES = [
'history',
'dictionary',
'meetings',
'meeting_memos',
'meeting_documents',
'memo_tags',
'custom_instructions',
'user_templates',
'knowledge_documents',
'user_settings',
'sync_tombstones',
] as const
/** 로그아웃·계정 전환 때 진행 중인 동기화가 멈추기를 기다리는 최대 시간 */
const ENGINE_STOP_TIMEOUT_MS = 5_000
/** 로그인 버튼을 누른 뒤 deep link 콜백을 받아들이는 시간 — 요청하지 않은 콜백(login CSRF)을 거른다 */
const SIGN_IN_WINDOW_MS = 10 * 60_000
/** refresh token 파일 이름(userData 아래) */
const TOKEN_FILE = 'cloud-sync.token'
// ============================================================
// 타입
@ -103,9 +101,17 @@ class CloudSyncService extends EventEmitter {
private _initialized = false
private _realtimeChannel: RealtimeChannel | null = null
private _engine: SyncEngine | null = null
private _flushTimer: NodeJS.Timeout | null = null
private _pullTimer: NodeJS.Timeout | null = null
private _heartbeatTimer: NodeJS.Timeout | null = null
/** 로그인 시작(startSignIn) 후 콜백을 받아들이는 기한. null이면 진행 중인 로그인이 없다 */
private _pendingSignInUntil: number | 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 클라이언트 생성, 저장된 세션 복원.
@ -129,6 +135,18 @@ class CloudSyncService extends EventEmitter {
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 로 올린다(원격 반영 중엔 제외).
onConfigChanged((event) => {
if (isApplyingRemoteSettings()) return
@ -201,9 +219,17 @@ class CloudSyncService extends EventEmitter {
session: Session,
opts: { reason: 'restore' | 'signin' }
): Promise<void> {
this._session = session
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 격리)
try {
const { created, dbPath } = openForUser(userId)
@ -245,9 +271,8 @@ class CloudSyncService extends EventEmitter {
)
}
// 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다.
this._engine?.dispose()
this._engine = null
// 4) 동기화 엔진 — 사용자 DB가 열린 뒤, 이 사용자 전용으로 만든다(엔진은 이 DB에 묶인다).
// 이전 엔진은 0)에서 이미 해제했다.
if (this._client) {
const engine = new SyncEngine({ remote: new SupabaseSyncRemote(this._client), userId })
engine.on('progress', (payload) => this.emit('sync-progress', payload))
@ -358,7 +383,8 @@ class CloudSyncService extends EventEmitter {
// 2) Realtime 구독·주기 작업 종료, 엔진 해제
await this.stopRealtime()
this._stopHeartbeat()
this._engine?.dispose()
const engine = this._engine
engine?.dispose()
this._engine = null
// 3) 사용자가 직접 로그아웃하면 기기 목록에서도 빠진다(원격 해제로 로그아웃될 때는 이미 해제됨).
@ -392,6 +418,11 @@ class CloudSyncService extends EventEmitter {
// 6) 사용자 DB 닫고 로컬 모드 DB로 복귀
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (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()
try {
const { dbPath } = openLocal()
@ -587,19 +618,22 @@ class CloudSyncService extends EventEmitter {
const userId = this._session.user.id
// persistSession: false 에서는 Supabase realtime 클라이언트가 auth state change를
// 자동 추적하지 않는다. access_token을 명시적으로 realtime에 주입해서
// postgres_changes 채널이 RLS를 통과하도록 한다. (TIMED_OUT 버그 픽스)
// postgres_changes 채널이 RLS를 통과하도록 access token을 realtime에 넣는다(TIMED_OUT 버그 픽스).
// 로그인 시점의 토큰은 1시간 뒤 만료되므로, 클라이언트의 현재 세션(필요하면 갱신)에서 꺼낸다 —
// 만료된 토큰으로 재구독하면 CHANNEL_ERROR만 반복한다.
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) {
logger.warn(
`Realtime setAuth 실패 (계속 진행): ${err instanceof Error ? err.message : String(err)}`
)
}
if (!this._session || !this._client) return
let channel = this._client.channel(`cloud-sync:${userId}`)
for (const table of REALTIME_TABLES) {
for (const table of realtimeTables()) {
channel = channel.on(
'postgres_changes',
{ 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}`)
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가 설정되지 않았습니다')
}
this._consumePendingSignIn()
const { data, error } = await this._client.auth.exchangeCodeForSession(code)
if (error || !data.session) {
throw new D3ROError(
@ -754,6 +810,7 @@ class CloudSyncService extends EventEmitter {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
}
this._consumePendingSignIn()
const { data, error } = await this._client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
@ -805,20 +862,12 @@ class CloudSyncService extends EventEmitter {
this._scheduleFlush()
}
private _scheduleFlush(delayMs = FLUSH_DEBOUNCE_MS): void {
if (this._flushTimer) return
this._flushTimer = setTimeout(() => {
this._flushTimer = null
void this._runEngine('flush').catch(() => undefined)
}, delayMs)
private _scheduleFlush(): void {
this._scheduler.scheduleFlush()
}
private _schedulePull(delayMs = PULL_DEBOUNCE_MS): void {
if (this._pullTimer) return
this._pullTimer = setTimeout(() => {
this._pullTimer = null
void this._runEngine('pull').catch(() => undefined)
}, delayMs)
private _schedulePull(): void {
this._scheduler.schedulePull()
}
/** 수동 "업로드": 보관된 실패 항목도 다시 시도한다. */
@ -862,6 +911,11 @@ class CloudSyncService extends EventEmitter {
return result
} catch (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}`)
this.emit('sync-error', { error: message })
throw err
@ -905,33 +959,23 @@ class CloudSyncService extends EventEmitter {
}
private _startHeartbeat(): void {
this._stopHeartbeat()
this._heartbeatTimer = setInterval(() => {
if (!this._session) return
void this._checkInDevice('heartbeat')
// 재구독을 포기한 채널(3회 실패)을 되살린다 — 네트워크가 돌아온 뒤에도 실시간이 끊겨 있지 않게.
if (this._realtimeChannel?.state !== 'joined') {
this._realtimeRetryCount = 0
void this.startRealtime().catch(() => undefined)
}
// Realtime 이벤트를 놓쳤을 때의 안전망
void this._runEngine('full').catch(() => undefined)
}, HEARTBEAT_MS)
this._scheduler.startHeartbeat()
}
private _onHeartbeat(): void {
if (!this._session) return
void this._checkInDevice('heartbeat')
// 재구독을 포기한 채널(3회 실패)을 되살린다 — 네트워크가 돌아온 뒤에도 실시간이 끊겨 있지 않게.
if (this._realtimeChannel?.state !== 'joined') {
this._realtimeRetryCount = 0
void this.startRealtime().catch(() => undefined)
}
// Realtime 이벤트를 놓쳤을 때의 안전망
void this._runEngine('full').catch(() => undefined)
}
private _stopHeartbeat(): 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
}
this._scheduler.stop()
}
/**
@ -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 {
try {
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)}`)
}
this._tokens().save(token)
}
private _loadStoredRefreshToken(): string | null {
try {
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
}
return this._tokens().load()
}
private _clearStoredRefreshToken(): void {
try {
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)) {
fs.unlinkSync(tokenPath)
}
} catch {
// ignore
}
this._tokens().clear()
}
/** 테스트 전용: 타이머·auth 구독을 해제한다. */
disposeForTests(): void {
this._scheduler.stop()
this._unbindAuthEvents?.()
this._unbindAuthEvents = null
void this._client?.auth.stopAutoRefresh().catch(() => undefined)
this.removeAllListeners()
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
@ -1029,7 +1069,7 @@ class CloudSyncService extends EventEmitter {
let instance: CloudSyncService | null = null
export function resetCloudSyncServiceForTests(): void {
if (instance) instance.removeAllListeners()
if (instance) instance.disposeForTests()
instance = null
}

View file

@ -1,10 +1,18 @@
// src/main/services/CustomInstructionService.ts
// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조.
// electron-store에 저장, 프리셋 5개 기본 제공.
// 계정 범위 저장소(users/<id>/custom-instructions.json)에 저장, 프리셋 5개 기본 제공.
// 계정이 바뀌면(로그인·로그아웃) 그 계정의 목록으로 다시 읽는다 — 다른 계정의 명령이 보이거나 올라가지 않게.
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { configGet } from './ConfigService'
import { getCloudSyncService } from './CloudSyncService'
import {
AccountScopedList,
currentAccountScope,
electronStoreCollectionFactory,
onAccountScopeChanged,
type UserScopedCollectionFactory,
} from './account-scope'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
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와 별도)
// 실제로는 electron-store의 별도 인스턴스를 사용하지만,
// Phase 6에서는 메모리 + configGet/configSet 패턴으로 단순화
export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const
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 initialized = false
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
let store: AccountScopedList<CustomInstruction> | null = null
let unsubscribeScope: (() => void) | null = null
function loadInstructions(): CustomInstruction[] {
// electron-store에서 로드 시도
/** 계정 범위가 생기기 전(전역 config)의 목록 — 로컬 모드 범위로 한 번 옮긴다. */
function readLegacyInstructions(): CustomInstruction[] | null {
try {
const stored = configGet('customInstructions') as CustomInstruction[] | undefined
if (Array.isArray(stored) && stored.length > 0) {
return stored
}
return Array.isArray(stored) && stored.length > 0 ? stored : null
} 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()
return BUILTIN_INSTRUCTIONS.map((b) => ({
...b,
createdAt: now,
updatedAt: now
}))
if (!stored || stored.length === 0) return builtinDefaults(now)
// 가져온 목록에 프리셋이 빠져 있으면 채운다(프리셋은 지울 수 없다).
const ids = new Set(stored.map((i) => i.id))
return [...stored, ...builtinDefaults(now).filter((b) => !ids.has(b.id))]
}
function saveInstructions(): void {
try {
configSet('customInstructions', instructions)
getStore().save(instructions)
} catch (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
// ============================================================
@ -113,8 +184,9 @@ function saveInstructions(): void {
class CustomInstructionService {
initialize(): void {
if (initialized) return
instructions = loadInstructions()
instructions = loadInstructions(currentAccountScope())
initialized = true
unsubscribeScope ??= onAccountScopeChanged(reloadForScope)
logger.info(`CustomInstructionService initialized (${instructions.length} instructions)`)
}
@ -133,6 +205,8 @@ class CustomInstructionService {
if (!input.prompt.trim()) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty')
}
// 서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
assertWithinLimits(input)
const now = Date.now()
const instruction: CustomInstruction = {
id: crypto.randomUUID(),
@ -158,6 +232,7 @@ class CustomInstructionService {
if (index === -1) return null
const existing = instructions[index]
assertWithinLimits(existing.isBuiltin ? { prompt: data.prompt } : data)
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
if (existing.isBuiltin) {
@ -285,8 +360,14 @@ export function getCustomInstructionService(): CustomInstructionService {
return instance
}
export function resetCustomInstructionServiceForTests(): void {
export function resetCustomInstructionServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
): void {
instance = null
initialized = false
instructions = []
unsubscribeScope?.()
unsubscribeScope = null
store = null
collectionFactory = factory
}

View file

@ -6,6 +6,13 @@ import { EventEmitter } from 'events'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import {
AccountScopedList,
currentAccountScope,
electronStoreCollectionFactory,
onAccountScopeChanged,
type UserScopedCollectionFactory,
} from './account-scope'
import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -67,31 +74,28 @@ const BUILTIN_TEMPLATES: DictationTemplate[] = [
},
]
type TemplateStoreSchema = {
templates: DictationTemplate[]
}
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
constructor() {
constructor(factory: UserScopedCollectionFactory = collectionFactory) {
super()
this._store = new Store<TemplateStoreSchema>({
this._list = new AccountScopedList<DictationTemplate>({
name: 'dictation-templates',
defaults: {
templates: [...BUILTIN_TEMPLATES],
},
factory,
readLegacy: readLegacyTemplates,
})
// 프리셋이 없으면 추가
this._ensureBuiltins()
this._loadScope(currentAccountScope())
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
}
// ── CRUD ──
getAll(): DictationTemplate[] {
return this._store.get('templates', [])
return [...this._items]
}
getById(id: string): DictationTemplate | null {
@ -113,7 +117,7 @@ class DictationTemplateService extends EventEmitter {
const templates = this.getAll()
templates.push(template)
this._store.set('templates', templates)
this._save(templates)
getCloudSyncService().pushOne('user_templates', template.id)
return template
@ -137,7 +141,7 @@ class DictationTemplateService extends EventEmitter {
}
templates[idx] = updated
this._store.set('templates', templates)
this._save(templates)
if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id)
return updated
}
@ -152,10 +156,7 @@ class DictationTemplateService extends EventEmitter {
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Cannot delete builtin template')
}
this._store.set(
'templates',
templates.filter((t) => t.id !== id),
)
this._save(templates.filter((t) => t.id !== id))
getCloudSyncService().pushDelete('user_templates', id)
}
@ -166,7 +167,7 @@ class DictationTemplateService extends EventEmitter {
if (idx === -1) templates.push(template)
else if (!templates[idx].isBuiltin) templates[idx] = template
else return
this._store.set('templates', templates)
this._save(templates)
}
/** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */
@ -174,7 +175,7 @@ class DictationTemplateService extends EventEmitter {
const templates = this.getAll()
const target = templates.find((t) => t.id === id)
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
}
@ -307,15 +308,50 @@ class DictationTemplateService extends EventEmitter {
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 {
this._unsubscribeScope()
this.cancelSession()
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
@ -326,6 +362,10 @@ export function getDictationTemplateService(): DictationTemplateService {
return instance
}
export function resetDictationTemplateServiceForTests(): void {
export function resetDictationTemplateServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
): void {
instance?.dispose()
instance = null
collectionFactory = factory
}

View file

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

View file

@ -5,6 +5,13 @@ import { EventEmitter } from 'events'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import {
AccountScopedList,
currentAccountScope,
electronStoreCollectionFactory,
onAccountScopeChanged,
type UserScopedCollectionFactory,
} from './account-scope'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
MeetingDocTemplate,
@ -146,28 +153,27 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
},
]
type MeetingDocTemplateStoreSchema = {
templates: MeetingDocTemplate[]
}
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()
this._store = new Store<MeetingDocTemplateStoreSchema>({
this._list = new AccountScopedList<MeetingDocTemplate>({
name: 'meeting-doc-templates',
defaults: {
templates: [...BUILTIN_TEMPLATES],
},
factory,
readLegacy: readLegacyTemplates,
})
this._ensureBuiltins()
this._loadScope(currentAccountScope())
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
}
// ── CRUD ──
getAll(): MeetingDocTemplate[] {
return this._store.get('templates', [])
return [...this._items]
}
getById(id: string): MeetingDocTemplate | null {
@ -188,7 +194,7 @@ class MeetingDocTemplateService extends EventEmitter {
const templates = this.getAll()
templates.push(template)
this._store.set('templates', templates)
this._save(templates)
getCloudSyncService().pushOne('user_templates', template.id)
logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`)
return template
@ -214,7 +220,7 @@ class MeetingDocTemplateService extends EventEmitter {
}
templates[idx] = updated
this._store.set('templates', templates)
this._save(templates)
if (!updated.isBuiltin) getCloudSyncService().pushOne('user_templates', updated.id)
logger.info(`회의 문서 템플릿 수정: ${params.id}`)
return updated
@ -236,10 +242,7 @@ class MeetingDocTemplateService extends EventEmitter {
)
}
this._store.set(
'templates',
templates.filter((t) => t.id !== id),
)
this._save(templates.filter((t) => t.id !== id))
getCloudSyncService().pushDelete('user_templates', id)
logger.info(`회의 문서 템플릿 삭제: ${id}`)
}
@ -251,7 +254,7 @@ class MeetingDocTemplateService extends EventEmitter {
if (idx === -1) templates.push(template)
else if (!templates[idx].isBuiltin) templates[idx] = template
else return
this._store.set('templates', templates)
this._save(templates)
}
/** 동기화: 다른 기기에서 지운 사용자 템플릿을 지운다. */
@ -259,7 +262,7 @@ class MeetingDocTemplateService extends EventEmitter {
const templates = this.getAll()
const target = templates.find((t) => t.id === id)
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
}
@ -273,15 +276,49 @@ class MeetingDocTemplateService extends EventEmitter {
}
}
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 {
this._unsubscribeScope()
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
@ -292,6 +329,10 @@ export function getMeetingDocTemplateService(): MeetingDocTemplateService {
return instance
}
export function resetMeetingDocTemplateServiceForTests(): void {
export function resetMeetingDocTemplateServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
): void {
instance?.dispose()
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된다(오프라인·재시작에도 유실 없음).
// - pull은 서버 시각(updated_at) keyset 커서로 가져온다. 로컬 시계를 쓰지 않는다.
// - 아직 올라가지 않은 로컬 변경이 있는 행은 pull이 덮지 않는다.
// - 삭제는 sync_tombstones로 양방향 전파된다.
// - 삭제는 sync_tombstones로 양방향 전파된다. 원격 삭제는 push보다 먼저 반영한다(좀비 행 방지).
// - 커서·플래그는 사용자 DB 안(sync_state)에 있어 계정별로 분리된다.
// - 엔진은 만들어질 때 열린 사용자 DB에 묶인다. 해제되거나 DB가 바뀌면 await 뒤마다 멈추고 아무것도 쓰지 않는다.
import { EventEmitter } from 'events'
import { getLogger } from '../LoggerService'
import { TABLE_ADAPTERS, adapterFor, type PushContext, type SyncAdapter } from './sync-adapters'
import { getCurrentUserId } from '../../db'
import {
fetchRemoteMemoTagKeys,
listLocalMemoTagKeys,
pushMemoTagAdds,
pushMemoTagRemovals,
reconcileMemoTags,
} from './memo-tag-sync'
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync'
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync'
import {
applyRemoteBuiltins,
editedBuiltinsDifferingFrom,
fetchRemoteBuiltins,
pushBuiltinPrompts,
} from './builtin-instruction-sync'
SyncAbortedError,
TABLE_ADAPTERS,
adapterFor,
type ApplyResult,
type PushContext,
type SyncAdapter,
} from './sync-adapters'
import { fetchRemoteMemoTagKeys, listLocalMemoTagKeys, reconcileMemoTags } from './memo-tag-sync'
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners } from './audio-sync'
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings } from './settings-sync'
import { applyRemoteBuiltins, editedBuiltinsDifferingFrom, fetchRemoteBuiltins } from './builtin-instruction-sync'
import { deleteOrder, upsertOrder } from './sync-registry'
import {
completeEntry,
dropEntry,
@ -42,6 +41,7 @@ import {
import {
emptyRunResult,
isSyncEntity,
parentOf,
type PushOutcome,
type RemoteCursor,
type RemoteRow,
@ -62,6 +62,12 @@ const BACKFILL_FLAG = 'backfill:v1'
/** 되감은 커서의 id 하한. 행 테이블은 uuid, sync_tombstones는 bigserial이다. */
const MIN_UUID = '00000000-0000-0000-0000-000000000000'
const MIN_SERIAL = '0'
/** 반영을 미룬 행(부모 없음·일시 오류)을 다시 가져와 보는 최대 횟수. 넘으면 포기한다(남의 팀 회의 메모 등). */
export const MAX_DEFERRED_ATTEMPTS = 5
/** 엔티티마다 기억하는 미룬 행 수 상한 */
const MAX_DEFERRED_IDS = 500
/** 한 번의 pull에서 다시 가져와 보는 미룬 행 수 */
const DEFERRED_RETRY_BATCH = 50
export interface SyncEngineOptions {
remote: SyncRemote
@ -83,6 +89,10 @@ function cursorKey(entity: SyncEntity | 'tombstones'): string {
return `cursor:${entity}`
}
function deferredKey(entity: SyncEntity): string {
return `deferred:${entity}`
}
function parseCursor(value: string | null): RemoteCursor | null {
if (!value) return null
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만큼 되감은 시작점. */
function rewind(cursor: RemoteCursor | null, minId: string): RemoteCursor | null {
if (!cursor) return null
@ -127,10 +151,16 @@ function rowCursor(row: RemoteRow, column: string): RemoteCursor | null {
return { ts, id: String(id) }
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
export class SyncEngine extends EventEmitter {
private readonly remote: SyncRemote
private readonly userId: string
private readonly now: () => number
/** 엔진이 만들어질 때 열려 있던 사용자 DB. 다른 DB가 열리면 쓰지 않는다. */
private readonly boundDbUserId: string | null
/** flush/pull/backfill을 한 줄로 세운다 — 서로 끼어들면 pending 판정이 흔들린다. */
private queue: Promise<unknown> = Promise.resolve()
private disposed = false
@ -140,6 +170,7 @@ export class SyncEngine extends EventEmitter {
this.remote = options.remote
this.userId = options.userId
this.now = options.now ?? Date.now
this.boundDbUserId = getCurrentUserId()
}
dispose(): void {
@ -147,18 +178,37 @@ export class SyncEngine extends EventEmitter {
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 {
const counts = outboxCounts()
const lastPull = getSyncState('lastPullAt')
return { ...counts, lastPullAt: lastPull ? Number(lastPull) : null }
}
/** 로그인 직후/복원 직후: 최초 1회 대조 → push → pull. */
/** 로그인 직후/복원 직후: 원격 삭제 반영 → 최초 1회 대조 → push → pull. */
runFullSync(): Promise<SyncRunResult> {
return this.serialize(async () => {
const result = emptyRunResult()
await this.pullTombstonesBeforePush(result, true)
await this.backfillIfNeeded()
await this.flushInner(result)
await this.flushInner(result, true)
await this.pullInner(result)
return result
})
@ -168,8 +218,10 @@ export class SyncEngine extends EventEmitter {
return this.serialize(async () => {
if (options.releaseParked) releaseParkedEntries()
const result = emptyRunResult()
const backfillPending = getSyncState(BACKFILL_FLAG) !== 'done'
if (backfillPending) await this.pullTombstonesBeforePush(result, true)
await this.backfillIfNeeded()
await this.flushInner(result)
await this.flushInner(result, backfillPending)
return result
})
}
@ -183,72 +235,80 @@ export class SyncEngine extends EventEmitter {
}
private serialize<T>(task: () => Promise<T>): Promise<T> {
const run = this.queue.then(
() => task(),
() => task()
)
const guarded = (): Promise<T> => {
// 해제 뒤에 줄을 선 작업은 시작하지 않는다(로그아웃 뒤 로컬 모드 DB에 쓰지 않게).
if (!this.isCurrent()) return Promise.reject(new SyncAbortedError())
return task()
}
const run = this.queue.then(guarded, guarded)
this.queue = run.catch(() => undefined)
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에 넣는다.
* 서버에 같은 판이 이미 있으면 다시 올리지 않는다(다른 기기의 편집을 덮지 않는다).
* 호출 전에 원격 삭제(tombstone)가 반영돼 있어야 한다 — 지운 행을 되살리지 않도록.
*/
private async backfillIfNeeded(): Promise<void> {
if (getSyncState(BACKFILL_FLAG) === 'done') return
const now = this.now()
let queued = 0
const ctx = this.context()
const queue: Array<{ entity: SyncEntity; id: string }> = []
for (const adapter of TABLE_ADAPTERS) {
const local = adapter.listLocalVersions()
if (local.length === 0) continue
const remoteVersions = await this.fetchRemoteVersions(adapter)
this.checkpoint()
for (const row of local) {
const remoteUpdatedAt = remoteVersions.get(row.id)
if (remoteUpdatedAt === undefined || row.updatedAt > remoteUpdatedAt) {
enqueueChange(adapter.entity, row.id, 'upsert', now)
queued++
queue.push({ entity: adapter.entity, id: row.id })
}
}
}
const localTags = listLocalMemoTagKeys()
if (localTags.size > 0) {
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
this.checkpoint()
for (const key of localTags) {
if (!remoteTags.has(key)) {
enqueueChange('memo_tags', key, 'upsert', now)
queued++
}
if (!remoteTags.has(key)) queue.push({ entity: 'memo_tags', id: key })
}
}
const ctx: PushContext = { remote: this.remote, userId: this.userId }
// 설정: 서버에 행이 없을 때만 로컬 값을 올린다. 있으면 다른 기기의 선택이 우선(pull이 반영).
if (!(await fetchRemoteSettings(ctx))) {
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now)
queued++
}
const remoteSettings = await fetchRemoteSettings(ctx)
this.checkpoint()
if (!remoteSettings) queue.push({ entity: 'user_settings', id: SETTINGS_ROW_ID })
// 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다).
for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) {
enqueueChange('builtin_instructions', id, 'upsert', now)
queued++
}
const remoteBuiltins = await fetchRemoteBuiltins(ctx)
this.checkpoint()
for (const id of editedBuiltinsDifferingFrom(remoteBuiltins)) queue.push({ entity: 'builtin_instructions', id })
if (isAudioSyncEnabled()) {
for (const owner of ['history', 'meeting'] as const) {
const local = listLocalAudioOwners(owner)
if (local.length === 0) continue
const remote = await fetchRemoteAudioOwners(this.remote, this.userId, owner)
this.checkpoint()
for (const id of local) {
if (!remote.has(id)) {
enqueueChange(owner === 'history' ? 'history_audio' : 'meeting_audio', id, 'upsert', now)
queued++
}
if (!remote.has(id)) queue.push({ entity: owner === 'history' ? 'history_audio' : 'meeting_audio', id })
}
}
}
this.checkpoint()
for (const item of queue) enqueueChange(item.entity, item.id, 'upsert', 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>> {
@ -264,6 +324,7 @@ export class SyncEngine extends EventEmitter {
columns: 'id,updated_at',
filters: adapter.pull?.filters,
})
this.checkpoint()
for (const row of page) {
if (typeof row.id === 'string' && typeof row.updated_at === 'string') {
const at = Date.parse(row.updated_at)
@ -280,10 +341,29 @@ export class SyncEngine extends EventEmitter {
// ── 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())
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[] }>()
for (const entry of due) {
if (!isSyncEntity(entry.entity)) continue
@ -292,60 +372,22 @@ export class SyncEngine extends EventEmitter {
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
for (const entity of upsertOrder) {
const bucket = byEntity.get(entity)
// 부모 먼저 upsert (순서는 sync-types SYNC_ENTITY_SPECS 가 정본)
for (const handler of upsertOrder()) {
const bucket = byEntity.get(handler.entity)
if (!bucket || bucket.upserts.length === 0 || networkDown) continue
const ids = bucket.upserts.map((e) => e.rowId)
const outcomes = await this.pushUpserts(ctx, entity, ids)
networkDown = this.settle(bucket.upserts, outcomes, result, entity)
const outcomes = await handler.push(ctx, bucket.upserts.map((e) => e.rowId))
this.checkpoint()
networkDown = this.settle(bucket.upserts, outcomes, result, handler.entity)
}
// 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다)
for (const entity of [...order].reverse()) {
const bucket = byEntity.get(entity)
if (!bucket || bucket.deletes.length === 0 || networkDown) continue
const ids = bucket.deletes.map((e) => e.rowId)
const outcomes =
entity === 'memo_tags'
? 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)
for (const handler of deleteOrder()) {
const bucket = byEntity.get(handler.entity)
if (!bucket || bucket.deletes.length === 0 || networkDown || !handler.pushDeletes) continue
const outcomes = await handler.pushDeletes(ctx, bucket.deletes.map((e) => e.rowId))
this.checkpoint()
networkDown = this.settle(bucket.deletes, outcomes, result, handler.entity)
}
}
@ -371,24 +413,29 @@ export class SyncEngine extends EventEmitter {
return networkDown
}
private requireAdapter(entity: SyncEntity): SyncAdapter {
const adapter = adapterFor(entity)
if (!adapter) throw new Error(`No sync adapter for ${entity}`)
return adapter
}
// ── pull ──────────────────────────────────────────────
private async pullInner(result: SyncRunResult): Promise<void> {
const changed = new Set<SyncEntity>(result.changed)
/** 이번 pull에서 실패한 엔티티 — 자식은 커서를 넘기지 않도록 건너뛴다. */
const failed = new Set<SyncEntity>()
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 {
const applied = await this.pullEntity(adapter)
if (applied > 0) changed.add(adapter.entity)
result.pulled += applied
} 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}`)
logger.warn(`Pull ${adapter.entity} failed: ${message}`)
}
@ -398,60 +445,85 @@ export class SyncEngine extends EventEmitter {
const removed = await this.pullTombstones(changed)
result.deleted += removed
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(`tombstones: ${message}`)
if (err instanceof SyncAbortedError) throw err
const message = errorMessage(err)
if (!result.errors.includes(`tombstones: ${message}`)) result.errors.push(`tombstones: ${message}`)
logger.warn(`Pull tombstones failed: ${message}`)
}
try {
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now())
if (tagChanges > 0) {
changed.add('memo_tags')
result.pulled += tagChanges
if (!failed.has('history')) {
try {
const remoteTags = await fetchRemoteMemoTagKeys(this.remote, this.userId)
this.checkpoint()
const tagChanges = reconcileMemoTags(remoteTags, pendingOps('memo_tags'), this.now())
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 {
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)) {
changed.add('user_settings')
result.pulled++
}
}
} 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}`)
logger.warn(`Pull user_settings failed: ${message}`)
}
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()))
if (applied > 0) {
changed.add('custom_instructions')
result.pulled += applied
}
} 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}`)
logger.warn(`Pull builtin_instructions failed: ${message}`)
}
this.checkpoint()
result.changed = [...changed]
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> {
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)))
let after = rewind(saved, MIN_UUID)
let newest = saved
let applied = 0
let seen = 0
for (;;) {
const page = await this.remote.fetchPage({
@ -463,6 +535,7 @@ export class SyncEngine extends EventEmitter {
columns: adapter.pull?.columns,
filters: adapter.pull?.filters,
})
this.checkpoint()
if (page.length === 0) break
// 페이지마다 새로 읽는다: 페이지 사이에 사용자가 편집했을 수 있다.
const pending = pendingOps(adapter.entity)
@ -470,12 +543,13 @@ export class SyncEngine extends EventEmitter {
seen++
const id = typeof row.id === 'string' ? row.id : null
if (id && !pending.has(id)) {
try {
if (await adapter.applyRemote(row, { remote: this.remote, userId: this.userId })) applied++
} catch (err) {
logger.warn(
`Apply ${adapter.entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}`
)
const outcome = await this.applyOne(adapter, row, id)
if (outcome === 'deferred') {
// 커서는 넘기되 id를 기억해 다음 pull에서 다시 가져온다(부모 회의·청크가 늦게 오는 경우).
deferred.set(id, deferred.get(id) ?? 0)
} else {
deferred.delete(id)
if (outcome) applied++
}
}
const cursor = rowCursor(row, 'updated_at')
@ -487,10 +561,68 @@ export class SyncEngine extends EventEmitter {
if (!next) break
after = next
}
this.checkpoint()
this.saveDeferred(adapter.entity, deferred, deferredBefore)
if (newest && newest !== saved) setSyncState(cursorKey(adapter.entity), JSON.stringify(newest))
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> {
const saved = parseCursor(getSyncState(cursorKey('tombstones')))
@ -506,6 +638,7 @@ export class SyncEngine extends EventEmitter {
limit: PULL_PAGE_SIZE,
columns: 'id,table_name,row_id,deleted_at',
})
this.checkpoint()
if (page.length === 0) break
for (const row of page) {
const entity = row.table_name
@ -521,7 +654,7 @@ export class SyncEngine extends EventEmitter {
changed.add(entity)
}
} 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,
meetingMemos,
meetingSessions,
memoTags,
ragDocuments,
} from '../../db/schema'
import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types'
import { getCustomInstructionService } from '../CustomInstructionService'
import { getCustomInstructionService, instructionLimitViolation } from '../CustomInstructionService'
import { getDictationTemplateService } from '../DictationTemplateService'
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
import { getRAGService } from '../RAGService'
import { deleteHistoryLocally } from '../history-deletion'
import { purgeRemoteAudio } from './audio-sync'
import { pushMeetingSegments } from './transcript-sync'
import { dropEntry } from './sync-outbox'
@ -33,8 +33,31 @@ import {
export interface PushContext {
remote: SyncRemote
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 {
id: string
updatedAt: number
@ -48,7 +71,7 @@ export interface SyncAdapter {
push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
/** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */
applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise<boolean>
applyRemote(row: RemoteRow, ctx: PushContext): ApplyResult | Promise<ApplyResult>
/** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */
deleteLocal(id: string): boolean
}
@ -192,7 +215,8 @@ export function historyToRemote(r: typeof history.$inferSelect, userId: string):
summary_text: r.summaryText,
is_favorite: r.isFavorite,
created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt),
// updated_at 은 보내지 않는다: 서버 시각(INSERT 기본값·트리거)이 keyset 커서의 기준이다.
// 기기 시계·오프라인 편집 시각을 실으면 다른 기기의 커서 뒤로 들어가 영영 내려받히지 않는다.
}
}
@ -252,9 +276,8 @@ const historyAdapter: SyncAdapter = {
return true
},
deleteLocal(id) {
const db = getDatabase()
db.delete(memoTags).where(eq(memoTags.historyId, id)).run()
return db.delete(history).where(eq(history.id, id)).run().changes > 0
// 녹음 WAV까지 함께 지운다(폰에서 지운 기록의 목소리가 디스크에 남지 않게).
return deleteHistoryLocally({ ids: [id] }).deleted > 0
},
}
@ -272,7 +295,6 @@ export function dictionaryToRemote(r: typeof dictionary.$inferSelect, userId: st
usage_count: Math.round(r.usageCount),
last_used_at: nullableIso(r.lastUsedAt),
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),
error_message: r.errorMessage,
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,
timestamp_ms: Math.round(r.timestampMs),
created_at: iso(r.createdAt),
updated_at: iso(r.updatedAt ?? r.createdAt),
}
}
@ -540,8 +560,10 @@ const meetingMemosAdapter: SyncAdapter = {
const id = row.id
const meetingId = row.meeting_id
const content = str(row.content)
// 남의 팀 회의에 내가 단 메모는 부모 회의가 로컬에 없다 — 고아 행을 만들지 않는다.
if (!isUuid(id) || !isUuid(meetingId) || content === null || !hasLocalMeeting(meetingId)) return false
if (!isUuid(id) || !isUuid(meetingId) || content === null) return false
// 부모 회의가 아직 로컬에 없으면 고아 행을 만들지 않고 미룬다(회의 pull이 실패했을 수 있다).
// 남의 팀 회의에 단 내 메모는 끝내 부모가 오지 않는다 — 엔진이 몇 번 재시도 후 포기한다.
if (!hasLocalMeeting(meetingId)) return 'deferred'
const createdAt = ms(row.created_at, Date.now())
const values = {
sessionId: meetingId,
@ -577,7 +599,6 @@ export function meetingDocumentToRemote(r: typeof meetingDocuments.$inferSelect,
llm_model: r.llmModel,
llm_latency_ms: int(r.llmLatencyMs),
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 title = str(row.title)
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 values = {
sessionId: meetingId,
@ -637,13 +658,24 @@ const meetingDocumentsAdapter: SyncAdapter = {
// ── custom_instructions ─────────────────────────────────
// 데스크톱 프리셋(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 {
return {
id: i.id,
user_id: userId,
name: i.name.trim().slice(0, 80),
description: (i.description ?? '').trim().slice(0, 240),
prompt: i.prompt.trim().slice(0, 4000),
name: i.name.trim(),
description: (i.description ?? '').trim(),
prompt: i.prompt,
icon: (i.icon || 'sparkles').slice(0, 32),
sort_order: Math.max(0, Math.round(i.order)),
created_at: iso(i.createdAt),
@ -665,12 +697,19 @@ const customInstructionsAdapter: SyncAdapter = {
.map((id) => service.getById(id))
.filter((i): i is CustomInstruction => i !== null && !i.isBuiltin && isUuid(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(
ctx,
'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) {
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 = {
entity: 'user_templates',
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))
outcomes.push({ id, error: null })
} catch (err) {
outcomes.push({ id, error: toSyncRemoteError(err) })
outcomes.push({ id, error: templateUpsertError(err) })
}
}
return outcomes
@ -932,7 +980,11 @@ const knowledgeAdapter: SyncAdapter = {
const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get()
if (exists) return false
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)
// 다른 기기가 문서 행을 올리고 청크를 아직 못 올렸을 수 있다 — 다음 pull에서 다시 본다.
if (chunks.length === 0) return 'deferred'
return getRAGService().applyRemoteDocument({
id,
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
// 기기 간 동기화 공통 타입. 데스크톱 SQLite ↔ Supabase(모바일·웹이 직접 읽고 쓰는 정본).
/** 동기화 대상. 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다. */
export const SYNC_ENTITIES = [
'history',
'dictionary',
'meetings',
'meeting_memos',
'meeting_documents',
'memo_tags',
'custom_instructions',
'user_templates',
'knowledge_documents',
/** 단일 행(row_id 'self'): 언어·테마·자동 다듬기·활성 명령 */
'user_settings',
/** 녹음 파일 업로드(row_id = history id / meeting id) */
'history_audio',
'meeting_audio',
/**
* 동기화 대상 명세 — 엔티티 목록·push 순서·부모 관계·Realtime 감시 여부의 단일 정본(SSOT).
* 값은 Supabase 테이블 이름과 같고 sync_tombstones.table_name 과도 같다.
*
* 배열 순서 = push(upsert) 순서: 부모가 자식보다 먼저 온다. 삭제는 이 순서의 역순(자식 먼저).
* 새 엔티티는 여기 한 줄과 sync-registry.ts 의 핸들러 한 개만 추가한다(누락은 타입 오류).
*/
export const SYNC_ENTITY_SPECS = [
{ entity: 'history', realtime: true },
{ entity: 'dictionary', realtime: true },
{ entity: 'meetings', realtime: true },
{ entity: 'meeting_memos', realtime: true, parent: 'meetings' },
{ entity: 'meeting_documents', realtime: true, parent: 'meetings' },
{ entity: 'custom_instructions', realtime: true },
/** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */
'builtin_instructions',
] as const
{ entity: 'builtin_instructions', realtime: false },
/** 단일 행(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'