feat(history): add query history filters
This commit is contained in:
1 parent
1c7576537a
commit
d16ba82d5a
22 files changed
+1324
-40
No files matched your search
@@ -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("Ä");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user