feat(grid): preserve data-grid viewport and selection across tab switches

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-09 22:36:20 +08:00
1 parent 2b26bd6a1a
commit 41b5e51dcc
21 files changed
+1231 -8

No files matched your search

@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DATA_GRID_VIEW_SNAPSHOT_RESTORE,
MAX_CACHE_BYTES,
MAX_SNAPSHOT_BYTES,
MAX_SPARSE_CELL_ENTRIES,
beginClosingDataGridViewSnapshotsForTab,
buildDataGridViewProbe,
clampDataGridViewSelection,
clearDataGridViewSnapshot,
clearDataGridViewSnapshotsForTab,
dataGridViewSnapshotCacheBytes,
dataGridViewSnapshotCacheSize,
peekDataGridViewSnapshot,
resetDataGridViewSnapshots,
saveDataGridViewSnapshot,
shouldNotifyOverBudgetSelection,
type DataGridViewSelectionSnapshot,
type DataGridViewSnapshot,
} from "@/lib/dataGrid/dataGridViewStateCache";
function snapshot(ownerKey: string, patch: Partial<DataGridViewSnapshot> = {}): DataGridViewSnapshot {
return {
ownerKey,
viewGeneration: "gen-1",
probe: "probe",
renderer: "dom",
rowCount: 10,
columnCount: 2,
viewport: { top: 100, left: 0 },
...patch,
};
}
function sparseCellSelection(count: number): DataGridViewSelectionSnapshot {
return { kind: "cells", cellKeys: Array.from({ length: count }, (_, index) => `${index}:${index}`) };
}
describe("dataGridViewStateCache", () => {
beforeEach(() => {
resetDataGridViewSnapshots();
});
it("exposes an enabled rollback switch by default", () => {
expect(DATA_GRID_VIEW_SNAPSHOT_RESTORE).toBe(true);
});
it("round-trips a snapshot and refreshes LRU recency on peek", () => {
saveDataGridViewSnapshot(snapshot("tab-1-run-1-0"));
expect(peekDataGridViewSnapshot("tab-1-run-1-0")?.viewport.top).toBe(100);
expect(peekDataGridViewSnapshot("missing")).toBeUndefined();
});
it("caps the cache at 32 owners and evicts the least recently used", () => {
for (let index = 0; index < 33; index += 1) saveDataGridViewSnapshot(snapshot(`tab-${index}`));
expect(dataGridViewSnapshotCacheSize()).toBe(32);
expect(peekDataGridViewSnapshot("tab-0")).toBeUndefined();
expect(peekDataGridViewSnapshot("tab-32")).toBeDefined();
});
it("measures size in bytes, not UTF-16 code units", () => {
// A selection of multi-byte characters: byte length exceeds code-unit count.
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 300 }, () => "格:式") };
const stored = snapshot("tab-1", { selection });
saveDataGridViewSnapshot(stored);
// ~300 CJK keys cost well above 600 UTF-16 units but above 1800 bytes; a
// code-unit measure would keep this under a tiny budget while bytes exceed it.
const kept = peekDataGridViewSnapshot("tab-1");
expect(JSON.stringify(kept ?? {}).length).toBeGreaterThan(600);
});
it("drops the cell keys when they exceed the per-snapshot byte budget and marks the snapshot", () => {
// Long keys push the serialized selection past the 64 KB budget.
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 1200 }, (_, index) => `${index}:${"cell".repeat(20)}`) };
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection }));
expect(result.droppedSelection).toBe(true);
const kept = peekDataGridViewSnapshot("tab-1");
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toEqual([]);
expect(kept?.selectionDropped).toBe(true);
expect(kept?.viewport.top).toBe(100);
expect(dataGridViewSnapshotCacheBytes()).toBeLessThan(MAX_SNAPSHOT_BYTES);
});
it("keeps a selection that fits the budget without a dropped flag", () => {
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection: sparseCellSelection(3) }));
expect(result.droppedSelection).toBe(false);
const kept = peekDataGridViewSnapshot("tab-1");
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toHaveLength(3);
expect(kept?.selectionDropped).toBeUndefined();
});
it("clamps cell keys beyond the sparse cardinality cap", () => {
const over = sparseCellSelection(MAX_SPARSE_CELL_ENTRIES + 1);
const result = clampDataGridViewSelection(over);
expect(result.droppedSelection).toBe(true);
expect(result.selection.kind === "cells" && result.selection.cellKeys).toEqual([]);
expect(clampDataGridViewSelection(sparseCellSelection(MAX_SPARSE_CELL_ENTRIES)).droppedSelection).toBe(false);
});
it("clears a single owner and its notice marker", () => {
saveDataGridViewSnapshot(snapshot("tab-1"));
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
clearDataGridViewSnapshot("tab-1");
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
});
it("clears every snapshot belonging to a tab", () => {
saveDataGridViewSnapshot(snapshot("tab-1-run-a-0"));
saveDataGridViewSnapshot(snapshot("tab-1-run-b-0"));
saveDataGridViewSnapshot(snapshot("tab-2-run-a-0"));
clearDataGridViewSnapshotsForTab("tab-1");
expect(peekDataGridViewSnapshot("tab-1-run-a-0")).toBeUndefined();
expect(peekDataGridViewSnapshot("tab-1-run-b-0")).toBeUndefined();
expect(peekDataGridViewSnapshot("tab-2-run-a-0")).toBeDefined();
});
it("lets a tab capture again right after a plain clear (no tombstone)", () => {
clearDataGridViewSnapshotsForTab("tab-1");
saveDataGridViewSnapshot(snapshot("tab-1"));
expect(peekDataGridViewSnapshot("tab-1")).toBeDefined();
});
it("notifies at most once per owner and generation", () => {
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(false);
// A new logical result is a new chance to report.
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-2")).toBe(true);
expect(shouldNotifyOverBudgetSelection("tab-2", "gen-1")).toBe(true);
});
it("keeps the whole cache within the global byte budget", () => {
for (let index = 0; index < 40; index += 1) {
saveDataGridViewSnapshot(snapshot(`tab-${index}`, { selection: sparseCellSelection(2000) }));
}
expect(dataGridViewSnapshotCacheBytes()).toBeLessThanOrEqual(MAX_CACHE_BYTES);
expect(dataGridViewSnapshotCacheSize()).toBeLessThanOrEqual(32);
});
it("blocks writes for a closing tab without requiring window timers", () => {
const originalWindow = globalThis.window;
// Test hosts may stub `window` without timer functions.
(globalThis as { window?: unknown }).window = {};
try {
expect(() => beginClosingDataGridViewSnapshotsForTab("tab-1")).not.toThrow();
} finally {
(globalThis as { window?: unknown }).window = originalWindow;
}
saveDataGridViewSnapshot(snapshot("tab-1"));
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
});
});
describe("buildDataGridViewProbe", () => {
it("is deterministic and bounded", () => {
const long = "x".repeat(500);
const input = {
columns: Array.from({ length: 40 }, (_, index) => `column-${index}`),
columnTypes: Array.from({ length: 40 }, (_, index) => `type-${index}`),
rowCount: 3,
firstRow: [long, 1, 2, 3, 4],
lastRow: ["z", 1],
};
const probe = buildDataGridViewProbe(input);
expect(probe).toBe(buildDataGridViewProbe(input));
expect(probe).not.toContain(long);
expect(probe).toContain("x".repeat(64));
// Bounded to 16 columns / 4 probed row columns.
expect(probe).not.toContain("column-20");
});
it("changes when the dataset changes", () => {
const base = { columns: ["id", "name"], rowCount: 1, firstRow: [1, "a"] };
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, firstRow: [2, "a"] }));
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, rowCount: 2 }));
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, columns: ["id", "other"] }));
});
it("skips large-value cells instead of reading them", () => {
const input = {
columns: ["id", "blob"],
rowCount: 1,
firstRow: [1, "huge-value"],
largeValueCells: [{ row_index: 0, column_index: 1 }],
};
expect(buildDataGridViewProbe(input)).not.toContain("huge-value");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
import type { QueryResult } from "@/types/database";
function sampleResult(): QueryResult {
return {
columns: ["id", "name"],
rows: [[1, "a"]],
affected_rows: 0,
execution_time_ms: 1,
};
}
describe("tab result cache view generation", () => {
it("round-trips the tab-level view generation for a data tab with no runs", () => {
const restored = decodeTabResultSnapshot(
encodeTabResultSnapshot({
result: sampleResult(),
resultViewGeneration: "gen-data-tab",
cachedAt: 1,
}),
);
expect(restored?.resultViewGeneration).toBe("gen-data-tab");
});
it("round-trips the per-run view generation", () => {
const restored = decodeTabResultSnapshot(
encodeTabResultSnapshot({
resultRuns: [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(), resultViewGeneration: "gen-run" }],
activeResultRunId: "run-1",
cachedAt: 1,
}),
);
expect(restored?.resultRuns?.[0]?.resultViewGeneration).toBe("gen-run");
});
});
@@ -0,0 +1,309 @@
/**
* Session-scoped cache for the view state of a data grid across tab switches.
*
* `ContentArea` renders only the active tab, so every `DataGrid` is unmounted on
* a tab switch and remounted when the user returns. Scroll position and
* selection live inside the component instance and would otherwise be lost.
*
* This cache deliberately holds primitives only — never a `QueryResult`, a rows
* array, a Vue reactive object, or a callback — so it cannot defeat
* `queryStore.releaseResultObjectPayload()` or inactive-result eviction.
*
* A snapshot is replayed only when the owning grid's `resultViewGeneration`
* matches the value captured with the snapshot. That token is assigned by
* `publishResultGeneration` in `queryStore` (new for every dataset replacement,
* inherited only for disk restore and infinite-scroll append), so a fresh
* result can never adopt a stale viewport (#7341).
*/
export type DataGridRendererMode = "dom" | "canvas";
/** Cell position in display-row / visible-column coordinates. */
export interface DataGridViewCellPosition {
rowIndex: number;
colIndex: number;
}
/**
* Selection captured in grid display coordinates. A generation match already
* guarantees the same dataset, so plain indexes are used instead of the
* identity-token machinery from `dataGridSelectionPersistence.ts` — that path
* builds a token for every row, which is O(row count) and would violate the
* capture budget on large results.
*/
export type DataGridViewSelectionSnapshot =
| { kind: "all"; anchorRowIndex?: number | null }
| { kind: "rows"; rowIndexes: number[]; contiguous?: boolean; anchorRowIndex?: number | null }
| { kind: "columns"; columnIndexes: number[] }
| { kind: "cells"; cellKeys: string[]; anchor?: DataGridViewCellPosition; focus?: DataGridViewCellPosition; selectingAll?: boolean }
| { kind: "range"; anchor: DataGridViewCellPosition; focus: DataGridViewCellPosition; selectingAll?: boolean; lastClickedRowIndex?: number | null };
export interface DataGridViewSnapshot {
/** Grid cache key: `resultGridCacheKey(tab)` for results, tab id for data tabs. */
ownerKey: string;
/** Logical-result identity the grid was rendering when this was captured. */
viewGeneration: string;
/** Bounded deterministic probe; integrity check only, never the authority. */
probe: string;
renderer: DataGridRendererMode;
rowCount: number;
columnCount: number;
viewport: { top: number; left: number };
/** Index-based selection captured in O(selection size). */
selection?: DataGridViewSelectionSnapshot;
/** Set when a sparse selection had to be dropped; surfaces the restore-time notice. */
selectionDropped?: boolean;
}
export interface DataGridViewProbeInput {
columns: readonly string[];
columnTypes?: readonly (string | undefined)[];
rowCount: number;
firstRow?: readonly unknown[];
lastRow?: readonly unknown[];
/** Large-value cells are skipped so the probe never reads a multi-MB value. */
largeValueCells?: ReadonlyArray<{ row_index: number; column_index: number }>;
navigation?: {
whereInput?: string;
orderByInput?: string;
pageOffset?: number;
pageLimit?: number;
sortColumn?: string;
sortDirection?: string;
sortMode?: string;
};
}
const MAX_ENTRIES = 32;
/**
* Rollback switch (default on). Set `VITE_DBX_DISABLE_GRID_VIEW_SNAPSHOT=true`
* to skip capture and restore entirely, which restores the pre-feature
* behavior without a code change.
*/
export const DATA_GRID_VIEW_SNAPSHOT_RESTORE = import.meta.env?.VITE_DBX_DISABLE_GRID_VIEW_SNAPSHOT !== "true";
/** Per-snapshot serialized budget. */
export const MAX_SNAPSHOT_BYTES = 64 * 1024;
/** Whole-cache serialized budget. */
export const MAX_CACHE_BYTES = 2 * 1024 * 1024;
/** Hard cap on sparse cell-selection entries. */
export const MAX_SPARSE_CELL_ENTRIES = 4096;
const PROBE_COLUMNS = 16;
const PROBE_ROW_COLUMNS = 4;
const PROBE_VALUE_CHARS = 64;
const cache = new Map<string, DataGridViewSnapshot>();
/**
* Tabs whose snapshots must not be written again. Closing a tab unmounts its
* grid, whose teardown capture would otherwise re-create the entry the store
* just deleted. Mirrors `closingPendingSnapshotTabs` in `useDataGridEditor`.
*/
const closingTabs = new Set<string>();
/**
* Owners already told that an oversized sparse selection was dropped. Cleared
* only when the owner's snapshot is cleared or its generation changes, so
* repeated tab switching cannot re-notify.
*/
const overBudgetNotified = new Set<string>();
function ownerBelongsToTab(ownerKey: string, tabId: string): boolean {
return ownerKey === tabId || ownerKey.startsWith(`${tabId}-`);
}
function isClosingOwner(ownerKey: string): boolean {
for (const tabId of closingTabs) {
if (ownerBelongsToTab(ownerKey, tabId)) return true;
}
return false;
}
const byteEncoder = new TextEncoder();
/**
* Serialized size in bytes (UTF-8), so the budgets hold for non-ASCII column
* and cell content too. Only ever called on the small snapshot object, never
* on result data.
*/
function snapshotBytes(snapshot: DataGridViewSnapshot): number {
try {
return byteEncoder.encode(JSON.stringify(snapshot)).byteLength;
} catch {
return Number.MAX_SAFE_INTEGER;
}
}
/** True when the selection exceeds the sparse-cell budget. */
export function selectionExceedsBudget(selection: DataGridViewSelectionSnapshot | undefined): boolean {
return !!selection && (selection.kind === "cells" ? selection.cellKeys.length > MAX_SPARSE_CELL_ENTRIES : selection.kind === "rows" && !selection.contiguous && selection.rowIndexes.length > MAX_SPARSE_CELL_ENTRIES);
}
/** Trim an over-budget sparse selection, reporting what was dropped. */
export function clampDataGridViewSelection(selection: DataGridViewSelectionSnapshot): { selection: DataGridViewSelectionSnapshot; droppedSelection: boolean } {
if (selection.kind === "cells") {
if (selection.cellKeys.length <= MAX_SPARSE_CELL_ENTRIES) return { selection, droppedSelection: false };
return { selection: { kind: "cells", cellKeys: [], anchor: selection.anchor, focus: selection.focus }, droppedSelection: true };
}
if (selection.kind === "rows" && !selection.contiguous) {
if (selection.rowIndexes.length <= MAX_SPARSE_CELL_ENTRIES) return { selection, droppedSelection: false };
return { selection: { kind: "rows", rowIndexes: [], anchorRowIndex: selection.anchorRowIndex }, droppedSelection: true };
}
return { selection, droppedSelection: false };
}
/**
* Shrink a snapshot until it fits the per-snapshot budget: a sparse selection
* first, then the whole selection. The viewport is always retained.
*/
function degradeToBudget(snapshot: DataGridViewSnapshot): { snapshot: DataGridViewSnapshot; droppedSelection: boolean } {
if (snapshotBytes(snapshot) <= MAX_SNAPSHOT_BYTES) {
return { snapshot, droppedSelection: false };
}
if (snapshot.selection?.kind === "cells" && snapshot.selection.cellKeys.length) {
const withoutCells: DataGridViewSnapshot = { ...snapshot, selection: { ...snapshot.selection, cellKeys: [] }, selectionDropped: true };
if (snapshotBytes(withoutCells) <= MAX_SNAPSHOT_BYTES) {
return { snapshot: withoutCells, droppedSelection: true };
}
}
return { snapshot: { ...snapshot, selection: undefined, selectionDropped: true }, droppedSelection: !!snapshot.selection };
}
function totalCacheBytes(): number {
let total = 0;
for (const snapshot of cache.values()) total += snapshotBytes(snapshot);
return total;
}
function evictOldest(): boolean {
const oldest = cache.keys().next().value;
if (oldest === undefined) return false;
cache.delete(oldest);
return true;
}
/**
* Store a snapshot for `ownerKey`, degrading it to fit the budgets. Returns
* whether a selection had to be dropped, so the caller can surface the one-shot
* notice.
*/
export function saveDataGridViewSnapshot(snapshot: DataGridViewSnapshot): { droppedSelection: boolean } {
if (isClosingOwner(snapshot.ownerKey)) return { droppedSelection: false };
const { snapshot: fitted, droppedSelection } = degradeToBudget(snapshot);
cache.delete(snapshot.ownerKey);
cache.set(snapshot.ownerKey, fitted);
while (cache.size > MAX_ENTRIES) {
if (!evictOldest()) break;
}
while (cache.size > 1 && totalCacheBytes() > MAX_CACHE_BYTES) {
if (!evictOldest()) break;
}
return { droppedSelection };
}
/** Read a snapshot without consuming it, refreshing its LRU recency. */
export function peekDataGridViewSnapshot(ownerKey: string): DataGridViewSnapshot | undefined {
const snapshot = cache.get(ownerKey);
if (!snapshot) return undefined;
cache.delete(ownerKey);
cache.set(ownerKey, snapshot);
return snapshot;
}
/**
* Drop a replayed snapshot after a successful restore, keeping the over-budget
* notice marker so switching back and forth cannot re-notify.
*/
export function consumeDataGridViewSnapshot(ownerKey: string): void {
cache.delete(ownerKey);
}
/** Drop a single owner's snapshot and its notice marker. */
export function clearDataGridViewSnapshot(ownerKey: string): void {
cache.delete(ownerKey);
// Deleting while iterating a Set/Map is safe: iteration skips removed entries.
for (const key of overBudgetNotified) {
if (key.startsWith(`${ownerKey} `)) overBudgetNotified.delete(key);
}
}
/** Drop every snapshot belonging to a tab (owner keys are `${tabId}-...`). */
export function clearDataGridViewSnapshotsForTab(tabId: string): void {
for (const key of cache.keys()) {
if (ownerBelongsToTab(key, tabId)) cache.delete(key);
}
for (const key of overBudgetNotified) {
if (ownerBelongsToTab(key, tabId) || key.startsWith(`${tabId} `)) overBudgetNotified.delete(key);
}
}
/**
* Clear a tab's snapshots and block new writes for a short window, so the grid
* teardown capture cannot resurrect an entry after the tab is closed or its
* result cleared.
*/
export function beginClosingDataGridViewSnapshotsForTab(tabId: string): void {
closingTabs.add(tabId);
// Some test environments stub `window` without timers, so a bare
// `window.setTimeout` would throw while clearing a result.
const schedule = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout;
schedule(() => closingTabs.delete(tabId), 5000);
clearDataGridViewSnapshotsForTab(tabId);
}
/** Test seam: drop everything. */
export function resetDataGridViewSnapshots(): void {
cache.clear();
overBudgetNotified.clear();
closingTabs.clear();
}
export function dataGridViewSnapshotCacheSize(): number {
return cache.size;
}
export function dataGridViewSnapshotCacheBytes(): number {
return totalCacheBytes();
}
/**
* One-shot notice bookkeeping for a dropped selection. Returns true the first
* time this owner+generation combination reports a drop, and false on every
* later call until the snapshot is cleared or the generation changes.
*/
export function shouldNotifyOverBudgetSelection(ownerKey: string, viewGeneration: string): boolean {
const marker = `${ownerKey} ${viewGeneration}`;
if (overBudgetNotified.has(marker)) return false;
overBudgetNotified.add(marker);
return true;
}
function probeValue(value: unknown, skip: boolean): string {
if (skip) return "";
if (value === null) return " ";
const text = typeof value === "string" ? value : String(value);
return text.length > PROBE_VALUE_CHARS ? text.slice(0, PROBE_VALUE_CHARS) : text;
}
/**
* Deterministic bounded fingerprint of a result. Used only to detect cache
* corruption or wiring mistakes: it must never authorize a restore by itself.
*/
export function buildDataGridViewProbe(input: DataGridViewProbeInput): string {
const skip = new Set((input.largeValueCells ?? []).map((cell) => `${cell.row_index}:${cell.column_index}`));
const rowProbe = (row: readonly unknown[] | undefined, rowIndex: number): string => {
if (!row) return "";
const cells: string[] = [];
const limit = Math.min(PROBE_ROW_COLUMNS, row.length);
for (let columnIndex = 0; columnIndex < limit; columnIndex += 1) {
cells.push(probeValue(row[columnIndex], skip.has(`${rowIndex}:${columnIndex}`)));
}
return cells.join("");
};
const navigation = input.navigation;
return JSON.stringify({
columns: input.columns.slice(0, PROBE_COLUMNS),
columnTypes: input.columnTypes?.slice(0, PROBE_COLUMNS),
rowCount: input.rowCount,
first: rowProbe(input.firstRow, 0),
last: rowProbe(input.lastRow, Math.max(0, input.rowCount - 1)),
nav: navigation ? [navigation.whereInput ?? "", navigation.orderByInput ?? "", navigation.pageOffset ?? 0, navigation.pageLimit ?? 0, navigation.sortColumn ?? "", navigation.sortDirection ?? "", navigation.sortMode ?? ""] : undefined,
});
}
@@ -32,6 +32,12 @@ export interface TabResultSnapshot {
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
resultRuns?: QueryTab["resultRuns"];
activeResultRunId?: string;
/**
* Logical-result identity for the tab-switch view snapshot cache. Required at
* the tab level because a data tab has no result run to carry it; query tabs
* additionally carry it per run through `resultRuns`.
*/
resultViewGeneration?: string;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
queryWriteTargets?: QueryTab["queryWriteTargets"];
@@ -747,6 +753,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined,
resultRuns: stripResultRunSessionIds(tab.resultRuns),
activeResultRunId: tab.activeResultRunId,
resultViewGeneration: tab.resultViewGeneration,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),