architecture: 数据唯一权威迁至服务器——本地降级为实时缓存,写后即时推送+快照重建;账号登录转为云端原生(新增 /api/auth/password、/api/auth/delete、每日自动备份)

This commit is contained in:
root
2026-10-16 16:13:38 +08:00
parent 7517eb0ad9
commit e5f99d46d8
16 changed files with 641 additions and 478 deletions
+248 -267
View File
@@ -1,22 +1,24 @@
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 自然键去重 + 外键重映射 + 软删除墓碑
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
// 同步引擎:服务器是唯一数据权威,本机只是实时缓存。
// 写路径:界面修改本地缓存 → 立即把差异推给服务器 → 服务器返回权威快照 → 本地缓存全量重建
// 读路径:启动 / SSE 通知 / 60 秒兜底 / 任何写操作之后 → 全量刷新
// 因此本地与服务器的数据永远强一致:没有合并、没有冲突收敛、没有双端分歧。
import { ref } from 'vue'
import Dexie, { liveQuery } from 'dexie'
import { db } from '../db/schema'
import { getSetting, setSetting } from '../db/repo'
import { getCloudCreds } from '../db/auth'
import { nowLocalISO } from '../lib/date'
import { notifyAttendanceChanged } from '../lib/bus'
import { DEFAULT_SYNC_BASE, getSyncBase as baseOf, getToken, setToken, setSyncBaseOverride } from '../lib/server'
// 最近一次自动同步的错误(设置页展示用)
export const syncLastError = ref('')
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
// 本地敏感或临时性的设置不参与同步
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据(冲突时保留 updated_at 较新者)
// 自然键:同一逻辑记录在并发写入时的服务端去重依据(保留 updated_at 较新者)
const NATURAL_KEYS: Record<string, string> = {
project: 'name',
work_log: 'date',
@@ -26,23 +28,11 @@ const NATURAL_KEYS: Record<string, string> = {
}
export const SYNC_TABLES = TABLES
export { DEFAULT_SYNC_BASE, setSyncBaseOverride }
export const getSyncBase = baseOf
interface AnyRow { [key: string]: any }
function newUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
function stripId(row: AnyRow): AnyRow {
const copy: AnyRow = { ...row }
delete copy.id
return copy
}
function rowHash(row: AnyRow): string {
return JSON.stringify(row)
}
@@ -56,7 +46,7 @@ function pkOf(t: string, row: AnyRow): any {
return t === 'setting' ? row.key : row.id
}
// 一次性补齐 uuid 与 updated_at,缺失时回填
// 一次性补齐 uuid 与 updated_at,缺失时回填(写路径入口先生效,快照兜底)
async function ensureSyncFields(): Promise<void> {
for (const t of TABLES) {
const table = db.table(t)
@@ -64,13 +54,18 @@ async function ensureSyncFields(): Promise<void> {
for (const row of rows) {
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
const patch: AnyRow = {}
if (!row.uuid) patch.uuid = newUuid()
if (!row.updated_at) patch.updated_at = row.created_at || nowLocalISO()
if (!row.uuid) patch.uuid = genUuid()
if (!row.updated_at && row.created_at) patch.updated_at = row.created_at
if (Object.keys(patch).length) await table.update(pkOf(t, row), patch)
}
}
}
function genUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
}
export interface SyncResult {
pushed: number
pulled: number
@@ -90,7 +85,11 @@ export async function isSyncConfigured(): Promise<boolean> {
}
export async function getSyncLastTime(): Promise<string> {
return getSetting('sync_last_time', '')
try { return localStorage.getItem('dsh_last_sync') || '' } catch { return '' }
}
async function putSyncLastTime(at: string): Promise<void> {
try { localStorage.setItem('dsh_last_sync', at) } catch { /* 忽略 */ }
}
// 云端账号注册(服务端存 PBKDF2 哈希)
@@ -128,101 +127,24 @@ async function loginCloud(base: string, config: SyncConfig): Promise<string> {
}
async function ensureToken(base: string, config: SyncConfig): Promise<string> {
let token = await getSetting('sync_token', '')
let token = getToken()
if (token) return token
token = await loginCloud(base, config)
await setSetting('sync_token', token)
setToken(token)
return token
}
// ---------- 并发保护与实时触发 ----------
let syncRunning = false
let pendingSync = false
let changesSuppressed = false
let changeDebounce: number | null = null
let sse: EventSource | null = null
let sseKey = ''
let realtimeAttached = false
let realtimeSub: { unsubscribe: () => void } | null = null
export async function autoSyncNow(): Promise<void> {
if (syncRunning) {
pendingSync = true
return
}
try {
if (!(await isSyncConfigured())) return
await runSync(await loadSyncConfig())
syncLastError.value = ''
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
}
}
// 实时触发:本地任何数据变更 3 秒后自动推送(Dexie 4 核心 liveQuery,跨全部业务表)
export function enableRealtimeSync(): void {
if (realtimeAttached) return
realtimeAttached = true
const sub = liveQuery(async () => {
const counts: Record<string, number> = {}
for (const t of TABLES) counts[t] = await db.table(t).count()
return counts
})
realtimeSub = sub.subscribe({
next: () => {
if (changesSuppressed || syncRunning) return
if (changeDebounce) globalThis.clearTimeout(changeDebounce)
changeDebounce = globalThis.setTimeout(() => { void autoSyncNow() }, 3000)
},
error: () => { /* 忽略监听错误,轮询兜底 */ }
})
}
export function stopRealtime(): void {
if (realtimeSub) { realtimeSub.unsubscribe(); realtimeSub = null }
if (sse) { sse.close(); sse = null; sseKey = '' }
if (changeDebounce) { globalThis.clearTimeout(changeDebounce); changeDebounce = null }
}
// 服务端 SSE 通知:其他端有变更时立即拉取
function ensureSSE(base: string, token: string): void {
const key = base + '|' + token
if (sse && sseKey === key) return
if (sse) { sse.close(); sse = null }
try {
sse = new EventSource(base + '/api/events?token=' + encodeURIComponent(token))
sseKey = key
sse.onmessage = () => { void autoSyncNow() }
} catch { sse = null }
}
// 同步基址:设置页可覆盖,默认使用独立子域名
export async function getSyncBase(): Promise<string> {
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
}
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
async function loadSyncConfig(): Promise<SyncConfig> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
}
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
// 登录后自动绑定云端账号:云端存在则校验密码一致(同用户名即同一数据集)
export async function ensureCloudBinding(): Promise<void> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录')
const base = await getSyncBase()
const headers = { 'Content-Type': 'application/json' }
const body = JSON.stringify({ username: creds.username, password: creds.password })
let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body })
if (resp.status === 409) {
resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body })
if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查')
} else if (!resp.ok) {
throw new Error('云端绑定失败:' + resp.status)
}
const base = await baseOf()
const resp = await fetch(base + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: creds.username, password: creds.password })
})
if (!resp.ok) throw new Error('云端账号校验失败:' + resp.status)
}
export function getCloudUser(): string | null {
@@ -230,9 +152,22 @@ export function getCloudUser(): string | null {
return c ? c.username : null
}
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
// 使用当前登录凭据执行同步(设置页与写后推送共用)
export async function syncWithStoredConfig(): Promise<SyncResult> {
return runSync(await loadSyncConfig())
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
}
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存(失败会把缓存回退到服务器真值)
export async function flushAfterWrite(): Promise<void> {
try {
await syncWithStoredConfig()
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
throw e
}
}
// ---------- 推送规范化:剥离本地 id,外键转换为 uuid 引用 ----------
@@ -270,8 +205,72 @@ async function buildRefMaps(): Promise<RefMaps> {
// ---------- 执行同步 ----------
let applyErrors = 0
let lastApplyError = ''
let syncRunning = false
let pendingSync = false
let changesSuppressed = false
let changeDebounce: number | null = null
let sse: EventSource | null = null
let sseKey = ''
let realtimeAttached = false
let realtimeSub: { unsubscribe: () => void } | null = null
export async function autoSyncNow(): Promise<void> {
if (syncRunning) {
pendingSync = true
return
}
try {
if (!(await isSyncConfigured())) return
await runSync(await configOf())
syncLastError.value = ''
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
}
}
async function configOf(): Promise<SyncConfig> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return { baseUrl: await baseOf(), username: creds.username, password: creds.password }
}
// 实时触发:本地任何数据变更 3 秒内推送(Dexie 4 liveQuery,跨全部业务表)
export function enableRealtimeSync(): void {
if (realtimeAttached) return
realtimeAttached = true
const sub = liveQuery(async () => {
const counts: Record<string, number> = {}
for (const t of TABLES) counts[t] = await db.table(t).count()
return counts
})
realtimeSub = sub.subscribe({
next: () => {
if (changesSuppressed || syncRunning) return
if (changeDebounce) globalThis.clearTimeout(changeDebounce)
changeDebounce = globalThis.setTimeout(() => { void autoSyncNow() }, 3000)
},
error: () => { /* 忽略监听错误,轮询兜底 */ }
})
}
export function stopRealtime(): void {
if (realtimeSub) { realtimeSub.unsubscribe(); realtimeSub = null }
if (sse) { sse.close(); sse = null; sseKey = '' }
if (changeDebounce) { globalThis.clearTimeout(changeDebounce); changeDebounce = null }
realtimeAttached = false
}
// 服务端 SSE 通知:其他端有变更时立即刷新(服务端广播 change 即表示权威数据有变化)
function ensureSSE(base: string, token: string): void {
const key = base + '|' + token
if (sse && sseKey === key) return
if (sse) { sse.close(); sse = null }
try {
sse = new EventSource(base + '/api/events?token=' + encodeURIComponent(token))
sseKey = key
sse.onmessage = () => { void autoSyncNow() }
} catch { sse = null }
}
export async function runSync(config: SyncConfig): Promise<SyncResult> {
if (syncRunning) {
@@ -280,8 +279,6 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
}
syncRunning = true
changesSuppressed = true
applyErrors = 0
lastApplyError = ''
try {
const result = await doRunSync(config)
if (pendingSync) {
@@ -301,7 +298,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
await ensureSyncFields()
const refs = await buildRefMaps()
// 1. 载入镜像,计算本地差异(推送内容按规范化形式比对)
// 1. 载入镜像,计算本地与服务器权威数据的差异(推送内容按规范化形式比对)
const mirrorRows = await db.sync_mirror.toArray()
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
for (const m of mirrorRows) mirror.set(m.table + '|' + m.uuid, m)
@@ -334,11 +331,8 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
pushTables[t] = pushTables[t] || []
}
// 本地待推送的删除墓碑
const localTombs = await db.tombstone.toArray()
for (const tb of localTombs) {
tombstones.push({ table: tb.table, uuid: tb.uuid, deleted_at: tb.deleted_at })
}
// 服务端残留临时键的清理墓碑(如早前同步上去的 sync_token 等)
tombstones.push(...drainServerCleanup())
// 2. 认证并与服务端交换(token 失效自动重登一次)
const token = await ensureToken(base, config)
@@ -349,9 +343,9 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
})
let resp = await doPush(token)
if (resp.status === 401) {
await setSetting('sync_token', '')
setToken('')
const token2 = await loginCloud(base, config)
await setSetting('sync_token', token2)
setToken(token2)
resp = await doPush(token2)
}
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
@@ -359,160 +353,147 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
// 3. 应用服务端权威数据
const applyResult = await applyServerTables(serverTables, serverTombstones)
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
const applyResult = await applySnapshot(serverTables, serverTombstones)
// 4. 重建镜像 + 建立 SSE 实时通道 + 广播考勤变更
const refsNow = await buildRefMaps()
// 4. 建立 SSE 实时通道 + 记录同步时间 + 广播考勤变更
ensureSSE(base, getToken() || token)
const at = nowLocalISO()
await putSyncLastTime(at)
if (applyResult.changed > 0 || applyResult.removed > 0 || pushed > 0) notifyAttendanceChanged()
return { pushed, pulled: applyResult.changed, removed: applyResult.removed, errors: 0, at }
}
// ---------- 快照重建:清空重灌 + 稳定本地 id(uuid -> id 映射保存在本地) ----------
interface IdMap { [table: string]: { [uuid: string]: number } }
function idMapKey(): string {
const user = getCloudCreds()?.username || 'anon'
return 'dsh_idmap_' + user
}
function loadIdMap(): IdMap {
try {
const raw = localStorage.getItem(idMapKey())
if (raw) return JSON.parse(raw) as IdMap
} catch { /* 忽略 */ }
return {}
}
function saveIdMap(map: IdMap): void {
try { localStorage.setItem(idMapKey(), JSON.stringify(map)) } catch { /* 忽略 */ }
}
async function applySnapshot(
serverTables: Record<string, AnyRow[]>,
serverTombstones: { table: string; uuid: string; deleted_at: string }[]
): Promise<{ changed: number; removed: number }> {
const idMap = loadIdMap()
const dead = new Set(serverTombstones.map(x => x.table + '|' + x.uuid))
const mirrorNow = new Map<string, { table: string; uuid: string; hash: string }>()
for (const m of await db.sync_mirror.toArray()) mirrorNow.set(m.table + '|' + m.uuid, m)
const mirrorUuids = new Set<string>(Array.from(mirrorNow.keys()).map(k => k.split('|')[1]))
let changed = 0
let removed = 0
const wlIds: { [uuid: string]: number } = {}
const pjIds: { [uuid: string]: number } = {}
for (const t of TABLES) {
const table = db.table(t)
const prevRows: AnyRow[] = await table.toArray()
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
const live: AnyRow[] = (serverTables[t] || []).filter(r => !dead.has(t + '|' + r.uuid))
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
for (const r of tmpKeys) tombstoneLater(t, r.uuid)
// 在途写入保护:本次界面写操作尚未推送完成时,快照重建不得抹掉这些行
const inflight = t === 'setting'
? []
: prevRows.filter(r => r.uuid && !mirrorUuids.has(r.uuid) && !dead.has(t + '|' + r.uuid))
void inflight
// 本地 id:优先沿用既有映射,保证视图引用稳定
const nextId = { v: Math.max(0, ...prevRows.map(r => Number(r.id) || 0)) }
const importance = filtered.length
const hydrated: AnyRow[] = []
for (const srow0 of filtered) {
const srow = { ...srow0 }
if (t === 'log_item') {
const wlId = srow.wuuid ? wlIds[srow.wuuid] : undefined
if (!wlId) continue
srow.work_log_id = wlId
delete srow.wuuid
srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null
delete srow.puuid
}
let id: any = pkOf(t, srow0)
if (t === 'setting') id = srow0.key
else {
const byUuid = idMap[t] && idMap[t][srow0.uuid]
const prevRow = prevByUuid.get(srow0.uuid)
if (prevRow && prevRow.id != null) id = prevRow.id
else if (typeof byUuid === 'number') id = byUuid
else id = ++nextId.v
}
const rowOut: AnyRow = { ...srow, id }
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(rowOut.key)) continue
hydrated.push(rowOut)
if (t === 'work_log') wlIds[srow0.uuid] = id
if (t === 'project') pjIds[srow0.uuid] = id
}
void importance
const outRows = hydrated.concat(inflight)
// 增删差异统计:内容或 id 变化都算变更
for (const r of outRows) {
const p = prevById.get(pkOf(t, r))
if (!p || rowHash(p) !== rowHash(r)) changed++
}
removed += prevRows.filter(r => r.uuid && !hydrated.some(x => x.uuid === r.uuid) && !dead.has(t + '|' + r.uuid)).length
// 全量重建:先清空后批量写入;在途行粘回缓存(它们会在随后的推送中上 JOIN 服务器)
await table.clear()
if (outRows.length) await table.bulkPut(outRows)
// 更新 uuid -> id 映射
const m2: { [uuid: string]: number } = {}
for (const r of outRows) { if (r.uuid) m2[r.uuid] = r.id }
idMap[t] = m2
}
saveIdMap(idMap)
// 清理镜像与本地墓碑(快照重建后镜像直接按快照重建)
const refs = await buildRefMaps()
const finalMirror: { table: string; uuid: string; hash: string }[] = []
for (const t of TABLES) {
const rows: AnyRow[] = await db.table(t).toArray()
for (const row of rows) {
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(normalizeRow(t, row, refsNow)) })
if (!row.uuid) continue
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(normalizeRow(t, row, refs)) })
}
}
await db.sync_mirror.clear()
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
ensureSSE(base, token)
const at = nowLocalISO()
await setSetting('sync_last_time', at)
if (applyResult.pulled > 0 || applyResult.removed > 0) notifyAttendanceChanged()
// 清除已确认推送的本地墓碑(保留本轮新产生的去重墓碑)
if (localTombs.length) {
const pushedIds = new Set(localTombs.map(t => t.id))
await db.tombstone.where('id').anyOf(Array.from(pushedIds)).delete()
}
return { pushed, pulled: applyResult.pulled, removed: applyResult.removed, errors: applyResult.errors, at }
await db.tombstone.clear()
return { changed, removed: removed + pendingTombstones.length }
}
// ---------- 应用服务端数据(含自然键去重与外键重映射) ----------
let localWorkLogIdByUuid = new Map<string, number>()
let localProjectIdByUuid = new Map<string, number>()
let applyConflicts = 0
async function applyServerTables(
serverTables: Record<string, AnyRow[]>,
serverTombstones: { table: string; uuid: string; deleted_at: string }[]
): Promise<{ pulled: number; removed: number; errors: number }> {
let pulled = 0
let removed = 0
for (const t of TABLES) {
const table = db.table(t)
const localRows: AnyRow[] = await table.toArray()
const byUuid = new Map<string, AnyRow>()
for (const row of localRows) byUuid.set(row.uuid, row)
const nk = NATURAL_KEYS[t] || null
const byNatural = new Map<string, AnyRow>()
if (nk) {
for (const row of localRows) {
const v = row[nk]
if (v != null && v !== '') byNatural.set(String(v), row)
}
}
const dead = new Set(serverTombstones.filter(x => x.table === t).map(x => x.uuid))
const serverRows: AnyRow[] = serverTables[t] || []
// 阶段一:应用未删除行的增改
for (const srow of serverRows) {
try {
if (dead.has(srow.uuid)) continue
const local = byUuid.get(srow.uuid)
if (local) {
const localUa = String(local.updated_at || '')
const serverUa = String(srow.updated_at || '')
if (serverUa > localUa) {
const patch: AnyRow = { ...srow }
if (t === 'setting') delete patch.key
if (t === 'log_item') {
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
if (!wlId) continue
patch.work_log_id = wlId
delete patch.wuuid
patch.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : (local.project_id ?? null)
delete patch.puuid
}
await table.update(local.id, patch)
pulled++
}
} else {
const nkVal = nk && srow[nk] != null && srow[nk] !== '' ? String(srow[nk]) : null
const dup = nkVal ? byNatural.get(nkVal) : undefined
if (dup && dup.uuid !== srow.uuid) {
const dupUa = String(dup.updated_at || '')
const serverUa = String(srow.updated_at || '')
if (serverUa >= dupUa) {
// 服务端较新:先迁移关联数据,删除本地重复行,写入服务端行
if (t === 'work_log') {
await db.log_item.where('work_log_id').equals(dup.id as number).modify({ work_log_id: -1 })
}
await table.delete(pkOf(t, dup))
await db.tombstone.add({ table: t, uuid: dup.uuid, deleted_at: nowLocalISO() })
const newId = await table.add({ ...srow })
if (t === 'work_log') {
await db.log_item.where('work_log_id').equals(-1).modify({ work_log_id: newId })
}
byUuid.set(srow.uuid, { ...srow, id: newId })
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
pulled++
} else {
// 本地较新:跳过服务端行,记墓碑待下次推送清除服务端副本
await db.tombstone.add({ table: t, uuid: srow.uuid, deleted_at: nowLocalISO() })
applyConflicts++
}
} else {
if (t === 'log_item') {
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
if (!wlId) continue
srow.work_log_id = wlId
delete srow.wuuid
srow.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : null
delete srow.puuid
}
const newId = await table.add({ ...srow })
byUuid.set(srow.uuid, { ...srow, id: newId })
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
pulled++
}
}
} catch (err) {
applyErrors++
lastApplyError = err instanceof Error ? err.message : String(err)
}
}
// 阶段二:应用墓碑删除(work_log 删除时把本地条目迁移到同日幸存者)
for (const row of localRows) {
if (!dead.has(row.uuid)) continue
try {
if (t === 'work_log') {
const freshRows: AnyRow[] = await table.toArray()
const survivor = freshRows.find(r2 => r2.date === row.date && !dead.has(r2.uuid) && r2.uuid !== row.uuid)
if (survivor && row.id) {
await db.log_item.where('work_log_id').equals(row.id as number).modify({ work_log_id: survivor.id as number })
}
}
await table.delete(pkOf(t, row))
removed++
} catch (err) {
applyErrors++
}
}
// 外键重映射表:work_log 与 project 处理完成后构建
if (t === 'work_log') {
const fresh: AnyRow[] = await table.toArray()
localWorkLogIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
}
if (t === 'project') {
const fresh: AnyRow[] = await table.toArray()
localProjectIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
}
// 服务端不应存在的临时设置键:推墓碑到服务端做一次清理
const pendingTombstones: { table: string; uuid: string; deleted_at: string }[] = []
function tombstoneLater(t: string, uuid: string): void {
if (!pendingTombstones.some(x => x.table === t && x.uuid === uuid)) {
pendingTombstones.push({ table: t, uuid, deleted_at: nowLocalISO() })
}
void lastApplyError
void applyConflicts
return { pulled, removed, errors: applyErrors }
}
// 每轮推送时把待清理的临时键墓碑捎带上去
export function drainServerCleanup(): { table: string; uuid: string; deleted_at: string }[] {
return pendingTombstones.splice(0)
}