feat(sync): 持久化类型化主题与编辑器偏好并支持草稿恢复
This commit is contained in:
@@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { hostInvoke } from '@/services/platform/desktop'
|
||||
import SyncSettings from './SyncSettings.vue'
|
||||
vi.mock('@/services/platform/preferenceSync', () => ({ preferenceSyncIssues: [], resolvePreferenceDraft: vi.fn(), seedCurrentPreferences: vi.fn() }))
|
||||
vi.mock('@/services/platform/desktop', () => ({ hostInvoke: vi.fn() }))
|
||||
const confirm = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/useActionDialog', () => ({ useActionDialog: () => ({ actionDialog: null, resolveAction: vi.fn(), askConfirm: confirm }) }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch, computed } from 'vue'
|
||||
import { preferenceSyncIssues, resolvePreferenceDraft, seedCurrentPreferences } from '@/services/platform/preferenceSync'
|
||||
import { hostInvoke } from '@/services/platform/desktop'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
@@ -18,6 +19,7 @@ const preview = ref<Preview | null>(null), previewPage = ref(0)
|
||||
const previewItems = computed(() => preview.value?.items.slice(previewPage.value * 100, (previewPage.value + 1) * 100) ?? [])
|
||||
watch([endpoint, account, selected, () => status.value?.vault_id], () => { preview.value = null; previewPage.value = 0 })
|
||||
function previewMerge() { return act(async () => {
|
||||
await seedCurrentPreferences(status.value!.vault_id)
|
||||
preview.value = await hostInvoke<Preview>('sync_preview', { request: { vault_id: status.value!.vault_id, endpoint: endpoint.value, account: account.value, remote_vault: selected.value, mode: 'merge' } })
|
||||
}) }
|
||||
async function merge() {
|
||||
@@ -61,7 +63,10 @@ async function bind(mode: 'upload' | 'download') {
|
||||
const remote = selected.value
|
||||
if (!vaultId || !remote) return
|
||||
if (!(await askConfirm(mode === 'upload' ? t('将当前本地笔记上传到所选空远端库?', 'Upload current notes to the selected empty remote vault?') : t('将所选远端库下载到当前空本地库?', 'Download the selected remote vault into this empty local vault?')))) return
|
||||
await act(async () => { await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode } }) })
|
||||
await act(async () => {
|
||||
if (mode === 'upload') await seedCurrentPreferences(vaultId)
|
||||
await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode } })
|
||||
})
|
||||
}
|
||||
async function unbind() {
|
||||
const binding = status.value?.binding
|
||||
@@ -86,8 +91,13 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<h2 id="sync-title">OpenNexus Sync</h2>
|
||||
<p>{{ t('同步当前 Vault 的 Markdown 与常用附件。登录前请先解锁设备凭据保险库。', 'Sync Markdown and supported attachments in the current vault. Unlock the device credential vault before signing in.') }}</p>
|
||||
<p class="subtle">{{ t('两边都有文件时先预览合并。内容冲突会保留两端版本;任务和配置记录同步仍在开发中。', 'Preview a merge when both vaults contain files. Conflicts retain both versions; task and configuration record sync is still under development.') }}</p>
|
||||
<p class="subtle">{{ t('默认同步笔记、附件、任务、主题设置和编辑器偏好。两边都有数据时先预览合并;密钥、权限和本机路径不随设置同步。', 'Notes, attachments, tasks, theme settings and editor preferences sync by default. Preview a merge when both vaults contain data. Secrets, permissions and device paths stay local.') }}</p>
|
||||
<p v-if="message" class="error-banner" role="alert">{{ message }}</p>
|
||||
<article v-for="issue in preferenceSyncIssues" :key="issue.kind" class="sync-conflict" role="status">
|
||||
<h3>{{ issue.label }}</h3><p>{{ issue.error }}</p>
|
||||
<p>{{ t('本机待提交设置已保留,请选择使用哪一份。', 'The local preference draft is retained. Choose which version to use.') }}</p>
|
||||
<div v-if="issue.hasDraft" class="inline-actions"><button :disabled="busy" @click="act(() => resolvePreferenceDraft(issue.kind, 'local'))">{{ t('保留本机设置', 'Keep local settings') }}</button><button :disabled="busy" @click="act(() => resolvePreferenceDraft(issue.kind, 'remote'))">{{ t('采用工作区设置', 'Use workspace settings') }}</button></div>
|
||||
</article>
|
||||
<form class="sync-form" @submit.prevent="login">
|
||||
<label>{{ t('服务器地址', 'Server URL') }}<input v-model="endpoint" required :disabled="!!status?.binding || busy" type="url" autocomplete="url" /></label>
|
||||
<label>{{ t('账户', 'Account') }}<input v-model="account" required :disabled="!!status?.binding || busy" autocomplete="username" /></label>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from './stores/settings'
|
||||
import { watch } from 'vue'
|
||||
import { appLocale } from './i18n'
|
||||
import { updateDocumentTitle } from './router'
|
||||
import { installPreferenceSync } from './services/platform/preferenceSync'
|
||||
import { installDesktopLifecycle } from './services/platform/lifecycle'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -20,7 +21,7 @@ app.use(router)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
void themeStore.initTheme()
|
||||
void themeStore.initTheme().then(installPreferenceSync, installPreferenceSync)
|
||||
watch(appLocale, () => updateDocumentTitle())
|
||||
watch(() => settingsStore.spellCheck, (enabled) => {
|
||||
document.body.spellcheck = enabled
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { hostInvoke } from './desktop'
|
||||
import { installPreferenceSync, seedCurrentPreferences } from './preferenceSync'
|
||||
vi.mock('./desktop', () => ({ isDesktop: () => true, hostInvoke: vi.fn(), nativePath: (value: string) => value, nativeTree: () => [] }))
|
||||
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); localStorage.clear() })
|
||||
it('applies remote appearance without echoing it and only exports approved local fields', async () => {
|
||||
vi.useFakeTimers(); setActivePinia(createPinia())
|
||||
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
||||
settings.permissionPolicy = { plantedSecretPermission: 'allow' }
|
||||
const data = { themeId: 'dark', fontEditorSize: 18, fontEditorFamily: 'system-ui', lineHeight: 1.7, codeBlockTheme: 'auto', headings: { custom: false, family: 'inherit', levels: [32,28,24,21,18,16].map(size => ({ size, weight: 700 })) } }
|
||||
let remote: Record<string, unknown> = { record: { schema: 1, kind: 'theme_settings', id: 'appearance', data }, hash: '1'.repeat(64), file_id: 'theme-file' }
|
||||
vi.mocked(hostInvoke).mockImplementation(async (command, args) => {
|
||||
const request = args!.request as { kind: string; record?: Record<string, unknown> }
|
||||
if (command === 'record_get') return request.kind === 'theme_settings' ? remote : null
|
||||
const result = { record: request.record, hash: '2'.repeat(64), file_id: 'theme-file' }
|
||||
if (request.record?.kind === 'theme_settings') remote = result
|
||||
return result
|
||||
})
|
||||
workspace.vaultId = 'one'; installPreferenceSync(); await flushPromises()
|
||||
expect(theme.fontEditorSize).toBe(18); expect(theme.currentThemeId).toBe('dark')
|
||||
expect(vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(0)
|
||||
theme.fontEditorSize = 24; await vi.advanceTimersByTimeAsync(1500); await flushPromises()
|
||||
let writes = vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(JSON.stringify(writes)).not.toContain('plantedSecretPermission')
|
||||
await seedCurrentPreferences('one')
|
||||
writes = vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')
|
||||
expect(writes.some(([, args]) => (args!.request as { record: { kind: string } }).record.kind === 'preferences')).toBe(true)
|
||||
expect(JSON.stringify(writes)).not.toContain('permissionPolicy')
|
||||
await expect(seedCurrentPreferences('other')).rejects.toThrow('PREFERENCE_BINDING_NOT_READY')
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Portable preference records are bound to the active Vault; local drafts retain their own Vault key. */
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useHeadingAppearanceStore, normalizeHeadingAppearance } from '@/stores/headingAppearance'
|
||||
import { useMarkdownPreferencesStore, normalizeMarkdownPreferences } from '@/stores/markdownPreferences'
|
||||
import { hostInvoke, isDesktop } from './desktop'
|
||||
import { RecordBinding } from './recordBinding'
|
||||
interface Controller { label: string; binding: Pick<RecordBinding<never>, 'capture' | 'poll' | 'seed' | 'stop' | 'keepLocal' | 'useRemote' | 'error' | 'hasDraft'> }
|
||||
export const preferenceSyncIssues = ref<Array<{ kind: string; label: string; error: string; hasDraft: boolean }>>([])
|
||||
let controllers = new Map<string, Controller>(), activeVault = '', installed = false
|
||||
export async function seedCurrentPreferences(vaultId: string) {
|
||||
if (!isDesktop()) return
|
||||
if (vaultId !== activeVault || controllers.size !== 2) throw new Error('PREFERENCE_BINDING_NOT_READY')
|
||||
for (const { binding } of controllers.values()) {
|
||||
await binding.seed()
|
||||
if (binding.error) throw new Error(binding.error)
|
||||
}
|
||||
}
|
||||
export async function resolvePreferenceDraft(kind: string, choice: 'local' | 'remote') {
|
||||
const controller = controllers.get(kind)
|
||||
if (!controller) return
|
||||
if (choice === 'local') { controller.binding.capture(); await controller.binding.keepLocal() }
|
||||
else await controller.binding.useRemote()
|
||||
}
|
||||
export function installPreferenceSync() {
|
||||
if (!isDesktop() || installed) return
|
||||
installed = true
|
||||
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
||||
const headings = useHeadingAppearanceStore(), markdown = useMarkdownPreferencesStore()
|
||||
let applying = 0
|
||||
const readTheme = () => ({ themeId: theme.currentThemeId, fontEditorSize: theme.fontEditorSize, fontEditorFamily: theme.fontEditorFamily, lineHeight: theme.lineHeight, codeBlockTheme: theme.codeBlockTheme, headings: normalizeHeadingAppearance(headings.preferences) })
|
||||
const readPreferences = () => ({ restoreLastVault: settings.restoreLastVault, autoSaveInterval: settings.autoSaveInterval, language: settings.language, defaultEditorMode: settings.defaultEditorMode, editorLineWidth: settings.editorLineWidth, spellCheck: settings.spellCheck, markdown: normalizeMarkdownPreferences(markdown.preferences), presets: markdown.customPresets.map(preset => ({ name: preset.name, preferences: normalizeMarkdownPreferences(preset.preferences) })) })
|
||||
const changed = () => { preferenceSyncIssues.value = [...controllers].filter(([, value]) => value.binding.error).map(([kind, value]) => ({ kind, label: value.label, error: value.binding.error, hasDraft: value.binding.hasDraft })) }
|
||||
async function apply(action: () => void) { applying++; try { action(); await nextTick() } finally { applying-- } }
|
||||
watch(() => workspace.vaultId, vaultId => {
|
||||
for (const value of controllers.values()) value.binding.stop()
|
||||
controllers = new Map(); activeVault = vaultId; changed()
|
||||
if (!vaultId) return
|
||||
const common = { vaultId, invoke: hostInvoke, storage: localStorage, changed }
|
||||
controllers.set('theme_settings', { label: '主题与外观', binding: new RecordBinding({ ...common, kind: 'theme_settings', id: 'appearance', read: readTheme, apply: data => apply(() => {
|
||||
if (!theme.applyTheme(data.themeId)) { theme.applyTheme('light'); theme.themeLoadWarning = `同步主题 ${data.themeId} 尚未安装,请在本机安装并确认后使用。` }
|
||||
theme.fontEditorSize = data.fontEditorSize; theme.fontEditorFamily = data.fontEditorFamily; theme.lineHeight = data.lineHeight; theme.codeBlockTheme = data.codeBlockTheme
|
||||
headings.preferences = normalizeHeadingAppearance(data.headings)
|
||||
}) }) })
|
||||
controllers.set('preferences', { label: '编辑器偏好', binding: new RecordBinding({ ...common, kind: 'preferences', id: 'editor', read: readPreferences, apply: data => apply(() => {
|
||||
settings.restoreLastVault = data.restoreLastVault; settings.autoSaveInterval = data.autoSaveInterval; settings.language = data.language
|
||||
settings.defaultEditorMode = data.defaultEditorMode; settings.editorLineWidth = data.editorLineWidth; settings.spellCheck = data.spellCheck
|
||||
markdown.apply(data.markdown); markdown.customPresets = data.presets.map(preset => ({ name: preset.name, preferences: normalizeMarkdownPreferences(preset.preferences) }))
|
||||
}) }) })
|
||||
changed()
|
||||
for (const value of controllers.values()) void value.binding.poll()
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
watch(readTheme, () => { if (!applying) controllers.get('theme_settings')?.binding.capture() }, { deep: true, flush: 'sync' })
|
||||
watch(readPreferences, () => { if (!applying) controllers.get('preferences')?.binding.capture() }, { deep: true, flush: 'sync' })
|
||||
setInterval(() => { for (const value of controllers.values()) void value.binding.poll() }, 1500)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { RecordBinding, type RecordDocument } from './recordBinding'
|
||||
const h1 = '1'.repeat(64), h2 = '2'.repeat(64), h3 = '3'.repeat(64)
|
||||
function document(value: number, hash = h1): RecordDocument<{ value: number }> { return { record: { schema: 1, kind: 'preferences', id: 'editor', data: { value } }, hash, file_id: 'file' } }
|
||||
function setup(invoke: (command: string, args: Record<string, unknown>) => Promise<unknown>, vaultId = 'vault') {
|
||||
let local = { value: 1 }
|
||||
const apply = vi.fn((data: { value: number }) => { local = data })
|
||||
const binding = new RecordBinding({ vaultId, kind: 'preferences', id: 'editor', read: () => local, apply, invoke: async <R>(command: string, args: Record<string, unknown>) => await invoke(command, args) as R, storage: localStorage })
|
||||
return { binding, apply, edit: (value: number) => { local = { value }; binding.capture() }, read: () => local }
|
||||
}
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
it('reopens a conflicting draft without overwriting it and only rebases after a decision', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(document(1))
|
||||
const original = setup(invoke); await original.binding.poll(); original.edit(2)
|
||||
const firstDraft = JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!)
|
||||
original.binding.stop()
|
||||
invoke.mockImplementation(async command => { if (command === 'record_get') return document(3, h3); throw new Error('REVISION_CONFLICT') })
|
||||
const reopened = setup(invoke); await reopened.binding.poll()
|
||||
expect(reopened.read()).toEqual({ value: 2 }); expect(reopened.binding.conflicted).toBe(true)
|
||||
expect(JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!)).toEqual(firstDraft)
|
||||
invoke.mockImplementation(async (command, args) => command === 'record_get' ? document(3, h3) : { ...document(2, h2), record: args.request.record })
|
||||
await reopened.binding.keepLocal()
|
||||
const write = invoke.mock.calls.filter(([command]) => command === 'record_write').at(-1)![1].request
|
||||
expect(write.expected).toBe(h3); expect(write.operation_id).not.toBe(firstDraft.operation_id)
|
||||
expect(reopened.binding.hasDraft).toBe(false)
|
||||
})
|
||||
it('keeps drafts separated by Vault and does not seed an empty Vault during polling', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(null), first = setup(invoke)
|
||||
await first.binding.poll(); expect(invoke.mock.calls.some(([command]) => command === 'record_write')).toBe(false)
|
||||
first.edit(2); first.binding.stop()
|
||||
const second = setup(invoke, 'second'); await second.binding.poll()
|
||||
expect(second.binding.hasDraft).toBe(false); expect(second.read()).toEqual({ value: 1 })
|
||||
})
|
||||
it('orders edits made during an in-flight commit against its confirmed hash', async () => {
|
||||
let release!: (value: RecordDocument<{ value: number }>) => void
|
||||
const invoke = vi.fn().mockResolvedValue(document(1)), state = setup(invoke)
|
||||
await state.binding.poll(); state.edit(2)
|
||||
invoke.mockImplementation(async command => command === 'record_get' ? document(1) : new Promise(resolve => { release = resolve }))
|
||||
const pending = state.binding.poll(); await vi.waitFor(() => expect(release).toBeTypeOf('function'))
|
||||
state.edit(3); release(document(2, h2)); await pending
|
||||
const draft = JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!)
|
||||
expect(draft.expected).toBe(h2); expect(draft.record.data).toEqual({ value: 3 })
|
||||
})
|
||||
it('rejects a remote-choice response that would discard a newer local edit', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(document(1)), state = setup(invoke)
|
||||
await state.binding.poll(); state.edit(2)
|
||||
let release!: (value: RecordDocument<{ value: number }>) => void
|
||||
invoke.mockImplementation(() => new Promise(resolve => { release = resolve }))
|
||||
const choice = state.binding.useRemote(); await Promise.resolve(); state.edit(4); release(document(3, h3)); await choice
|
||||
expect(state.binding.error).toBe('PREFERENCE_CHANGED'); expect(state.read()).toEqual({ value: 4 }); expect(state.binding.hasDraft).toBe(true)
|
||||
})
|
||||
it('waits for a live poll before seeding and reuses its result', async () => {
|
||||
let release!: (value: null) => void
|
||||
const invoke = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve })).mockImplementation(async command => command === 'record_get' ? null : document(1))
|
||||
const state = setup(invoke), poll = state.binding.poll(), seed = state.binding.seed()
|
||||
release(null); await poll; await seed
|
||||
expect(invoke.mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retains the committed draft when removing its durable receipt fails', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(document(1)), state = setup(invoke)
|
||||
await state.binding.poll(); state.edit(2)
|
||||
invoke.mockImplementation(async command => command === 'record_get' ? document(1) : document(2, h2))
|
||||
const remove = vi.spyOn(localStorage, 'removeItem').mockImplementation(() => { throw new Error('disk unavailable') })
|
||||
await state.binding.poll()
|
||||
expect(state.binding.hasDraft).toBe(true)
|
||||
expect(state.binding.error).toBe('PREFERENCE_DRAFT_STORE_FAILED')
|
||||
expect(JSON.parse(localStorage.getItem('opennexus-record-draft:vault:preferences:editor')!).record.data).toEqual({ value: 2 })
|
||||
remove.mockRestore()
|
||||
state.edit(3); await state.binding.poll()
|
||||
expect(state.binding.hasDraft).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
/** A durable preference draft keeps its original CAS base until the user resolves a conflict. */
|
||||
export interface LogicalRecord<T> { schema: 1; kind: string; id: string; data: T }
|
||||
export interface RecordDocument<T> { record: LogicalRecord<T>; hash: string; file_id: string }
|
||||
interface Draft<T> { record: LogicalRecord<T>; expected: string; operation_id: string }
|
||||
interface Options<T> {
|
||||
vaultId: string; kind: string; id: string; read(): T; apply(data: T): void | Promise<void>
|
||||
invoke<R>(command: string, args: Record<string, unknown>): Promise<R>
|
||||
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>; changed?(): void
|
||||
}
|
||||
export class RecordBinding<T> {
|
||||
private draft: Draft<T> | null = null
|
||||
private remote: RecordDocument<T> | null = null
|
||||
private running = false
|
||||
private active: Promise<void> | null = null
|
||||
private restored = false
|
||||
private invalidDraft = false
|
||||
private stopped = false
|
||||
private initialized = false
|
||||
private appliedHash: string | null = null
|
||||
error = ''
|
||||
constructor(private options: Options<T>) {
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(this.key) ?? 'null') as Draft<T> | null
|
||||
if (value && (value.record?.schema !== 1 || value.record.kind !== options.kind || value.record.id !== options.id || !value.record.data || typeof value.record.data !== 'object' || typeof value.expected !== 'string' || !/^(?:[0-9a-f]{64})?$/.test(value.expected) || typeof value.operation_id !== 'string' || !/^[0-9a-f-]{36}$/.test(value.operation_id))) throw new Error('invalid draft')
|
||||
this.draft = value; this.restored = value !== null
|
||||
} catch { this.error = 'PREFERENCE_DRAFT_INVALID'; this.invalidDraft = true }
|
||||
}
|
||||
private get key() { return `opennexus-record-draft:${this.options.vaultId}:${this.options.kind}:${this.options.id}` }
|
||||
private persist(draft = this.draft) {
|
||||
try {
|
||||
if (draft) this.options.storage.setItem(this.key, JSON.stringify(draft))
|
||||
else this.options.storage.removeItem(this.key)
|
||||
} catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.(); throw new Error(this.error) }
|
||||
this.options.changed?.()
|
||||
}
|
||||
get conflicted() { return this.error === 'REVISION_CONFLICT' }
|
||||
get hasDraft() { return this.draft !== null || this.invalidDraft }
|
||||
stop() { this.stopped = true }
|
||||
capture() {
|
||||
if (this.stopped) return
|
||||
const data = JSON.parse(JSON.stringify(this.options.read())) as T
|
||||
if (!this.draft && this.remote && JSON.stringify(data) === JSON.stringify(this.remote.record.data)) return
|
||||
// New edits while a request runs get a new operation, but preserve the unresolved base.
|
||||
this.draft = { record: { schema: 1, kind: this.options.kind, id: this.options.id, data }, expected: this.draft?.expected ?? this.remote?.hash ?? '', operation_id: crypto.randomUUID() }
|
||||
this.restored = false; this.invalidDraft = false
|
||||
try { this.persist(); if (this.error === 'PREFERENCE_DRAFT_STORE_FAILED') this.error = '' } catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.() }
|
||||
}
|
||||
async seed() {
|
||||
await this.poll()
|
||||
if (!this.stopped && this.initialized && !this.remote && !this.draft) { this.capture(); await this.poll() }
|
||||
}
|
||||
poll(): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve()
|
||||
if (this.active) return this.active
|
||||
this.active = this.run().finally(() => { this.active = null })
|
||||
return this.active
|
||||
}
|
||||
private async run() {
|
||||
if (this.running || this.stopped || this.invalidDraft || this.error === 'PREFERENCE_DRAFT_STORE_FAILED') return
|
||||
this.running = true
|
||||
try {
|
||||
this.remote = await this.options.invoke<RecordDocument<T> | null>('record_get', { request: { vault_id: this.options.vaultId, kind: this.options.kind, id: this.options.id } })
|
||||
if (this.stopped) return
|
||||
this.initialized = true
|
||||
if (this.draft) {
|
||||
if (this.restored) { this.restored = false; await this.options.apply(this.draft.record.data) }
|
||||
if (this.stopped || this.conflicted) return
|
||||
const draft = this.draft
|
||||
const committed = await this.options.invoke<RecordDocument<T>>('record_write', { request: { vault_id: this.options.vaultId, ...draft } })
|
||||
this.remote = committed
|
||||
if (this.draft.operation_id === draft.operation_id) {
|
||||
this.persist(null); this.draft = null; this.appliedHash = committed.hash
|
||||
} else {
|
||||
// The next local edit follows the just-confirmed predecessor, not its older CAS base.
|
||||
this.draft.expected = committed.hash; this.persist()
|
||||
}
|
||||
} else if (this.remote && this.remote.hash !== this.appliedHash) {
|
||||
await this.options.apply(this.remote.record.data)
|
||||
this.appliedHash = this.remote.hash
|
||||
}
|
||||
this.error = ''
|
||||
} catch (error) { this.error = error instanceof Error ? error.message : 'PREFERENCE_SYNC_FAILED' }
|
||||
finally { this.running = false; this.options.changed?.() }
|
||||
}
|
||||
async keepLocal() {
|
||||
await this.active
|
||||
if (this.running || this.stopped) return
|
||||
this.running = true
|
||||
try {
|
||||
if (this.invalidDraft) this.capture()
|
||||
if (!this.draft) return
|
||||
const remote = await this.options.invoke<RecordDocument<T> | null>('record_get', { request: { vault_id: this.options.vaultId, kind: this.options.kind, id: this.options.id } })
|
||||
if (this.stopped) return
|
||||
this.remote = remote; this.draft.expected = remote?.hash ?? ''; this.draft.operation_id = crypto.randomUUID(); this.error = ''; this.persist()
|
||||
} finally { this.running = false }
|
||||
await this.poll()
|
||||
}
|
||||
async useRemote() {
|
||||
await this.active
|
||||
if (this.running || this.stopped) return
|
||||
this.running = true
|
||||
try {
|
||||
const decision = this.draft?.operation_id
|
||||
const remote = await this.options.invoke<RecordDocument<T> | null>('record_get', { request: { vault_id: this.options.vaultId, kind: this.options.kind, id: this.options.id } })
|
||||
if (this.stopped) return
|
||||
if (this.draft?.operation_id !== decision) { this.error = 'PREFERENCE_CHANGED'; this.options.changed?.(); return }
|
||||
if (!remote) { this.error = 'PREFERENCE_REMOTE_MISSING'; this.options.changed?.(); return }
|
||||
this.options.storage.removeItem(this.key)
|
||||
this.draft = null; this.invalidDraft = false; this.error = ''; this.remote = remote; this.options.changed?.()
|
||||
await this.options.apply(remote.record.data); this.appliedHash = remote.hash
|
||||
} finally { this.running = false }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user