🐛 fix(query-editor): 修复异常退出后SQL草稿丢失

- 为查询页签增加 crash-recovery 草稿快照并按 160ms 落盘
- 启动恢复时补齐缺失的 query tabs 与连接上下文
- 保存或关闭查询后清理临时草稿并补充恢复回归测试
This commit is contained in:
Syngnat
2026-06-29 19:21:37 +08:00
parent dac9e1f571
commit bedd1eceb5
5 changed files with 469 additions and 31 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearQueryTabDraft,
@@ -11,7 +11,47 @@ import {
setSQLFileTabDraft,
} from './sqlFileTabDrafts';
class MemoryStorage implements Storage {
private data = new Map<string, string>();
get length(): number {
return this.data.size;
}
clear(): void {
this.data.clear();
}
getItem(key: string): string | null {
return this.data.has(key) ? this.data.get(key)! : null;
}
key(index: number): string | null {
return Array.from(this.data.keys())[index] ?? null;
}
removeItem(key: string): void {
this.data.delete(key);
}
setItem(key: string, value: string): void {
this.data.set(key, String(value));
}
}
describe('sqlFileTabDrafts', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', new MemoryStorage());
localStorage.removeItem('gonavi-query-tab-drafts-v1');
clearQueryTabDraft('query-tab-1');
clearQueryTabDraft('query-recovery-1');
clearSQLFileTabDraft('tab-1');
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('stores query editor drafts outside the persisted tab state', () => {
clearQueryTabDraft('query-tab-1');
@@ -43,4 +83,27 @@ describe('sqlFileTabDrafts', () => {
expect(hasSQLFileTabDraft('tab-1')).toBe(false);
});
it('persists crash recovery snapshots and hydrates them after module reload', async () => {
const drafts = await import('./sqlFileTabDrafts');
drafts.persistQueryTabDraftSnapshot({
id: 'query-recovery-1',
title: '临时 SQL',
connectionId: 'conn-1',
dbName: 'main',
}, 'select * from large_table;');
vi.resetModules();
const reloaded = await import('./sqlFileTabDrafts');
expect(reloaded.getQueryTabDraft('query-recovery-1')).toBe('select * from large_table;');
expect(reloaded.getPersistedQueryTabDraftEntry('query-recovery-1')).toEqual(
expect.objectContaining({
tabId: 'query-recovery-1',
title: '临时 SQL',
connectionId: 'conn-1',
dbName: 'main',
query: 'select * from large_table;',
}),
);
});
});

View File

@@ -1,14 +1,214 @@
import type { TabData } from '../types';
const drafts = new Map<string, string>();
const QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY = 'gonavi-query-tab-drafts-v1';
const QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT = 30;
const QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH = 1024 * 1024;
const QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS = 160;
type PersistedQueryTabDraftEntry = {
tabId: string;
title: string;
query: string;
connectionId: string;
dbName: string;
filePath?: string;
savedQueryId?: string;
readOnly?: boolean;
updatedAt: number;
};
type QueryTabDraftSnapshotTab = Pick<
TabData,
'id' | 'title' | 'connectionId' | 'dbName' | 'filePath' | 'savedQueryId' | 'readOnly'
>;
const persistedDrafts = new Map<string, PersistedQueryTabDraftEntry>();
let persistedDraftsHydrated = false;
let persistTimer: number | null = null;
let flushListenersBound = false;
const toTabId = (value: unknown): string => String(value ?? '').trim();
const toTrimmedString = (value: unknown, fallback = ''): string => {
const text = String(value ?? '').trim();
return text || fallback;
};
const getDraftSnapshotStorage = (): Storage | null => {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage;
};
const normalizePersistedDraftEntry = (
value: unknown,
): PersistedQueryTabDraftEntry | null => {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
const tabId = toTabId(raw.tabId);
if (!tabId) {
return null;
}
const query = String(raw.query ?? '').slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH);
const filePath = toTrimmedString(raw.filePath);
const savedQueryId = toTrimmedString(raw.savedQueryId);
if (!query.trim() && !filePath && !savedQueryId) {
return null;
}
const updatedAt = Number(raw.updatedAt);
return {
tabId,
title: toTrimmedString(raw.title, 'SQL Query'),
query,
connectionId: toTrimmedString(raw.connectionId),
dbName: toTrimmedString(raw.dbName),
filePath: filePath || undefined,
savedQueryId: savedQueryId || undefined,
readOnly: raw.readOnly === true,
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? Math.trunc(updatedAt) : Date.now(),
};
};
const ensurePersistedDraftsHydrated = (): void => {
if (persistedDraftsHydrated) {
return;
}
persistedDraftsHydrated = true;
const storage = getDraftSnapshotStorage();
if (!storage) {
return;
}
try {
const raw = storage.getItem(QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY);
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return;
}
parsed
.map((entry) => normalizePersistedDraftEntry(entry))
.filter((entry): entry is PersistedQueryTabDraftEntry => !!entry)
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT)
.forEach((entry) => {
persistedDrafts.set(entry.tabId, entry);
drafts.set(entry.tabId, entry.query);
});
} catch {
// ignore invalid crash-recovery payloads
}
};
const flushPersistedDrafts = (): void => {
if (persistTimer !== null && typeof window !== 'undefined') {
window.clearTimeout(persistTimer);
persistTimer = null;
}
const storage = getDraftSnapshotStorage();
if (!storage) {
return;
}
try {
if (persistedDrafts.size === 0) {
storage.removeItem(QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY);
return;
}
const payload = Array.from(persistedDrafts.values())
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT);
storage.setItem(
QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY,
JSON.stringify(payload),
);
} catch {
// ignore storage quota or serialization failures
}
};
const bindFlushListeners = (): void => {
if (flushListenersBound || typeof window === 'undefined') {
return;
}
flushListenersBound = true;
const handleFlush = () => {
flushPersistedDrafts();
};
window.addEventListener('pagehide', handleFlush, { capture: true });
window.addEventListener('beforeunload', handleFlush, { capture: true });
if (typeof document !== 'undefined') {
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
flushPersistedDrafts();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
}
};
const schedulePersistedDraftFlush = (): void => {
bindFlushListeners();
if (typeof window === 'undefined') {
flushPersistedDrafts();
return;
}
if (persistTimer !== null) {
window.clearTimeout(persistTimer);
}
persistTimer = window.setTimeout(() => {
flushPersistedDrafts();
}, QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS);
};
const upsertPersistedDraftEntry = (
tab: QueryTabDraftSnapshotTab,
content: string,
overrides?: { connectionId?: string; dbName?: string },
): void => {
ensurePersistedDraftsHydrated();
const tabId = toTabId(tab.id);
if (!tabId) {
return;
}
const query = String(content ?? '').slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH);
const filePath = toTrimmedString(tab.filePath);
const savedQueryId = toTrimmedString(tab.savedQueryId);
const shouldKeep = Boolean(query.trim() || filePath || savedQueryId);
if (!shouldKeep) {
persistedDrafts.delete(tabId);
schedulePersistedDraftFlush();
return;
}
persistedDrafts.set(tabId, {
tabId,
title: toTrimmedString(tab.title, 'SQL Query'),
query,
connectionId: toTrimmedString(overrides?.connectionId, toTrimmedString(tab.connectionId)),
dbName: toTrimmedString(overrides?.dbName, toTrimmedString(tab.dbName)),
filePath: filePath || undefined,
savedQueryId: savedQueryId || undefined,
readOnly: tab.readOnly === true,
updatedAt: Date.now(),
});
schedulePersistedDraftFlush();
};
export const setQueryTabDraft = (tabId: string, content: string): void => {
ensurePersistedDraftsHydrated();
const id = toTabId(tabId);
if (!id) return;
drafts.set(id, String(content ?? ''));
};
export const getQueryTabDraft = (tabId: string, fallback = ''): string => {
ensurePersistedDraftsHydrated();
const id = toTabId(tabId);
if (!id || !drafts.has(id)) {
return fallback;
@@ -17,16 +217,50 @@ export const getQueryTabDraft = (tabId: string, fallback = ''): string => {
};
export const clearQueryTabDraft = (tabId: string): void => {
ensurePersistedDraftsHydrated();
const id = toTabId(tabId);
if (!id) return;
drafts.delete(id);
if (persistedDrafts.delete(id)) {
schedulePersistedDraftFlush();
}
};
export const hasQueryTabDraft = (tabId: string): boolean => {
ensurePersistedDraftsHydrated();
const id = toTabId(tabId);
return Boolean(id && drafts.has(id));
};
export const persistQueryTabDraftSnapshot = (
tab: QueryTabDraftSnapshotTab,
content: string,
overrides?: { connectionId?: string; dbName?: string },
): void => {
const tabId = toTabId(tab.id);
if (!tabId) {
return;
}
setQueryTabDraft(tabId, content);
upsertPersistedDraftEntry(tab, content, overrides);
};
export const getPersistedQueryTabDraftEntry = (
tabId: string,
): PersistedQueryTabDraftEntry | null => {
ensurePersistedDraftsHydrated();
const id = toTabId(tabId);
if (!id) {
return null;
}
return persistedDrafts.get(id) || null;
};
export const listPersistedQueryTabDraftEntries = (): PersistedQueryTabDraftEntry[] => {
ensurePersistedDraftsHydrated();
return Array.from(persistedDrafts.values()).sort((a, b) => b.updatedAt - a.updatedAt);
};
export const setSQLFileTabDraft = (tabId: string, content: string): void => {
setQueryTabDraft(tabId, content);
};