feat(history): add query history filters

This commit is contained in:
auth0 authored and GitHub committed 2026-07-22 12:22:01 +08:00
1 parent 1c7576537a
commit d16ba82d5a
22 files changed
+1324 -40

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "@/lib/backend/api";
import type { HistoryEntry, HistorySearchRequest, HistorySearchResult } from "@/lib/backend/api";
import { useHistoryStore } from "@/stores/historyStore";
vi.mock("@/lib/backend/api", () => ({
searchHistory: vi.fn(),
saveHistory: vi.fn(),
deleteHistoryEntry: vi.fn(),
clearHistory: vi.fn(),
loadHistoryConnectionOptions: vi.fn(),
}));
const request: HistorySearchRequest = {
search_text: "",
connections: [],
databases: [],
limit: 100,
};
function entry(overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
id: "history-1",
connection_id: "conn-a",
connection_name: "Primary",
database: "sales",
sql: "select 1",
executed_at: "2026-07-18T08:00:00.000Z",
execution_time_ms: 10,
success: true,
activity_kind: "query",
...overrides,
};
}
function addInput(stored = entry()): Omit<HistoryEntry, "id" | "executed_at"> {
const { id: _id, executed_at: _executedAt, ...input } = stored;
return input;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => {
resolve = complete;
});
return { promise, resolve };
}
describe("historyStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
vi.mocked(api.loadHistoryConnectionOptions).mockResolvedValue([]);
});
it("ignores a delayed search after deleting an entry", async () => {
const stored = entry();
vi.mocked(api.searchHistory).mockResolvedValueOnce({ entries: [stored], total: 1, next_cursor: null });
const store = useHistoryStore();
await store.search(request);
const delayed = deferred<HistorySearchResult>();
vi.mocked(api.searchHistory).mockReturnValueOnce(delayed.promise);
vi.mocked(api.deleteHistoryEntry).mockResolvedValue(undefined);
const searchPromise = store.search(request);
await store.remove(stored.id);
delayed.resolve({ entries: [stored], total: 1, next_cursor: { executed_at: stored.executed_at, id: stored.id } });
await searchPromise;
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(store.nextCursor).toBeNull();
});
it("ignores a delayed search after clearing history", async () => {
const stored = entry();
vi.mocked(api.searchHistory).mockResolvedValueOnce({ entries: [stored], total: 1, next_cursor: null });
const store = useHistoryStore();
await store.search(request);
const delayed = deferred<HistorySearchResult>();
vi.mocked(api.searchHistory).mockReturnValueOnce(delayed.promise);
vi.mocked(api.clearHistory).mockResolvedValue(undefined);
const searchPromise = store.search(request);
await store.clear();
delayed.resolve({ entries: [stored], total: 1, next_cursor: { executed_at: stored.executed_at, id: stored.id } });
await searchPromise;
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(store.nextCursor).toBeNull();
});
it("ignores a search started while deletion is pending", async () => {
const stored = entry();
vi.mocked(api.searchHistory).mockResolvedValueOnce({ entries: [stored], total: 1, next_cursor: null });
const store = useHistoryStore();
await store.search(request);
const deletion = deferred<void>();
const delayedSearch = deferred<HistorySearchResult>();
vi.mocked(api.deleteHistoryEntry).mockReturnValueOnce(deletion.promise);
vi.mocked(api.searchHistory).mockReturnValueOnce(delayedSearch.promise);
const removePromise = store.remove(stored.id);
const searchPromise = store.search(request);
deletion.resolve(undefined);
await removePromise;
delayedSearch.resolve({ entries: [stored], total: 1, next_cursor: { executed_at: stored.executed_at, id: stored.id } });
await searchPromise;
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(store.nextCursor).toBeNull();
expect(store.loading).toBe(false);
});
it("does not restore connection options returned after clear", async () => {
const delayedOptions = deferred<Awaited<ReturnType<typeof api.loadHistoryConnectionOptions>>>();
vi.mocked(api.loadHistoryConnectionOptions).mockReturnValueOnce(delayedOptions.promise);
vi.mocked(api.clearHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
const optionsPromise = store.loadConnectionOptions();
await store.clear();
delayedOptions.resolve([{ connection_id: "conn-a", connection_name: "Primary", databases: ["sales"] }]);
await optionsPromise;
expect(store.connectionOptions).toEqual([]);
});
it("does not let delayed connection options overwrite a newly added option", async () => {
const delayedOptions = deferred<Awaited<ReturnType<typeof api.loadHistoryConnectionOptions>>>();
vi.mocked(api.loadHistoryConnectionOptions).mockReturnValueOnce(delayedOptions.promise);
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
const optionsPromise = store.loadConnectionOptions();
await store.add(addInput());
delayedOptions.resolve([]);
await optionsPromise;
expect(store.connectionOptions).toEqual([{ connection_id: "conn-a", connection_name: "Primary", databases: ["sales"] }]);
});
it("refreshes an active panel even when its initial search is still pending", async () => {
const initialSearch = deferred<HistorySearchResult>();
const persisted = entry({ id: "persisted-new" });
vi.mocked(api.searchHistory)
.mockReturnValueOnce(initialSearch.promise)
.mockResolvedValueOnce({ entries: [persisted], total: 1, next_cursor: null });
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
const initialPromise = store.search(request);
await store.add(addInput());
initialSearch.resolve({ entries: [], total: 0, next_cursor: null });
await initialPromise;
expect(store.entries).toEqual([persisted]);
expect(store.total).toBe(1);
});
it("does not refresh after the history panel closes", async () => {
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
store.setHistoryPanelActive(false);
await store.add(addInput());
expect(api.searchHistory).not.toHaveBeenCalled();
});
it("refreshes from storage after insertion instead of exceeding the persisted limit", async () => {
const stored = entry();
vi.mocked(api.searchHistory)
.mockResolvedValueOnce({ entries: [stored], total: 1000, next_cursor: null })
.mockResolvedValueOnce({ entries: [entry({ id: "persisted-new" })], total: 1000, next_cursor: null });
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
await store.search(request);
await store.add(addInput(stored));
expect(store.total).toBe(1000);
expect(store.entries[0]?.id).toBe("persisted-new");
});
it("uses SQLite results for non-ASCII text searches", async () => {
const filteredRequest = { ...request, search_text: "Ä" };
vi.mocked(api.searchHistory).mockResolvedValue({ entries: [], total: 0, next_cursor: null });
vi.mocked(api.saveHistory).mockResolvedValue(undefined);
const store = useHistoryStore();
store.setHistoryPanelActive(true);
await store.search(filteredRequest);
const stored = entry({ sql: "select 'ä'" });
await store.add(addInput(stored));
expect(store.entries).toEqual([]);
expect(store.total).toBe(0);
expect(vi.mocked(api.searchHistory).mock.calls.at(-1)?.[0].search_text).toBe("Ä");
});
});
+178 -11
View File
@@ -2,21 +2,147 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref } from "vue";
import * as api from "@/lib/backend/api";
import type { HistoryEntry } from "@/lib/backend/api";
import type { HistoryConnectionOption, HistoryCursor, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
const DEFAULT_HISTORY_SEARCH: HistorySearchRequest = {
search_text: "",
connections: [],
databases: [],
limit: 100,
};
function copyRequest(request: HistorySearchRequest): HistorySearchRequest {
return {
...request,
connections: request.connections.map((connection) => ({ ...connection })),
databases: request.databases.map((database) => ({ ...database })),
cursor: request.cursor ? { ...request.cursor } : undefined,
};
}
export const useHistoryStore = defineStore("history", () => {
const entries = ref<HistoryEntry[]>([]);
const connectionOptions = ref<HistoryConnectionOption[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const total = ref(0);
const nextCursor = ref<HistoryCursor | null>(null);
const error = ref("");
const activeRequest = ref<HistorySearchRequest>(copyRequest(DEFAULT_HISTORY_SEARCH));
// Ignore stale responses when filters change faster than the backend can respond.
let requestSerial = 0;
let mutationGeneration = 0;
let destructiveMutations = 0;
let historyPanelActive = false;
let optionsRequestSerial = 0;
let latestRequestedSearch = copyRequest(DEFAULT_HISTORY_SEARCH);
function invalidateConnectionOptionRequests() {
optionsRequestSerial += 1;
}
function setHistoryPanelActive(active: boolean) {
historyPanelActive = active;
if (active) return;
requestSerial += 1;
invalidateConnectionOptionRequests();
loading.value = false;
loadingMore.value = false;
}
function beginDestructiveMutation() {
requestSerial += 1;
mutationGeneration += 1;
destructiveMutations += 1;
invalidateConnectionOptionRequests();
loading.value = false;
loadingMore.value = false;
}
function endDestructiveMutation() {
destructiveMutations = Math.max(0, destructiveMutations - 1);
// Invalidate searches started while the backend mutation was still pending.
mutationGeneration += 1;
requestSerial += 1;
loading.value = false;
loadingMore.value = false;
}
async function search(request: HistorySearchRequest, append = false) {
const serial = ++requestSerial;
const generation = mutationGeneration;
const baseRequest = copyRequest({ ...request, cursor: undefined });
if (!append) latestRequestedSearch = copyRequest(baseRequest);
const actualRequest = copyRequest({
...baseRequest,
cursor: append ? (nextCursor.value ?? undefined) : undefined,
});
if (append) loadingMore.value = true;
else loading.value = true;
error.value = "";
try {
const result = await api.searchHistory(actualRequest);
if (serial !== requestSerial || generation !== mutationGeneration || destructiveMutations > 0) return;
activeRequest.value = baseRequest;
if (append) {
// A new entry may shift page boundaries while loading; deduplicate before appending.
const knownIds = new Set(entries.value.map((entry) => entry.id));
entries.value.push(...result.entries.filter((entry) => !knownIds.has(entry.id)));
} else {
entries.value = result.entries;
}
total.value = result.total;
nextCursor.value = result.next_cursor ?? null;
} catch (searchError) {
if (serial !== requestSerial || generation !== mutationGeneration || destructiveMutations > 0) return;
error.value = searchError instanceof Error ? searchError.message : String(searchError);
throw searchError;
} finally {
if (serial === requestSerial) {
loading.value = false;
loadingMore.value = false;
}
}
}
async function load() {
loading.value = true;
await search(DEFAULT_HISTORY_SEARCH);
}
async function loadMore() {
if (!nextCursor.value || loading.value || loadingMore.value) return;
try {
entries.value = await api.loadHistory(200, 0);
} finally {
loading.value = false;
await search(activeRequest.value, true);
} catch {
// The error is already exposed through store.error; prevent an unhandled click promise.
}
}
async function loadConnectionOptions() {
const serial = ++optionsRequestSerial;
try {
const options = await api.loadHistoryConnectionOptions();
if (serial !== optionsRequestSerial) return;
connectionOptions.value = options;
} catch (loadError) {
if (serial !== optionsRequestSerial) return;
throw loadError;
}
}
function addConnectionOption(entry: HistoryEntry) {
const option = connectionOptions.value.find((candidate) => (entry.connection_id ? candidate.connection_id === entry.connection_id : !candidate.connection_id && candidate.connection_name === entry.connection_name));
if (option) {
if (entry.database && !option.databases.includes(entry.database)) option.databases.push(entry.database);
return;
}
connectionOptions.value.unshift({
connection_id: entry.connection_id ?? "",
connection_name: entry.connection_name,
databases: entry.database ? [entry.database] : [],
});
}
async function add(entry: Omit<HistoryEntry, "id" | "executed_at">) {
const full: HistoryEntry = {
...entry,
@@ -24,19 +150,60 @@ export const useHistoryStore = defineStore("history", () => {
executed_at: new Date().toISOString(),
};
await api.saveHistory(full);
entries.value.unshift(full);
if (entries.value.length > 200) entries.value.pop();
invalidateConnectionOptionRequests();
addConnectionOption(full);
if (historyPanelActive) {
try {
// Re-query SQLite so eviction, totals, cursors, and text matching stay authoritative.
await search(latestRequestedSearch);
} catch {
// The history panel exposes refresh failures without failing the completed query execution.
}
}
}
async function remove(id: string) {
await api.deleteHistoryEntry(id);
entries.value = entries.value.filter((e) => e.id !== id);
beginDestructiveMutation();
try {
await api.deleteHistoryEntry(id);
} finally {
endDestructiveMutation();
}
const wasVisible = entries.value.some((entry) => entry.id === id);
entries.value = entries.value.filter((entry) => entry.id !== id);
if (wasVisible) total.value = Math.max(0, total.value - 1);
void loadConnectionOptions().catch(() => {});
}
async function clear() {
await api.clearHistory();
beginDestructiveMutation();
try {
await api.clearHistory();
} finally {
endDestructiveMutation();
}
entries.value = [];
connectionOptions.value = [];
total.value = 0;
nextCursor.value = null;
}
return { entries, loading, load, add, remove, clear };
return {
entries,
connectionOptions,
loading,
loadingMore,
total,
nextCursor,
error,
activeRequest,
search,
load,
loadMore,
loadConnectionOptions,
setHistoryPanelActive,
add,
remove,
clear,
};
});