feat(object-browser): preserve object list on return via stale cache
This commit is contained in:
1 parent
a6cd501a78
commit
8642e1a599
6 files changed
+189
-19
No files matched your search
@@ -155,7 +155,7 @@ import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||||
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
|
||||
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
|
||||
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRowsForScaffold, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
|
||||
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
|
||||
import { loadObjectDdl, type ObjectDdlRequest } from "@/lib/metadata/objectDdlCache";
|
||||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
@@ -211,6 +211,8 @@ const sortKey = ref<ObjectBrowserSortKey>("name");
|
||||
const sortDirection = ref<ObjectBrowserSortDirection>("asc");
|
||||
const loadingSchemas = ref(false);
|
||||
const loadingObjects = ref(false);
|
||||
const refreshingObjects = ref(false);
|
||||
const scaffoldRefreshError = ref("");
|
||||
const sourceLoading = ref(false);
|
||||
const sourceContent = ref("");
|
||||
const sourceError = ref("");
|
||||
@@ -2815,22 +2817,57 @@ watch([() => props.initialEventName, () => props.initialEventOpenRequestId, () =
|
||||
openInitialEventIfNeeded();
|
||||
});
|
||||
|
||||
async function loadObjects(options?: { allowCached?: boolean }) {
|
||||
async function loadObjects(options?: { allowCached?: boolean; preserveExistingRows?: boolean }) {
|
||||
error.value = "";
|
||||
// A new load supersedes any in-flight one, so reset the transient refresh flags
|
||||
// on entry. A superseded request's finally() can no longer run (the guard's
|
||||
// epoch moved on) and would otherwise leave refreshingObjects stuck spinning
|
||||
// the toolbar icon — the newest request owns the spinner state from here.
|
||||
loadingObjects.value = false;
|
||||
refreshingObjects.value = false;
|
||||
scaffoldRefreshError.value = "";
|
||||
// True when we are revalidating on top of visible rows (a stale-cache scaffold, or a
|
||||
// same-instance refresh) — a failure then keeps the rows and raises a non-blocking
|
||||
// banner instead of replacing the whole list with a full-area error.
|
||||
let scaffoldRefresh = false;
|
||||
const schema = needsSchema.value ? selectedSchema.value || "" : props.database;
|
||||
const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema));
|
||||
const cacheWriteToken = createObjectBrowserRowsCacheWriteToken(request.scope);
|
||||
if (options?.allowCached) {
|
||||
const cachedRows = getCachedObjectBrowserRows(request.scope);
|
||||
if (cachedRows) {
|
||||
applyObjectBrowserRows(cachedRows);
|
||||
finishObjectBrowserRowsLoad();
|
||||
return;
|
||||
|
||||
// finishObjectBrowserRowsLoad() is idempotent, but keep the default-filter /
|
||||
// viewport restores to a single run per load so the events-preferred-filter case
|
||||
// doesn't leave preserveObjectFilterScrollOnce latched across an extra call.
|
||||
let finished = false;
|
||||
const finishOnce = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
finishObjectBrowserRowsLoad();
|
||||
};
|
||||
|
||||
const cached = options?.allowCached ? getCachedObjectBrowserRowsForScaffold(request.scope) : undefined;
|
||||
if (cached) {
|
||||
// Scaffold the last-known rows so returning to this tab shows them instantly
|
||||
// instead of flashing an empty list. A fresh entry (< TTL) is authoritative on
|
||||
// its own; a stale one is presented and then revalidated in the background.
|
||||
applyObjectBrowserRows(cached.rows);
|
||||
finishOnce();
|
||||
if (!cached.stale) return;
|
||||
scaffoldRefresh = true;
|
||||
refreshingObjects.value = true;
|
||||
} else {
|
||||
// No scaffold: first load in this scope, cache invalidated by a DDL mutation,
|
||||
// or the caller wants a true reload. If the caller explicitly asked to keep the
|
||||
// current rows (same-instance refresh) and rows exist, refresh without blanking.
|
||||
const keepExisting = options?.preserveExistingRows && rows.value.length > 0;
|
||||
if (keepExisting) {
|
||||
scaffoldRefresh = true;
|
||||
refreshingObjects.value = true;
|
||||
} else {
|
||||
loadingObjects.value = true;
|
||||
rows.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
loadingObjects.value = true;
|
||||
rows.value = [];
|
||||
try {
|
||||
const nextRows = props.connection.db_type === "mongodb" ? await loadMongoObjectBrowserRows(request.scope.connectionId, request.scope.database) : await loadSqlObjectBrowserRows(request);
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||||
@@ -2839,9 +2876,19 @@ async function loadObjects(options?: { allowCached?: boolean }) {
|
||||
if (props.connection.db_type !== "mongodb") void loadObjectStatistics(request, cacheWriteToken, cachedAt);
|
||||
} catch (e: any) {
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||||
error.value = translateBackendError(t, e);
|
||||
// Keep visible rows on a background revalidate failure — surface a lightweight
|
||||
// banner rather than replacing the scaffold (or same-instance refresh) list.
|
||||
if (scaffoldRefresh) {
|
||||
scaffoldRefreshError.value = translateBackendError(t, e);
|
||||
} else {
|
||||
error.value = translateBackendError(t, e);
|
||||
}
|
||||
} finally {
|
||||
if (objectBrowserRowsLoadGuard.isCurrent(request)) finishObjectBrowserRowsLoad();
|
||||
if (objectBrowserRowsLoadGuard.isCurrent(request)) {
|
||||
loadingObjects.value = false;
|
||||
refreshingObjects.value = false;
|
||||
finishOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2894,15 +2941,15 @@ function normalizeStatisticNumber(value: number | null | undefined): number | nu
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number }) {
|
||||
async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number; preserveExistingRows?: boolean }) {
|
||||
const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate();
|
||||
if (!(await loadSchemas(epoch))) return;
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return;
|
||||
await loadObjects({ allowCached: options?.allowCachedObjects });
|
||||
await loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows });
|
||||
}
|
||||
|
||||
function refresh(): boolean {
|
||||
void reload();
|
||||
void reload({ preserveExistingRows: true });
|
||||
void refreshActiveTableInfo();
|
||||
return true;
|
||||
}
|
||||
@@ -3302,7 +3349,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="refreshTooltip" :disabled="loadingObjects" @click="refresh">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects }" />
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects || refreshingObjects }" />
|
||||
</Button>
|
||||
<Button v-if="canPasteTableClipboard()" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openPasteTableDialog">
|
||||
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
|
||||
@@ -3382,6 +3429,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="scaffoldRefreshError" role="status" class="flex h-8 shrink-0 items-center gap-2 border-b border-destructive/30 bg-destructive/5 px-3 text-xs text-destructive">
|
||||
<RefreshCw class="h-3 w-3 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ scaffoldRefreshError }}</span>
|
||||
</div>
|
||||
<div v-if="loadingObjects" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("objects.loading") }}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ describe("ObjectBrowser table metadata refresh", () => {
|
||||
it("refreshes the object list and the open table-info tab from the toolbar", () => {
|
||||
const refresh = functionBody("refresh");
|
||||
|
||||
expect(refresh).toContain("void reload();");
|
||||
expect(refresh).toContain("void reload({ preserveExistingRows: true });");
|
||||
expect(refresh).toContain("void refreshActiveTableInfo();");
|
||||
expect(source).toContain('@click="refresh"');
|
||||
});
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser scaffold refresh race", () => {
|
||||
// Scope A hits stale cache and starts a background revalidate (refreshingObjects = true);
|
||||
// before it settles, the user switches to scope B which hits fresh cache and early-returns.
|
||||
// A's finally() can no longer run (the load guard's epoch moved on), so the flag must be
|
||||
// cleared by the newest request (B) at entry — otherwise the toolbar icon spins forever.
|
||||
it("resets the transient refresh flags at entry, before the cache decision", () => {
|
||||
const body = functionBody("loadObjects");
|
||||
|
||||
const loadingReset = body.indexOf("loadingObjects.value = false;");
|
||||
const refreshingReset = body.indexOf("refreshingObjects.value = false;");
|
||||
const cachedBranch = body.indexOf("const cached =");
|
||||
|
||||
expect(loadingReset).toBeGreaterThanOrEqual(0);
|
||||
expect(refreshingReset).toBeGreaterThanOrEqual(0);
|
||||
// Both resets must precede the cached fresh/stale decision so the newest request
|
||||
// owns the spinner state even when a superseded request's finally() cannot run.
|
||||
expect(Math.min(loadingReset, refreshingReset)).toBeLessThan(cachedBranch);
|
||||
// And neither flag may be turned back on before the branch decides the indicator.
|
||||
expect(body.indexOf("refreshingObjects.value = true;")).toBeGreaterThan(cachedBranch);
|
||||
expect(body.indexOf("loadingObjects.value = true;")).toBeGreaterThan(cachedBranch);
|
||||
});
|
||||
|
||||
it("exposes a non-blocking refresh flag distinct from the blocking full-area load", () => {
|
||||
expect(source).toContain("const refreshingObjects = ref(false);");
|
||||
expect(functionBody("loadObjects")).toContain("refreshingObjects.value = true;");
|
||||
expect(functionBody("loadObjects")).toContain("loadingObjects.value = true;");
|
||||
});
|
||||
|
||||
it("keeps visible rows on a background revalidate failure (scaffold-preserving error)", () => {
|
||||
expect(source).toContain('const scaffoldRefreshError = ref("");');
|
||||
const body = functionBody("loadObjects");
|
||||
// A scaffold/refresh revalidate failure must route to the non-blocking banner
|
||||
// (scaffoldRefreshError) and keep the visible rows, not replace them with the
|
||||
// blocking full-area error.
|
||||
expect(body).toContain("scaffoldRefresh = true;");
|
||||
expect(body).toContain("if (scaffoldRefresh) {");
|
||||
expect(body).toContain("scaffoldRefreshError.value = translateBackendError(t, e)");
|
||||
expect(source).toContain('v-if="scaffoldRefreshError"');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -36,7 +36,7 @@ describe("ContentArea object browser refresh wiring", () => {
|
||||
});
|
||||
|
||||
it("exposes object and active table-info reloads as refresh", () => {
|
||||
expect(objectBrowserSource).toMatch(/function refresh\(\): boolean \{\s+void reload\(\);\s+void refreshActiveTableInfo\(\);\s+return true;\s+\}/);
|
||||
expect(objectBrowserSource).toMatch(/function refresh\(\): boolean \{\s+void reload\(\{ preserveExistingRows: true \}\);\s+void refreshActiveTableInfo\(\);\s+return true;\s+\}/);
|
||||
expect(objectBrowserSource).toContain("defineExpose({ focusSearch, refresh });");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
import { cacheObjectBrowserRows, clearObjectBrowserRowsCache, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||||
import { cacheObjectBrowserRows, clearObjectBrowserRowsCache, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, getCachedObjectBrowserRowsForScaffold, invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||||
|
||||
const row: ObjectBrowserRow = {
|
||||
id: "table:users",
|
||||
@@ -127,4 +127,43 @@ describe("objectBrowserRowsCache", () => {
|
||||
expect(invalidateObjectBrowserRowsCache({ ...scope, tableName: "users" })).toBe(1);
|
||||
expect(getCachedObjectBrowserRows(scope)).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("scaffold reads (allowStale)", () => {
|
||||
it("returns a fresh hit marked stale=false before the TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
|
||||
const scope = { connectionId: "c1", database: "db", schema: "public" };
|
||||
cacheRows(scope);
|
||||
|
||||
const hit = getCachedObjectBrowserRowsForScaffold(scope);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit!.rows).toEqual([row]);
|
||||
expect(hit!.stale).toBe(false);
|
||||
expect(hit!.ageMs).toBe(0);
|
||||
expect(hit!.cachedAt).toBe(Date.now());
|
||||
});
|
||||
|
||||
it("surfaces an expired entry as a stale scaffold instead of refusing it", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
|
||||
const scope = { connectionId: "c1", database: "db", schema: "public" };
|
||||
cacheRows(scope);
|
||||
|
||||
vi.advanceTimersByTime(30_001);
|
||||
const hit = getCachedObjectBrowserRowsForScaffold(scope);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit!.rows).toEqual([row]);
|
||||
expect(hit!.stale).toBe(true);
|
||||
expect(hit!.ageMs).toBeGreaterThan(30_000);
|
||||
});
|
||||
|
||||
it("returns undefined when no scaffold is available (uncached or invalidated)", () => {
|
||||
const scope = { connectionId: "c1", database: "db", schema: "public" };
|
||||
expect(getCachedObjectBrowserRowsForScaffold(scope)).toBeUndefined();
|
||||
|
||||
cacheRows(scope);
|
||||
invalidateObjectBrowserRowsCache({ connectionId: "c1", database: "db", schema: "public" });
|
||||
expect(getCachedObjectBrowserRowsForScaffold(scope)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,28 @@ export function getCachedObjectBrowserRows(scope: ObjectBrowserRowsCacheScope):
|
||||
return hit ? cloneRows(hit.value) : undefined;
|
||||
}
|
||||
|
||||
export interface ObjectBrowserRowsCacheHit {
|
||||
rows: ObjectBrowserRow[];
|
||||
stale: boolean;
|
||||
ageMs: number;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the cached rows for a scope as a **scaffold**, allowing stale entries
|
||||
* (beyond the TTL) to be surfaced and refreshed in the background. This never
|
||||
* adds a new cache entry or expands capacity — a stale hit is the same Map entry
|
||||
* the TTL would otherwise refuse to return, only now with its freshness metadata.
|
||||
*
|
||||
* Carries `stale`/`ageMs`/`cachedAt` so the caller can decide: keep an entry
|
||||
* under TTL as-is, or present it immediately then revalidate in the background.
|
||||
*/
|
||||
export function getCachedObjectBrowserRowsForScaffold(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRowsCacheHit | undefined {
|
||||
const hit = objectBrowserRowsCache.get(objectBrowserRowsScope(scope), { allowStale: true });
|
||||
if (!hit) return undefined;
|
||||
return { rows: cloneRows(hit.value), stale: hit.stale, ageMs: hit.ageMs, cachedAt: hit.cachedAt };
|
||||
}
|
||||
|
||||
export function createObjectBrowserRowsCacheWriteToken(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRowsCacheWriteToken {
|
||||
const frozenScope = Object.freeze({ ...scope });
|
||||
return Object.freeze({ generation: objectBrowserRowsCacheGeneration(frozenScope).generation, scope: frozenScope });
|
||||
|
||||
Reference in new issue
Block a user