diff --git a/apps/api/index.js b/apps/api/index.js index a77d914..cd939c6 100644 --- a/apps/api/index.js +++ b/apps/api/index.js @@ -429,6 +429,24 @@ async function gracefulShutdown(signal) { } } +let shutdownHandlersRegistered = false; + +function registerShutdownHandlers() { + if (shutdownHandlersRegistered) { + return; + } + + const handleSignal = (signal) => { + gracefulShutdown(signal).catch((error) => { + console.error(`Failed to shut down after ${signal}:`, error); + }); + }; + + process.on('SIGTERM', () => handleSignal('SIGTERM')); + process.on('SIGINT', () => handleSignal('SIGINT')); + shutdownHandlersRegistered = true; +} + async function startServer() { try { @@ -468,6 +486,7 @@ module.exports = { server, io, startServer, + registerShutdownHandlers, initializePersistence, updateRoomMembers, gracefulShutdown, @@ -480,5 +499,6 @@ module.exports = { }; if (require.main === module) { + registerShutdownHandlers(); startServer(); } diff --git a/apps/api/index.test.js b/apps/api/index.test.js index 8e51de3..be79e40 100644 --- a/apps/api/index.test.js +++ b/apps/api/index.test.js @@ -19,13 +19,14 @@ describe('server sync flow', () => { let startServer; let gracefulShutdown; let handleSocketConnection; + let registerShutdownHandlers; const roomId = 'valid-room-12345'; beforeEach(() => { jest.resetModules(); process.env.PORT = '3102'; - ({ app, server, stores, startServer, gracefulShutdown, handleSocketConnection } = require('./index')); + ({ app, server, stores, startServer, gracefulShutdown, handleSocketConnection, registerShutdownHandlers } = require('./index')); stores.chainStore.clear(); stores.socketMeta.clear(); stores.chunkStore.clear(); @@ -145,4 +146,15 @@ describe('server sync flow', () => { version: 99, })); }); + + test('registerShutdownHandlers wires SIGINT and SIGTERM', () => { + const onSpy = jest.spyOn(process, 'on').mockImplementation(() => process); + + registerShutdownHandlers(); + + expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + + onSpy.mockRestore(); + }); }); diff --git a/apps/api/src/persistence/RedisPersistence.js b/apps/api/src/persistence/RedisPersistence.js index 53440ec..542788e 100644 --- a/apps/api/src/persistence/RedisPersistence.js +++ b/apps/api/src/persistence/RedisPersistence.js @@ -114,6 +114,33 @@ class RedisPersistence extends PersistenceAdapter { return `${this.options.keyPrefix}log:${roomId}`; } + /** + * 使用 SCAN 非阻塞遍历匹配的 key + * @param {string} pattern + * @returns {Promise} + * @private + */ + async _scanKeys(pattern) { + const keys = []; + let cursor = '0'; + + do { + const reply = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + let batch = []; + + if (Array.isArray(reply)) { + [cursor, batch] = reply; + } else { + cursor = reply?.cursor ?? '0'; + batch = reply?.keys ?? []; + } + + keys.push(...batch); + } while (cursor !== '0' && cursor !== 0); + + return keys; + } + /** * 保存同步链数据 * @param {string} roomId - 房间ID @@ -199,9 +226,8 @@ class RedisPersistence extends PersistenceAdapter { let deletedCount = 0; try { - // 扫描所有房间 key const roomPattern = `${this.options.keyPrefix}room:*`; - const keys = await this.client.keys(roomPattern); + const keys = await this._scanKeys(roomPattern); for (const key of keys) { try { @@ -354,9 +380,8 @@ class RedisPersistence extends PersistenceAdapter { const info = await this.client.info('memory'); const keyCount = await this.client.dbSize(); - // 统计房间和日志数量 - const roomKeys = await this.client.keys(`${this.options.keyPrefix}room:*`); - const logKeys = await this.client.keys(`${this.options.keyPrefix}log:*`); + const roomKeys = await this._scanKeys(`${this.options.keyPrefix}room:*`); + const logKeys = await this._scanKeys(`${this.options.keyPrefix}log:*`); return { connected: this.isConnected, diff --git a/apps/api/src/persistence/__tests__/RedisPersistence.test.js b/apps/api/src/persistence/__tests__/RedisPersistence.test.js index fc398a7..632732c 100644 --- a/apps/api/src/persistence/__tests__/RedisPersistence.test.js +++ b/apps/api/src/persistence/__tests__/RedisPersistence.test.js @@ -11,8 +11,10 @@ jest.mock('redis', () => ({ get: jest.fn().mockResolvedValue(null), del: jest.fn().mockResolvedValue(1), keys: jest.fn().mockResolvedValue([]), + scan: jest.fn().mockResolvedValue(['0', []]), hSet: jest.fn().mockResolvedValue('OK'), hGetAll: jest.fn().mockResolvedValue({}), + hGet: jest.fn().mockResolvedValue(null), expire: jest.fn().mockResolvedValue(1), info: jest.fn().mockResolvedValue(''), dbSize: jest.fn().mockResolvedValue(0), @@ -86,6 +88,45 @@ describe('RedisPersistence', () => { }); }); + describe('cleanupExpired', () => { + it('scans room keys instead of blocking with KEYS', async () => { + await redisPersistence.connect(); + + redisPersistence.client.scan + .mockResolvedValueOnce(['0', ['notesync:room:old-room', 'notesync:room:new-room']]); + redisPersistence.client.hGet + .mockResolvedValueOnce(String(new Date('2020-01-01').getTime())) + .mockResolvedValueOnce(String(new Date('2030-01-01').getTime())); + + const deleted = await redisPersistence.cleanupExpired(new Date('2025-01-01')); + + expect(deleted).toBe(1); + expect(redisPersistence.client.scan).toHaveBeenCalled(); + expect(redisPersistence.client.keys).not.toHaveBeenCalled(); + expect(redisPersistence.client.del).toHaveBeenCalledWith('notesync:room:old-room'); + expect(redisPersistence.client.del).toHaveBeenCalledWith('notesync:log:old-room'); + }); + }); + + describe('getStats', () => { + it('counts room and log keys via SCAN', async () => { + await redisPersistence.connect(); + + redisPersistence.client.scan + .mockResolvedValueOnce(['0', ['notesync:room:one', 'notesync:room:two']]) + .mockResolvedValueOnce(['0', ['notesync:log:one']]); + redisPersistence.client.info.mockResolvedValue('used_memory:42'); + redisPersistence.client.dbSize.mockResolvedValue(7); + + const stats = await redisPersistence.getStats(); + + expect(stats.roomCount).toBe(2); + expect(stats.logCount).toBe(1); + expect(redisPersistence.client.scan).toHaveBeenCalledTimes(2); + expect(redisPersistence.client.keys).not.toHaveBeenCalled(); + }); + }); + describe('close', () => { it('should close connection gracefully', async () => { await redisPersistence.connect(); diff --git a/apps/web/src/hooks/__tests__/useOffline.test.js b/apps/web/src/hooks/__tests__/useOffline.test.js index a820eb1..76999f1 100644 --- a/apps/web/src/hooks/__tests__/useOffline.test.js +++ b/apps/web/src/hooks/__tests__/useOffline.test.js @@ -15,8 +15,9 @@ vi.mock('../utils/offline', () => ({ })); vi.mock('../utils/storage', () => ({ - getStorageManager: vi.fn().mockReturnValue({ + createStorageManager: vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), }), })); diff --git a/apps/web/src/hooks/__tests__/useStorage.test.js b/apps/web/src/hooks/__tests__/useStorage.test.js index b88f54f..e8ec9af 100644 --- a/apps/web/src/hooks/__tests__/useStorage.test.js +++ b/apps/web/src/hooks/__tests__/useStorage.test.js @@ -4,7 +4,7 @@ import { useStorage } from '../useStorage'; // Mock storage manager vi.mock('../utils/storage', () => ({ - getStorageManager: vi.fn().mockReturnValue({ + createStorageManager: vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), getStorageType: vi.fn().mockReturnValue('indexeddb'), saveNotebook: vi.fn().mockResolvedValue(undefined), @@ -19,6 +19,7 @@ vi.mock('../utils/storage', () => ({ getHistory: vi.fn().mockResolvedValue([]), cleanupHistory: vi.fn().mockResolvedValue(undefined), enqueueOperation: vi.fn().mockResolvedValue('op-id'), + listOperations: vi.fn().mockResolvedValue([]), dequeueOperations: vi.fn().mockResolvedValue([]), clearQueue: vi.fn().mockResolvedValue(undefined), removeOperation: vi.fn().mockResolvedValue(undefined), @@ -70,6 +71,7 @@ describe('useStorage', () => { const { result } = renderHook(() => useStorage()); expect(typeof result.current.enqueueOperation).toBe('function'); + expect(typeof result.current.listOperations).toBe('function'); expect(typeof result.current.dequeueOperations).toBe('function'); expect(typeof result.current.clearQueue).toBe('function'); expect(typeof result.current.removeOperation).toBe('function'); diff --git a/apps/web/src/hooks/socket/__tests__/socket-event-binding.test.js b/apps/web/src/hooks/socket/__tests__/socket-event-binding.test.js new file mode 100644 index 0000000..bd27a73 --- /dev/null +++ b/apps/web/src/hooks/socket/__tests__/socket-event-binding.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; + +const toast = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), +})); + +vi.mock('react-hot-toast', () => ({ + default: toast, +})); + +import { bindSocketEvents } from '../socket-event-binding'; + +const createMockSocket = () => { + const handlers = {}; + return { + handlers, + on: vi.fn((event, handler) => { + handlers[event] = handler; + }), + emit: vi.fn(), + }; +}; + +describe('socket-event-binding', () => { + it('binds connect handler that joins room and flushes queue', async () => { + const socket = createMockSocket(); + const reconnectAttemptRef = { current: 5 }; + const isReconnectingRef = { current: false }; + const setStatus = vi.fn(); + const initOfflineQueue = vi.fn().mockResolvedValue(undefined); + const processQueuedOperations = vi.fn().mockResolvedValue(undefined); + + bindSocketEvents({ + socket, + keys: { roomId: 'room-1', encryptionKey: 'key-1' }, + name: 'Laptop', + t: { + connected: 'connected', + disconnected: 'disconnected', + reconnecting: 'reconnecting', + reconnected: 'reconnected', + syncError: 'sync-error', + }, + setStatus, + setMembers: vi.fn(), + initOfflineQueue, + processQueuedOperations, + handleRemoteContent: vi.fn(), + chunkManager: { reassemble: vi.fn() }, + reconnectAttemptRef, + isReconnectingRef, + }); + + await socket.handlers.connect(); + + expect(setStatus).toHaveBeenCalledWith('connected'); + expect(reconnectAttemptRef.current).toBe(0); + expect(socket.emit).toHaveBeenCalledWith('join-chain', { + roomId: 'room-1', + deviceName: 'Laptop', + }); + expect(initOfflineQueue).toHaveBeenCalled(); + expect(processQueuedOperations).toHaveBeenCalled(); + }); + + it('uses processSyncPayload dependency for sync-update handling', async () => { + const socket = createMockSocket(); + const processSyncPayload = vi.fn().mockResolvedValue(undefined); + const payload = { encryptedData: 'ciphertext' }; + + bindSocketEvents({ + socket, + keys: { roomId: 'room-1', encryptionKey: 'key-1' }, + name: 'Laptop', + t: { + connected: 'connected', + disconnected: 'disconnected', + reconnecting: 'reconnecting', + reconnected: 'reconnected', + syncError: 'sync-error', + }, + setStatus: vi.fn(), + setMembers: vi.fn(), + initOfflineQueue: vi.fn().mockResolvedValue(undefined), + processQueuedOperations: vi.fn().mockResolvedValue(undefined), + handleRemoteContent: vi.fn(), + chunkManager: { reassemble: vi.fn() }, + reconnectAttemptRef: { current: 0 }, + isReconnectingRef: { current: false }, + processSyncPayload, + }); + + await socket.handlers['sync-update'](payload); + + expect(processSyncPayload).toHaveBeenCalledWith({ + payload, + encryptionKey: 'key-1', + chunkManager: expect.any(Object), + onRemoteContent: expect.any(Function), + }); + }); +}); diff --git a/apps/web/src/hooks/socket/__tests__/socket-sync-utils.test.js b/apps/web/src/hooks/socket/__tests__/socket-sync-utils.test.js new file mode 100644 index 0000000..0875abf --- /dev/null +++ b/apps/web/src/hooks/socket/__tests__/socket-sync-utils.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + emitEncryptedUpdate, + processEncryptedSyncPayload, +} from '../socket-sync-utils'; + +describe('socket-sync-utils', () => { + it('emitEncryptedUpdate emits encrypted push-update payload(s)', () => { + const socket = { emit: vi.fn() }; + const keys = { roomId: 'room-1', encryptionKey: 'key-1' }; + + const hash = emitEncryptedUpdate({ + socket, + keys, + content: 'hello world', + timestamp: 12345, + }); + + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); + expect(socket.emit).toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith( + 'push-update', + expect.objectContaining({ + roomId: 'room-1', + timestamp: 12345, + chunkIndex: 0, + totalChunks: expect.any(Number), + }) + ); + }); + + it('processEncryptedSyncPayload resolves plain content payloads', async () => { + const payload = { + encryptedData: 'cipher', + version: 2, + timestamp: 200, + deviceName: 'remote', + }; + const onRemoteContent = vi.fn().mockResolvedValue(undefined); + const chunkManager = { reassemble: vi.fn() }; + const decrypt = vi.fn().mockReturnValue({ content: 'remote-content' }); + + await processEncryptedSyncPayload({ + payload, + encryptionKey: 'key', + chunkManager, + onRemoteContent, + decrypt, + }); + + expect(onRemoteContent).toHaveBeenCalledWith('remote-content', payload); + expect(chunkManager.reassemble).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/socket/socket-event-binding.js b/apps/web/src/hooks/socket/socket-event-binding.js new file mode 100644 index 0000000..037db57 --- /dev/null +++ b/apps/web/src/hooks/socket/socket-event-binding.js @@ -0,0 +1,102 @@ +import toast from 'react-hot-toast'; +import { processEncryptedSyncPayload } from './socket-sync-utils'; + +export const bindSocketEvents = ({ + socket, + keys, + name, + t, + setStatus, + setMembers, + initOfflineQueue, + processQueuedOperations, + handleRemoteContent, + chunkManager, + reconnectAttemptRef, + isReconnectingRef, + processSyncPayload = processEncryptedSyncPayload, +}) => { + socket.on('connect', async () => { + setStatus('connected'); + reconnectAttemptRef.current = 0; + + socket.emit('join-chain', { + roomId: keys.roomId, + deviceName: name, + }); + + await initOfflineQueue(); + await processQueuedOperations(); + + if (isReconnectingRef.current) { + toast.success(t.reconnected); + isReconnectingRef.current = false; + } else { + toast.success(t.connected); + } + }); + + socket.on('sync-update', async (payload) => { + if (payload && payload.encryptedData) { + try { + await processSyncPayload({ + payload, + encryptionKey: keys.encryptionKey, + chunkManager, + onRemoteContent: handleRemoteContent, + }); + } catch (err) { + console.error('Decryption error:', err); + } + } + }); + + socket.on('room-info', (data) => { + if (data && data.members) { + setMembers(data.members); + } + }); + + socket.on('disconnect', (reason) => { + setStatus('disconnected'); + if (reason !== 'io client disconnect') { + toast.error(t.disconnected); + } + }); + + socket.on('reconnect_attempt', (attempt) => { + reconnectAttemptRef.current = attempt; + isReconnectingRef.current = true; + setStatus('syncing'); + if (attempt === 1) { + toast.loading(t.reconnecting, { id: 'reconnecting' }); + } + }); + + socket.on('reconnect', async () => { + toast.dismiss('reconnecting'); + socket.emit('join-chain', { + roomId: keys.roomId, + deviceName: name, + }); + await processQueuedOperations(); + }); + + socket.on('reconnect_failed', () => { + toast.dismiss('reconnecting'); + toast.error(t.disconnected); + setStatus('disconnected'); + }); + + socket.on('connect_error', (error) => { + console.error('Connection error:', error); + if (reconnectAttemptRef.current === 0) { + setStatus('disconnected'); + } + }); + + socket.on('error', (error) => { + console.error('Socket error:', error); + toast.error(t.syncError); + }); +}; diff --git a/apps/web/src/hooks/socket/socket-sync-utils.js b/apps/web/src/hooks/socket/socket-sync-utils.js new file mode 100644 index 0000000..ec54266 --- /dev/null +++ b/apps/web/src/hooks/socket/socket-sync-utils.js @@ -0,0 +1,55 @@ +import { encryptData, decryptData } from '../../utils/crypto'; +import { hashContent, splitIntoChunks } from '../../utils/sync'; + +export const emitEncryptedUpdate = ({ + socket, + keys, + content, + timestamp = Date.now(), +}) => { + const chunks = splitIntoChunks(content); + const sessionId = Date.now().toString(); + + chunks.forEach((chunk) => { + const dataToEncrypt = chunks.length === 1 + ? { content } + : { chunked: true, sessionId, chunk }; + + const encrypted = encryptData(dataToEncrypt, keys.encryptionKey); + + socket.emit('push-update', { + roomId: keys.roomId, + encryptedData: encrypted, + timestamp, + chunkIndex: chunk.index, + totalChunks: chunks.length, + }); + }); + + return hashContent(content); +}; + +export const processEncryptedSyncPayload = async ({ + payload, + encryptionKey, + chunkManager, + onRemoteContent, + decrypt = decryptData, +}) => { + const decrypted = decrypt(payload.encryptedData, encryptionKey); + if (!decrypted) { + return; + } + + if (decrypted.chunked) { + const fullContent = chunkManager.reassemble(decrypted.sessionId, decrypted.chunk); + if (fullContent !== null) { + await onRemoteContent(fullContent, payload); + } + return; + } + + if (decrypted.content !== undefined) { + await onRemoteContent(decrypted.content, payload); + } +}; diff --git a/apps/web/src/hooks/useOffline.js b/apps/web/src/hooks/useOffline.js index 9fa5dfb..e1c3837 100644 --- a/apps/web/src/hooks/useOffline.js +++ b/apps/web/src/hooks/useOffline.js @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { OfflineQueue } from '../utils/offline'; -import { getStorageManager } from '../utils/storage'; +import { createStorageManager } from '../utils/storage'; /** * 离线状态管理 Hook @@ -21,9 +21,11 @@ export const useOffline = () => { if (queueRef.current) return; try { - const storage = getStorageManager(); + if (!storageRef.current) { + storageRef.current = createStorageManager(); + } + const storage = storageRef.current; await storage.initialize(); - storageRef.current = storage; queueRef.current = new OfflineQueue(storage); @@ -140,6 +142,18 @@ export const useOffline = () => { return () => clearInterval(interval); }, []); + useEffect(() => { + return () => { + if (storageRef.current) { + storageRef.current.close().catch((closeError) => { + console.error('Failed to close offline storage manager:', closeError); + }); + storageRef.current = null; + } + queueRef.current = null; + }; + }, []); + return { // 状态 isOnline, diff --git a/apps/web/src/hooks/useSocket.js b/apps/web/src/hooks/useSocket.js index ccab98c..8d488b9 100644 --- a/apps/web/src/hooks/useSocket.js +++ b/apps/web/src/hooks/useSocket.js @@ -1,17 +1,18 @@ import { useRef, useCallback, useEffect, useMemo, useState } from 'react'; import { io } from 'socket.io-client'; import { useAppStore } from '../store/useStore'; -import { deriveKeys, encryptData, decryptData } from '../utils/crypto'; +import { deriveKeys } from '../utils/crypto'; import { ConflictService } from '../utils/conflict'; import { OfflineQueue } from '../utils/offline'; -import { getStorageManager } from '../utils/storage'; +import { createStorageManager } from '../utils/storage'; import debounce from 'lodash.debounce'; import toast from 'react-hot-toast'; +import { emitEncryptedUpdate } from './socket/socket-sync-utils'; +import { bindSocketEvents } from './socket/socket-event-binding'; import { getSocketUrl, getMessages, hashContent, - splitIntoChunks, createChunkSessionManager, HISTORY_THROTTLE_MS, MAX_RECONNECTION_ATTEMPTS, @@ -46,6 +47,7 @@ export const useSocket = () => { const [conflictCount, setConflictCount] = useState(0); // Offline queue + const storageManagerRef = useRef(null); const offlineQueueRef = useRef(null); const [queueSize, setQueueSize] = useState(0); const [isProcessingQueue, setIsProcessingQueue] = useState(false); @@ -72,7 +74,10 @@ export const useSocket = () => { if (offlineQueueRef.current) return offlineQueueRef.current; try { - const storage = getStorageManager(); + if (!storageManagerRef.current) { + storageManagerRef.current = createStorageManager(); + } + const storage = storageManagerRef.current; await storage.initialize(); offlineQueueRef.current = new OfflineQueue(storage); const size = await offlineQueueRef.current.getQueueSize(); @@ -119,26 +124,12 @@ export const useSocket = () => { } try { - const chunks = splitIntoChunks(content); - const sessionId = Date.now().toString(); - - chunks.forEach((chunk) => { - const dataToEncrypt = chunks.length === 1 - ? { content } - : { chunked: true, sessionId, chunk }; - - const encrypted = encryptData(dataToEncrypt, keysRef.current.encryptionKey); - - socketRef.current.emit('push-update', { - roomId: keysRef.current.roomId, - encryptedData: encrypted, - timestamp: Date.now(), - chunkIndex: chunk.index, - totalChunks: chunks.length, - }); + lastSyncedHashRef.current = emitEncryptedUpdate({ + socket: socketRef.current, + keys: keysRef.current, + content, + timestamp: Date.now(), }); - - lastSyncedHashRef.current = hashContent(content); setStatus('connected'); } catch (err) { console.error('Push update error:', err); @@ -221,26 +212,12 @@ export const useSocket = () => { try { const content = operation.data; - const chunks = splitIntoChunks(content); - const sessionId = Date.now().toString(); - - for (const chunk of chunks) { - const dataToEncrypt = chunks.length === 1 - ? { content } - : { chunked: true, sessionId, chunk }; - - const encrypted = encryptData(dataToEncrypt, keysRef.current.encryptionKey); - - socketRef.current.emit('push-update', { - roomId: keysRef.current.roomId, - encryptedData: encrypted, - timestamp: Date.now(), - chunkIndex: chunk.index, - totalChunks: chunks.length, - }); - } - - lastSyncedHashRef.current = hashContent(content); + lastSyncedHashRef.current = emitEncryptedUpdate({ + socket: socketRef.current, + keys: keysRef.current, + content, + timestamp: Date.now(), + }); return { success: true }; } catch (err) { console.error('Failed to process queued operation:', err); @@ -307,95 +284,19 @@ export const useSocket = () => { const socket = socketRef.current; - // Event handlers - socket.on('connect', async () => { - setStatus('connected'); - reconnectAttemptRef.current = 0; - - socket.emit('join-chain', { - roomId: keys.roomId, - deviceName: name, - }); - - await initOfflineQueue(); - await processQueuedOperations(); - - if (isReconnectingRef.current) { - toast.success(t.reconnected); - isReconnectingRef.current = false; - } else { - toast.success(t.connected); - } - }); - - socket.on('sync-update', async (payload) => { - if (payload && payload.encryptedData) { - try { - const decrypted = decryptData(payload.encryptedData, keys.encryptionKey); - if (decrypted) { - // Handle chunked content - if (decrypted.chunked) { - const fullContent = chunkManagerRef.current.reassemble(decrypted.sessionId, decrypted.chunk); - if (fullContent !== null) { - await handleRemoteContent(fullContent, payload); - } - } else if (decrypted.content !== undefined) { - await handleRemoteContent(decrypted.content, payload); - } - } - } catch (err) { - console.error('Decryption error:', err); - } - } - }); - - socket.on('room-info', (data) => { - if (data && data.members) { - setMembers(data.members); - } - }); - - socket.on('disconnect', (reason) => { - setStatus('disconnected'); - if (reason !== 'io client disconnect') { - toast.error(t.disconnected); - } - }); - - socket.on('reconnect_attempt', (attempt) => { - reconnectAttemptRef.current = attempt; - isReconnectingRef.current = true; - setStatus('syncing'); - if (attempt === 1) { - toast.loading(t.reconnecting, { id: 'reconnecting' }); - } - }); - - socket.on('reconnect', async () => { - toast.dismiss('reconnecting'); - socket.emit('join-chain', { - roomId: keys.roomId, - deviceName: name, - }); - await processQueuedOperations(); - }); - - socket.on('reconnect_failed', () => { - toast.dismiss('reconnecting'); - toast.error(t.disconnected); - setStatus('disconnected'); - }); - - socket.on('connect_error', (error) => { - console.error('Connection error:', error); - if (reconnectAttemptRef.current === 0) { - setStatus('disconnected'); - } - }); - - socket.on('error', (error) => { - console.error('Socket error:', error); - toast.error(t.syncError); + bindSocketEvents({ + socket, + keys, + name, + t, + setStatus, + setMembers, + initOfflineQueue, + processQueuedOperations, + handleRemoteContent, + chunkManager: chunkManagerRef.current, + reconnectAttemptRef, + isReconnectingRef, }); setView('app'); @@ -446,6 +347,13 @@ export const useSocket = () => { conflictManagerRef.current?.clearConflicts(); setPendingConflicts([]); setConflictCount(0); + offlineQueueRef.current = null; + if (storageManagerRef.current) { + storageManagerRef.current.close().catch((closeError) => { + console.error('Failed to close socket storage manager:', closeError); + }); + storageManagerRef.current = null; + } setQueueSize(0); setIsProcessingQueue(false); }, []); diff --git a/apps/web/src/hooks/useStorage.js b/apps/web/src/hooks/useStorage.js index a86e0c5..3c6fb32 100644 --- a/apps/web/src/hooks/useStorage.js +++ b/apps/web/src/hooks/useStorage.js @@ -1,5 +1,5 @@ import { useState, useRef, useCallback, useEffect } from 'react'; -import { getStorageManager } from '../utils/storage'; +import { createStorageManager } from '../utils/storage'; /** * 存储管理 Hook @@ -24,10 +24,12 @@ export const useStorage = () => { setError(null); try { - const storage = getStorageManager(); + if (!storageRef.current) { + storageRef.current = createStorageManager(); + } + const storage = storageRef.current; await storage.initialize(); - storageRef.current = storage; setStorageType(storage.getStorageType()); setIsInitialized(true); setIsLoading(false); @@ -119,6 +121,11 @@ export const useStorage = () => { return storageRef.current.enqueueOperation(op); }, [ensureInitialized]); + const listOperations = useCallback(async () => { + await ensureInitialized(); + return storageRef.current.listOperations(); + }, [ensureInitialized]); + const dequeueOperations = useCallback(async () => { await ensureInitialized(); return storageRef.current.dequeueOperations(); @@ -151,6 +158,17 @@ export const useStorage = () => { initialize(); }, [initialize]); + useEffect(() => { + return () => { + if (storageRef.current) { + storageRef.current.close().catch((closeError) => { + console.error('Failed to close storage manager:', closeError); + }); + storageRef.current = null; + } + }; + }, []); + return { // 状态 isInitialized, @@ -180,6 +198,7 @@ export const useStorage = () => { // 离线队列操作 enqueueOperation, + listOperations, dequeueOperations, clearQueue, removeOperation, diff --git a/apps/web/src/store/domain/__tests__/notebook-domain.test.js b/apps/web/src/store/domain/__tests__/notebook-domain.test.js new file mode 100644 index 0000000..bbbcf87 --- /dev/null +++ b/apps/web/src/store/domain/__tests__/notebook-domain.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + applyAddNotebook, + applySetNote, + applyUpdateNote, + applyRemoveNotebook, +} from '../notebook-domain'; + +describe('notebook-domain helpers', () => { + it('applySetNote updates active note content and metadata', () => { + const now = 1700000000000; + vi.spyOn(Date, 'now').mockReturnValue(now); + + const state = { + notes: [ + { + id: 'note-1', + content: 'before', + version: 2, + timestamp: 100, + updatedAt: 100, + deviceId: 'desktop', + }, + ], + activeNoteId: 'note-1', + noteVersion: 2, + noteDeviceId: 'desktop', + deviceName: 'desktop', + }; + + const next = applySetNote(state, 'after', { + version: 3, + timestamp: 200, + deviceId: 'mobile', + }); + + expect(next.note).toBe('after'); + expect(next.noteVersion).toBe(3); + expect(next.noteTimestamp).toBe(200); + expect(next.noteDeviceId).toBe('mobile'); + expect(next.notes[0].content).toBe('after'); + expect(next.notes[0].version).toBe(3); + + vi.restoreAllMocks(); + }); + + it('applyUpdateNote bumps version when update payload omits version', () => { + const state = { + notes: [ + { + id: 'note-1', + content: 'before', + version: 7, + timestamp: 100, + updatedAt: 100, + deviceId: 'desktop', + }, + ], + activeNoteId: 'note-1', + deviceName: 'desktop', + }; + + const next = applyUpdateNote(state, 'note-1', { content: 'after' }); + + expect(next.notes[0].content).toBe('after'); + expect(next.notes[0].version).toBe(8); + expect(next.note).toBe('after'); + expect(next.noteVersion).toBe(8); + }); + + it('applyAddNotebook activates notebook and resets note context', () => { + const state = { + notebooks: [], + activeNotebookId: null, + deviceName: 'desktop', + mnemonic: '', + }; + + const next = applyAddNotebook(state, { + id: 'nb-1', + name: 'work', + mnemonic: 'test test test test test test test test test test test ball', + roomId: 'room-1', + encryptionKey: 'k', + }); + + expect(next.notebooks).toHaveLength(1); + expect(next.activeNotebookId).toBe('nb-1'); + expect(next.activeNoteId).toBe(null); + expect(next.note).toBe(''); + expect(next.mnemonic).toContain('test test'); + }); + + it('applyRemoveNotebook removes related notes and keeps next notebook selected', () => { + const state = { + notebooks: [ + { id: 'nb-1', mnemonic: 'm1' }, + { id: 'nb-2', mnemonic: 'm2' }, + ], + notes: [ + { id: 'n1', notebookId: 'nb-1', content: 'a', version: 1, timestamp: 1, deviceId: 'd' }, + { id: 'n2', notebookId: 'nb-2', content: 'b', version: 3, timestamp: 3, deviceId: 'd' }, + ], + activeNotebookId: 'nb-1', + activeNoteId: 'n1', + }; + + const next = applyRemoveNotebook(state, 'nb-1'); + + expect(next.notebooks).toHaveLength(1); + expect(next.notes).toHaveLength(1); + expect(next.activeNotebookId).toBe('nb-2'); + expect(next.activeNoteId).toBe('n2'); + expect(next.note).toBe('b'); + expect(next.mnemonic).toBe('m2'); + }); +}); diff --git a/apps/web/src/store/domain/notebook-domain.js b/apps/web/src/store/domain/notebook-domain.js new file mode 100644 index 0000000..9f26919 --- /dev/null +++ b/apps/web/src/store/domain/notebook-domain.js @@ -0,0 +1,180 @@ +import { generateUniqueId } from '../../utils/shared'; +import { createNotebook as buildNotebook } from '../../utils/notebooks'; + +const LOCAL_DEVICE_ID = 'local'; + +export const selectNotebookNote = (notes, notebookId) => { + return notes + .filter((note) => note.notebookId === notebookId) + .sort((a, b) => (b.updatedAt || b.timestamp || 0) - (a.updatedAt || a.timestamp || 0))[0]; +}; + +export const applySetNote = (state, note, meta) => { + const timestamp = meta?.timestamp ?? Date.now(); + const version = meta?.version ?? state.noteVersion; + const deviceId = meta?.deviceId ?? (state.deviceName || state.noteDeviceId || LOCAL_DEVICE_ID); + + return { + notes: state.notes.map((entry) => ( + entry.id === state.activeNoteId + ? { + ...entry, + content: note, + version, + timestamp, + updatedAt: timestamp, + deviceId, + } + : entry + )), + note, + noteVersion: version, + noteTimestamp: timestamp, + noteDeviceId: deviceId, + }; +}; + +export const applyAddNote = (state, note) => { + const now = Date.now(); + const newNote = { + id: note.id || generateUniqueId('note_'), + title: note.title || '未命名笔记', + content: note.content || '', + version: note.version || 1, + timestamp: note.timestamp || now, + deviceId: note.deviceId || state.deviceName || LOCAL_DEVICE_ID, + notebookId: note.notebookId || state.activeNotebookId, + createdAt: note.createdAt || now, + updatedAt: note.updatedAt || now, + }; + + return { + notes: [...state.notes, newNote], + activeNoteId: newNote.id, + note: newNote.content, + noteVersion: newNote.version, + noteTimestamp: newNote.timestamp, + noteDeviceId: newNote.deviceId, + }; +}; + +export const applyUpdateNote = (state, noteId, updates) => { + const now = Date.now(); + const notes = state.notes.map((note) => { + if (note.id !== noteId) { + return note; + } + + return { + ...note, + ...updates, + version: (updates.version !== undefined) ? updates.version : note.version + 1, + updatedAt: now, + }; + }); + + const updatedNote = notes.find((note) => note.id === noteId); + if (noteId === state.activeNoteId && updatedNote) { + return { + notes, + note: updatedNote.content, + noteVersion: updatedNote.version, + noteTimestamp: updatedNote.timestamp || now, + noteDeviceId: updatedNote.deviceId || state.deviceName || LOCAL_DEVICE_ID, + }; + } + + return { notes }; +}; + +export const applyRemoveNote = (state, noteId) => { + const notes = state.notes.filter((note) => note.id !== noteId); + if (noteId === state.activeNoteId) { + const nextNote = notes[0]; + return { + notes, + activeNoteId: nextNote?.id || null, + note: nextNote?.content || '', + noteVersion: nextNote?.version || 0, + noteTimestamp: nextNote?.timestamp || 0, + noteDeviceId: nextNote?.deviceId || LOCAL_DEVICE_ID, + }; + } + + return { notes }; +}; + +export const applySetActiveNoteId = (state, noteId) => { + const note = state.notes.find((entry) => entry.id === noteId); + if (note) { + return { + activeNoteId: noteId, + note: note.content, + noteVersion: note.version, + noteTimestamp: note.timestamp, + noteDeviceId: note.deviceId, + }; + } + + return { activeNoteId: noteId }; +}; + +export const applyAddNotebook = (state, notebook) => { + const newNotebook = buildNotebook(notebook); + return { + notebooks: [...state.notebooks, newNotebook], + activeNotebookId: newNotebook.id, + activeNoteId: null, + mnemonic: newNotebook.mnemonic || state.mnemonic, + note: '', + noteVersion: 0, + noteTimestamp: 0, + noteDeviceId: state.deviceName || LOCAL_DEVICE_ID, + }; +}; + +export const applyUpdateNotebook = (state, notebookId, updates) => ({ + notebooks: state.notebooks.map((entry) => + entry.id === notebookId + ? { ...entry, ...updates, updatedAt: Date.now() } + : entry + ), +}); + +export const applyRemoveNotebook = (state, notebookId) => { + const notebooks = state.notebooks.filter((entry) => entry.id !== notebookId); + const notes = state.notes.filter((note) => note.notebookId !== notebookId); + + if (notebookId === state.activeNotebookId) { + const nextNotebook = notebooks[0]; + const nextNote = selectNotebookNote(notes, nextNotebook?.id); + return { + notebooks, + notes, + activeNotebookId: nextNotebook?.id || null, + activeNoteId: nextNote?.id || null, + mnemonic: nextNotebook?.mnemonic || '', + note: nextNote?.content || '', + noteVersion: nextNote?.version || 0, + noteTimestamp: nextNote?.timestamp || 0, + noteDeviceId: nextNote?.deviceId || LOCAL_DEVICE_ID, + }; + } + + return { notebooks, notes }; +}; + +export const applySetActiveNotebookId = (state, notebookId) => { + const notebook = state.notebooks.find((entry) => entry.id === notebookId); + const firstNote = selectNotebookNote(state.notes, notebookId); + + return { + activeNotebookId: notebookId, + activeNoteId: firstNote?.id || null, + mnemonic: notebook?.mnemonic || state.mnemonic, + note: firstNote?.content || '', + noteVersion: firstNote?.version || 0, + noteTimestamp: firstNote?.timestamp || 0, + noteDeviceId: firstNote?.deviceId || LOCAL_DEVICE_ID, + }; +}; diff --git a/apps/web/src/store/useStore.js b/apps/web/src/store/useStore.js index 7fb8436..7b1d7e4 100644 --- a/apps/web/src/store/useStore.js +++ b/apps/web/src/store/useStore.js @@ -1,13 +1,16 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { generateUniqueId } from '../utils/shared'; -import { createNotebook as buildNotebook } from '../utils/notebooks'; - -const selectNotebookNote = (notes, notebookId) => { - return notes - .filter((note) => note.notebookId === notebookId) - .sort((a, b) => (b.updatedAt || b.timestamp || 0) - (a.updatedAt || a.timestamp || 0))[0]; -}; +import { + applyAddNote, + applyAddNotebook, + applyRemoveNote, + applyRemoveNotebook, + applySetActiveNoteId, + applySetActiveNotebookId, + applySetNote, + applyUpdateNote, + applyUpdateNotebook, +} from './domain/notebook-domain'; export const useAppStore = create( persist( @@ -79,26 +82,7 @@ export const useAppStore = create( setDeviceName: (deviceName) => set({ deviceName }), setMembers: (members) => set({ members }), - setNote: (note, meta) => { - set((state) => ({ - notes: state.notes.map((entry) => ( - entry.id === state.activeNoteId - ? { - ...entry, - content: note, - version: meta?.version ?? state.noteVersion, - timestamp: meta?.timestamp ?? Date.now(), - updatedAt: meta?.timestamp ?? Date.now(), - deviceId: meta?.deviceId ?? (state.deviceName || entry.deviceId || 'local'), - } - : entry - )), - note, - noteVersion: meta?.version ?? state.noteVersion, - noteTimestamp: meta?.timestamp ?? Date.now(), - noteDeviceId: meta?.deviceId ?? (state.deviceName || state.noteDeviceId || 'local'), - })); - }, + setNote: (note, meta) => set((state) => applySetNote(state, note, meta)), setCurrentFileType: (currentFileType) => set({ currentFileType }), // History Management @@ -172,153 +156,24 @@ export const useAppStore = create( // Multi-note Actions setNotes: (notes) => set({ notes }), - addNote: (note) => set((state) => { - const newNote = { - id: note.id || generateUniqueId('note_'), - title: note.title || '未命名笔记', - content: note.content || '', - version: note.version || 1, - timestamp: note.timestamp || Date.now(), - deviceId: note.deviceId || state.deviceName || 'local', - notebookId: note.notebookId || state.activeNotebookId, - createdAt: note.createdAt || Date.now(), - updatedAt: note.updatedAt || Date.now(), - }; - return { - notes: [...state.notes, newNote], - activeNoteId: newNote.id, - note: newNote.content, - noteVersion: newNote.version, - noteTimestamp: newNote.timestamp, - noteDeviceId: newNote.deviceId, - }; - }), + addNote: (note) => set((state) => applyAddNote(state, note)), - updateNote: (noteId, updates) => set((state) => { - const notes = state.notes.map((n) => { - if (n.id === noteId) { - return { - ...n, - ...updates, - version: (updates.version !== undefined) ? updates.version : n.version + 1, - updatedAt: Date.now(), - }; - } - return n; - }); - - // 如果更新的是当前活动笔记,同步更新 note 字段 - const updatedNote = notes.find((n) => n.id === noteId); - if (noteId === state.activeNoteId && updatedNote) { - return { - notes, - note: updatedNote.content, - noteVersion: updatedNote.version, - noteTimestamp: updatedNote.timestamp || Date.now(), - noteDeviceId: updatedNote.deviceId || state.deviceName || 'local', - }; - } + updateNote: (noteId, updates) => set((state) => applyUpdateNote(state, noteId, updates)), - return { notes }; - }), + removeNote: (noteId) => set((state) => applyRemoveNote(state, noteId)), - removeNote: (noteId) => set((state) => { - const notes = state.notes.filter((n) => n.id !== noteId); - - // 如果删除的是当前活动笔记,切换到第一个笔记 - if (noteId === state.activeNoteId) { - const nextNote = notes[0]; - return { - notes, - activeNoteId: nextNote?.id || null, - note: nextNote?.content || '', - noteVersion: nextNote?.version || 0, - noteTimestamp: nextNote?.timestamp || 0, - noteDeviceId: nextNote?.deviceId || 'local', - }; - } - - return { notes }; - }), - - setActiveNoteId: (noteId) => set((state) => { - const note = state.notes.find((n) => n.id === noteId); - if (note) { - return { - activeNoteId: noteId, - note: note.content, - noteVersion: note.version, - noteTimestamp: note.timestamp, - noteDeviceId: note.deviceId, - }; - } - return { activeNoteId: noteId }; - }), + setActiveNoteId: (noteId) => set((state) => applySetActiveNoteId(state, noteId)), // Notebook Actions setNotebooks: (notebooks) => set({ notebooks }), - addNotebook: (notebook) => set((state) => { - const newNotebook = buildNotebook(notebook); - return { - notebooks: [...state.notebooks, newNotebook], - activeNotebookId: newNotebook.id, - activeNoteId: null, - mnemonic: newNotebook.mnemonic || state.mnemonic, - note: '', - noteVersion: 0, - noteTimestamp: 0, - noteDeviceId: state.deviceName || 'local', - }; - }), - - updateNotebook: (notebookId, updates) => set((state) => ({ - notebooks: state.notebooks.map((nb) => - nb.id === notebookId - ? { ...nb, ...updates, updatedAt: Date.now() } - : nb - ), - })), + addNotebook: (notebook) => set((state) => applyAddNotebook(state, notebook)), - removeNotebook: (notebookId) => set((state) => { - const notebooks = state.notebooks.filter((nb) => nb.id !== notebookId); - const notes = state.notes.filter((n) => n.notebookId !== notebookId); - - // 如果删除的是当前活动笔记本,切换到第一个 - if (notebookId === state.activeNotebookId) { - const nextNotebook = notebooks[0]; - const nextNote = selectNotebookNote(notes, nextNotebook?.id); - return { - notebooks, - notes, - activeNotebookId: nextNotebook?.id || null, - activeNoteId: nextNote?.id || null, - mnemonic: nextNotebook?.mnemonic || '', - note: nextNote?.content || '', - noteVersion: nextNote?.version || 0, - noteTimestamp: nextNote?.timestamp || 0, - noteDeviceId: nextNote?.deviceId || 'local', - }; - } + updateNotebook: (notebookId, updates) => set((state) => applyUpdateNotebook(state, notebookId, updates)), - return { notebooks, notes }; - }), + removeNotebook: (notebookId) => set((state) => applyRemoveNotebook(state, notebookId)), - setActiveNotebookId: (notebookId) => set((state) => { - // 切换笔记本时,选择该笔记本的第一个笔记 - const notebook = state.notebooks.find((entry) => entry.id === notebookId); - const firstNote = selectNotebookNote(state.notes, notebookId); - - return { - activeNotebookId: notebookId, - activeNoteId: firstNote?.id || null, - mnemonic: notebook?.mnemonic || state.mnemonic, - note: firstNote?.content || '', - noteVersion: firstNote?.version || 0, - noteTimestamp: firstNote?.timestamp || 0, - noteDeviceId: firstNote?.deviceId || 'local', - }; - }), + setActiveNotebookId: (notebookId) => set((state) => applySetActiveNotebookId(state, notebookId)), // Reset resetConnection: () => set({ diff --git a/apps/web/src/utils/__tests__/crypto.test.js b/apps/web/src/utils/__tests__/crypto.test.js index 58b1adc..7b6b2d4 100644 --- a/apps/web/src/utils/__tests__/crypto.test.js +++ b/apps/web/src/utils/__tests__/crypto.test.js @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import CryptoJS from 'crypto-js'; import { generateSyncChain, deriveKeys, @@ -47,6 +48,17 @@ describe('crypto', () => { expect(keys.roomId).toMatch(/^[a-f0-9]{64}$/); expect(keys.encryptionKey).toMatch(/^[a-f0-9]{64}$/); }); + + it('memoizes repeated derivation for the same mnemonic', () => { + const mnemonic = generateSyncChain(); + const pbkdf2Spy = vi.spyOn(CryptoJS, 'PBKDF2'); + + deriveKeys(mnemonic); + deriveKeys(mnemonic); + + expect(pbkdf2Spy).toHaveBeenCalledTimes(1); + pbkdf2Spy.mockRestore(); + }); }); describe('encryptData and decryptData', () => { diff --git a/apps/web/src/utils/conflict/ConflictService.js b/apps/web/src/utils/conflict/ConflictService.js index 0c9f6ac..20ce552 100644 --- a/apps/web/src/utils/conflict/ConflictService.js +++ b/apps/web/src/utils/conflict/ConflictService.js @@ -330,30 +330,57 @@ class ConflictService { * @returns {string} */ autoResolve(conflict, strategy = 'last-write-wins') { + const local = conflict?.localVersion; + const remote = conflict?.remoteVersion; + + if (!local && !remote) { + throw new Error('Conflict must include at least one version'); + } + + const localContent = local?.content; + const remoteContent = remote?.content; + const localTimestamp = Number.isFinite(local?.timestamp) ? local.timestamp : null; + const remoteTimestamp = Number.isFinite(remote?.timestamp) ? remote.timestamp : null; + switch (strategy) { case 'last-write-wins': - // 使用时间戳最新的版本 - return conflict.localVersion.timestamp > conflict.remoteVersion.timestamp - ? conflict.localVersion.content - : conflict.remoteVersion.content; + if (!local) { + return remoteContent; + } + if (!remote) { + return localContent; + } + if (localTimestamp === null) { + return remoteContent; + } + if (remoteTimestamp === null) { + return localContent; + } + return localTimestamp > remoteTimestamp ? localContent : remoteContent; case 'first-write-wins': - // 使用时间戳最早的版本 - return conflict.localVersion.timestamp < conflict.remoteVersion.timestamp - ? conflict.localVersion.content - : conflict.remoteVersion.content; + if (!local) { + return remoteContent; + } + if (!remote) { + return localContent; + } + if (localTimestamp === null) { + return remoteContent; + } + if (remoteTimestamp === null) { + return localContent; + } + return localTimestamp < remoteTimestamp ? localContent : remoteContent; case 'local-wins': - // 总是使用本地版本 - return conflict.localVersion.content; + return localContent ?? remoteContent; case 'remote-wins': - // 总是使用远程版本 - return conflict.remoteVersion.content; + return remoteContent ?? localContent; case 'merge-both': - // 尝试合并两个版本 - return this._mergeBoth(conflict.localVersion.content, conflict.remoteVersion.content); + return this._mergeBoth(localContent ?? '', remoteContent ?? ''); default: throw new Error(`Unknown resolution strategy: ${strategy}`); diff --git a/apps/web/src/utils/conflict/__tests__/ConflictService.test.js b/apps/web/src/utils/conflict/__tests__/ConflictService.test.js index a2bd139..094ae00 100644 --- a/apps/web/src/utils/conflict/__tests__/ConflictService.test.js +++ b/apps/web/src/utils/conflict/__tests__/ConflictService.test.js @@ -212,6 +212,19 @@ describe('ConflictService', () => { expect(resolved).toContain('MERGED FROM REMOTE'); }); + it('should fall back to remote content when local version is missing', () => { + const resolved = service.autoResolve({ + type: 'concurrent_edit', + localVersion: null, + remoteVersion: { + content: 'Remote content', + timestamp: 2000, + }, + }, 'last-write-wins'); + + expect(resolved).toBe('Remote content'); + }); + it('should throw error for unknown strategy', () => { expect(() => { service.autoResolve(conflict, 'unknown-strategy'); diff --git a/apps/web/src/utils/crypto.js b/apps/web/src/utils/crypto.js index 81d06fc..de3e88d 100644 --- a/apps/web/src/utils/crypto.js +++ b/apps/web/src/utils/crypto.js @@ -5,6 +5,9 @@ import CryptoJS from 'crypto-js'; import { Buffer } from 'buffer'; globalThis.Buffer = Buffer; +const DERIVED_KEY_CACHE = new Map(); +const MAX_DERIVED_KEY_CACHE_SIZE = 128; + export const generateSyncChain = () => { // Generate a random 12-word mnemonic const mnemonic = bip39.generateMnemonic(); @@ -12,6 +15,10 @@ export const generateSyncChain = () => { }; export const deriveKeys = (mnemonic) => { + if (DERIVED_KEY_CACHE.has(mnemonic)) { + return DERIVED_KEY_CACHE.get(mnemonic); + } + // 1. Derive Room ID (Public) // SHA256 of the mnemonic — server uses this as the room identifier // but never sees the mnemonic itself. @@ -28,7 +35,15 @@ export const deriveKeys = (mnemonic) => { iterations: PBKDF2_ITERATIONS }).toString(CryptoJS.enc.Hex); - return { roomId, encryptionKey }; + const derived = { roomId, encryptionKey }; + + if (DERIVED_KEY_CACHE.size >= MAX_DERIVED_KEY_CACHE_SIZE) { + const oldestKey = DERIVED_KEY_CACHE.keys().next().value; + DERIVED_KEY_CACHE.delete(oldestKey); + } + + DERIVED_KEY_CACHE.set(mnemonic, derived); + return derived; }; export const encryptData = (data, key) => { diff --git a/apps/web/src/utils/offline/OfflineQueue.js b/apps/web/src/utils/offline/OfflineQueue.js index 3dd5956..9edb741 100644 --- a/apps/web/src/utils/offline/OfflineQueue.js +++ b/apps/web/src/utils/offline/OfflineQueue.js @@ -41,6 +41,10 @@ class OfflineQueue { * 获取队列中的所有操作 */ async getAll() { + if (typeof this.storage.listOperations === 'function') { + return await this.storage.listOperations(); + } + return await this.storage.dequeueOperations(); } diff --git a/apps/web/src/utils/storage/ClientStorage.js b/apps/web/src/utils/storage/ClientStorage.js index 9cb9812..d9dc84c 100644 --- a/apps/web/src/utils/storage/ClientStorage.js +++ b/apps/web/src/utils/storage/ClientStorage.js @@ -194,6 +194,14 @@ class ClientStorage { throw new Error('enqueueOperation method must be implemented'); } + /** + * 列出所有待处理操作 + * @returns {Promise} + */ + async listOperations() { + throw new Error('listOperations method must be implemented'); + } + /** * 获取所有待处理操作 * @returns {Promise} diff --git a/apps/web/src/utils/storage/IndexedDBStorage.js b/apps/web/src/utils/storage/IndexedDBStorage.js index 07c9167..d357d87 100644 --- a/apps/web/src/utils/storage/IndexedDBStorage.js +++ b/apps/web/src/utils/storage/IndexedDBStorage.js @@ -415,16 +415,19 @@ class IndexedDBStorage extends ClientStorage { }); } - async dequeueOperations() { + async listOperations() { const ops = await this._transaction('pendingOps', 'readonly', (store) => { return store.getAll(); }); - // 按时间戳排序 ops.sort((a, b) => a.timestamp - b.timestamp); return ops; } + async dequeueOperations() { + return this.listOperations(); + } + async clearQueue() { await this._transaction('pendingOps', 'readwrite', (store) => { return store.clear(); diff --git a/apps/web/src/utils/storage/LocalStorageAdapter.js b/apps/web/src/utils/storage/LocalStorageAdapter.js index 53dd4d2..e4d02b5 100644 --- a/apps/web/src/utils/storage/LocalStorageAdapter.js +++ b/apps/web/src/utils/storage/LocalStorageAdapter.js @@ -384,13 +384,13 @@ class LocalStorageAdapter extends ClientStorage { } } - async dequeueOperations() { + async listOperations() { this._ensureInitialized(); const indexKey = this._key('ops', 'index'); const index = this._getJSON(indexKey) || []; - const ops = []; + for (const opId of index) { const key = this._key('op', opId); const op = this._getJSON(key); @@ -399,11 +399,14 @@ class LocalStorageAdapter extends ClientStorage { } } - // 按时间戳排序 ops.sort((a, b) => a.timestamp - b.timestamp); return ops; } + async dequeueOperations() { + return this.listOperations(); + } + async clearQueue() { this._ensureInitialized(); diff --git a/apps/web/src/utils/storage/README.md b/apps/web/src/utils/storage/README.md index 162f33e..8a92971 100644 --- a/apps/web/src/utils/storage/README.md +++ b/apps/web/src/utils/storage/README.md @@ -15,10 +15,10 @@ ### 基本使用 ```javascript -import { getStorageManager } from './utils/storage'; +import { createStorageManager } from './utils/storage'; // 获取存储管理器实例 -const storage = getStorageManager(); +const storage = createStorageManager(); // 初始化存储 await storage.initialize(); @@ -221,7 +221,7 @@ console.log('Migration stats:', stats); ## 配置选项 ```javascript -const storage = getStorageManager({ +const storage = createStorageManager({ dbName: 'NoteSyncDB', // IndexedDB 数据库名称 version: 1, // 数据库版本 prefix: 'notesync_', // LocalStorage 键前缀 diff --git a/apps/web/src/utils/storage/StorageManager.js b/apps/web/src/utils/storage/StorageManager.js index 322ae61..83d86d3 100644 --- a/apps/web/src/utils/storage/StorageManager.js +++ b/apps/web/src/utils/storage/StorageManager.js @@ -139,6 +139,11 @@ class StorageManager { return this.storage.enqueueOperation(op); } + async listOperations() { + this._ensureInitialized(); + return this.storage.listOperations(); + } + async dequeueOperations() { this._ensureInitialized(); return this.storage.dequeueOperations(); @@ -217,7 +222,7 @@ class StorageManager { } // 迁移待处理操作 - const operations = await this.dequeueOperations(); + const operations = await this.listOperations(); for (const op of operations) { await targetStorage.enqueueOperation(op); stats.operations++; @@ -231,29 +236,8 @@ class StorageManager { } } -// 创建单例实例 -let storageManagerInstance = null; - -/** - * 获取存储管理器单例 - * @param {Object} options - 配置选项 - * @returns {StorageManager} - */ -export function getStorageManager(options = {}) { - if (!storageManagerInstance) { - storageManagerInstance = new StorageManager(options); - } - return storageManagerInstance; -} - -/** - * 重置存储管理器单例(主要用于测试) - */ -export function resetStorageManager() { - if (storageManagerInstance) { - storageManagerInstance.close(); - storageManagerInstance = null; - } +export function createStorageManager(options = {}) { + return new StorageManager(options); } export default StorageManager; diff --git a/apps/web/src/utils/storage/__tests__/LocalStorageAdapter.test.js b/apps/web/src/utils/storage/__tests__/LocalStorageAdapter.test.js index 256a850..16a10a9 100644 --- a/apps/web/src/utils/storage/__tests__/LocalStorageAdapter.test.js +++ b/apps/web/src/utils/storage/__tests__/LocalStorageAdapter.test.js @@ -231,6 +231,18 @@ describe('LocalStorageAdapter', () => { expect(ops[0].id).toBe(testOperation.id); }); + it('should list queued operations without removing them', async () => { + await storage.enqueueOperation(testOperation); + await storage.enqueueOperation({ ...testOperation, id: 'op-2', timestamp: testOperation.timestamp + 1 }); + + const listed = await storage.listOperations(); + const remaining = await storage.dequeueOperations(); + + expect(listed).toHaveLength(2); + expect(listed.map((op) => op.id)).toEqual(['op-1', 'op-2']); + expect(remaining).toHaveLength(2); + }); + it('should remove specific operation', async () => { await storage.enqueueOperation(testOperation); await storage.removeOperation(testOperation.id); diff --git a/apps/web/src/utils/storage/__tests__/StorageManager.factory.test.js b/apps/web/src/utils/storage/__tests__/StorageManager.factory.test.js new file mode 100644 index 0000000..5833788 --- /dev/null +++ b/apps/web/src/utils/storage/__tests__/StorageManager.factory.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { createStorageManager, default as StorageManager } from '../StorageManager'; + +describe('StorageManager factory', () => { + it('creates independent storage manager instances', () => { + const first = createStorageManager({ dbName: 'db-a' }); + const second = createStorageManager({ dbName: 'db-b' }); + + expect(first).toBeInstanceOf(StorageManager); + expect(second).toBeInstanceOf(StorageManager); + expect(first).not.toBe(second); + expect(first.options.dbName).toBe('db-a'); + expect(second.options.dbName).toBe('db-b'); + }); +}); diff --git a/apps/web/src/utils/storage/__tests__/history.property.test.js b/apps/web/src/utils/storage/__tests__/history.property.test.js index e90a448..09e841e 100644 --- a/apps/web/src/utils/storage/__tests__/history.property.test.js +++ b/apps/web/src/utils/storage/__tests__/history.property.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fc from 'fast-check'; -import { resetStorageManager, getStorageManager } from '../StorageManager'; +import { createStorageManager } from '../StorageManager'; /** * Feature: comprehensive-refactor, Property 9: History Version Limit @@ -12,10 +12,11 @@ import { resetStorageManager, getStorageManager } from '../StorageManager'; */ describe('History Property Tests', () => { let storage; + let testDbName; beforeEach(async () => { - resetStorageManager(); - storage = getStorageManager({ dbName: 'TestNoteSyncDB' }); + testDbName = `TestNoteSyncDB_${Date.now()}_${Math.random().toString(36).slice(2)}`; + storage = createStorageManager({ dbName: testDbName }); await storage.initialize(); }); @@ -23,7 +24,6 @@ describe('History Property Tests', () => { if (storage) { await storage.close(); } - resetStorageManager(); }); describe('Property 9: History Version Limit', () => { diff --git a/apps/web/src/utils/storage/__tests__/storage.property.test.js b/apps/web/src/utils/storage/__tests__/storage.property.test.js index 0934369..91c5a44 100644 --- a/apps/web/src/utils/storage/__tests__/storage.property.test.js +++ b/apps/web/src/utils/storage/__tests__/storage.property.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fc from 'fast-check'; -import { resetStorageManager, getStorageManager } from '../StorageManager'; +import { createStorageManager } from '../StorageManager'; /** * Feature: comprehensive-refactor, Property 1: Storage Round-Trip Consistency @@ -15,10 +15,9 @@ describe('Storage Property Tests', () => { let testDbName; beforeEach(async () => { - resetStorageManager(); // Use unique DB name per test to avoid state leakage testDbName = `TestNoteSyncDB_${Date.now()}_${Math.random().toString(36).slice(2)}`; - storage = getStorageManager({ dbName: testDbName }); + storage = createStorageManager({ dbName: testDbName }); await storage.initialize(); }); @@ -26,7 +25,6 @@ describe('Storage Property Tests', () => { if (storage) { await storage.close(); } - resetStorageManager(); }); describe('Property 1: Storage Round-Trip Consistency', () => { diff --git a/apps/web/src/utils/storage/index.js b/apps/web/src/utils/storage/index.js index c9945af..558e6e8 100644 --- a/apps/web/src/utils/storage/index.js +++ b/apps/web/src/utils/storage/index.js @@ -5,4 +5,4 @@ export { default as ClientStorage } from './ClientStorage'; export { default as IndexedDBStorage } from './IndexedDBStorage'; export { default as LocalStorageAdapter } from './LocalStorageAdapter'; -export { default as StorageManager, getStorageManager, resetStorageManager } from './StorageManager'; +export { default as StorageManager, createStorageManager } from './StorageManager';