feat(postgres): support concurrent index creation

This commit is contained in:
jearhe authored and GitHub committed 2026-08-17 16:31:44 +08:00
1 parent ebf412b144
commit aa6d60533c
38 files changed
+1917 -34

No files matched your search

@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
getTablePartitionStatus: vi.fn(),
toast: vi.fn(),
}));
@@ -218,6 +219,7 @@ vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCac
vi.mock("@/lib/backend/api", () => ({
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
@@ -333,6 +335,10 @@ beforeEach(() => {
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.invalidateObjectDdl.mockResolvedValue(undefined);
mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" });
// TableStructureEditor probes the partition status for PostgreSQL tables
// (PR #6361); a resolved non-partitioned result keeps metadata loads on the
// original facet expectations unchanged.
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
});
afterEach(() => {
@@ -25,7 +25,7 @@ import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHig
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
@@ -38,6 +38,7 @@ import { getSqliteDataTypeHelp } from "@/lib/table/sqliteDataTypeHelp";
import { getTableMetadataCapabilities, firstStructureMetadataTab, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visibleTableStructureRefreshScope, type TableStructureRefreshScope } from "@/lib/table/tableStructureMetadataLoading";
import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, sanitizeStructureIndexesForCapabilities, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities";
import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, normalizeUnsupportedConcurrentIndexes, type ConcurrentIndexAvailability } from "@/lib/table/concurrentIndexAvailability";
import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -207,6 +208,21 @@ const copySourceColumnsLoading = ref(false);
const copySourceError = ref("");
let copySourceColumnsRequestId = 0;
const indexes = ref<EditableStructureIndex[]>([]);
/** PostgreSQL partitioned parent (`relkind = 'p'`): `CREATE INDEX CONCURRENTLY`
* is rejected by the server on such tables, so the option is disabled here and
* the SQL builder refuses any concurrent request on it (fail closed). */
const isPartitionedParent = ref(false);
/** Whether the last partition-status probe succeeded. When it cannot be
* verified (probe failed), Concurrent is disabled — we must not assume a
* non-partitioned table we could not check. */
const partitionStatusKnown = ref(true);
/** Set when a `concurrently: true` index draft had to be normalized away
* because Concurrent availability became unknown/unsupported (partition probe
* failure, partitioned parent, capability loss). While set, no SQL is
* generated and Save stays blocked until the user re-verifies (the next
* successful probe clears it) — a cleared flag must never silently degrade
* into a blocking `CREATE INDEX`. */
const concurrentAvailabilityInvalidated = ref(false);
const pendingStatements = ref<string[]>([]);
const warnings = ref<string[]>([]);
const sqliteSchemaRevision = ref<string>();
@@ -254,7 +270,8 @@ function indexChanged(index: EditableStructureIndex): boolean {
!sameText(index.filter, original.filter) ||
!sameStructureIndexType(index.indexType, original.index_type) ||
!sameList(index.includedColumns, original.included_columns) ||
!sameText(index.comment, original.comment)
!sameText(index.comment, original.comment) ||
!!index.concurrently
);
}
@@ -303,7 +320,7 @@ const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths
const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths";
const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed";
const STRUCTURE_COLUMN_WIDTH_COUNT = 12;
const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 8;
const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 9;
const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]);
const structureDensityMetrics: Record<
StructureEditorDensity,
@@ -328,7 +345,7 @@ const structureDensityMetrics: Record<
> = {
compact: {
columns: [28, 168, 136, 82, 60, 52, 108, 220, 80, 120, 144, 108],
indexes: [120, 180, 60, 88, 124, 144, 120, 70],
indexes: [120, 180, 60, 88, 124, 144, 120, 84, 70],
minColumnWidth: 24,
minLengthColumnWidth: 140,
minIndexColumnWidth: 48,
@@ -346,7 +363,7 @@ const structureDensityMetrics: Record<
},
standard: {
columns: [32, 200, 160, 104, 72, 64, 128, 260, 90, 140, 160, 136],
indexes: [148, 224, 72, 108, 148, 180, 148, 84],
indexes: [148, 224, 72, 108, 148, 180, 148, 100, 84],
minColumnWidth: 28,
minLengthColumnWidth: 156,
minIndexColumnWidth: 60,
@@ -364,7 +381,7 @@ const structureDensityMetrics: Record<
},
comfortable: {
columns: [36, 232, 188, 116, 84, 76, 152, 300, 100, 160, 188, 148],
indexes: [176, 260, 84, 124, 176, 216, 176, 104],
indexes: [176, 260, 84, 124, 176, 216, 176, 116, 104],
minColumnWidth: 32,
minLengthColumnWidth: 176,
minIndexColumnWidth: 64,
@@ -825,7 +842,17 @@ const colLabels = computed(() => {
labels.push({ key: "actions", label: t("structureEditor.actions"), widthIndex: 11 });
return labels;
});
const indexColLabels = computed(() => [t("structureEditor.indexName"), t("structureEditor.indexColumns"), t("structureEditor.unique"), t("structureEditor.indexType"), t("structureEditor.includedColumns"), t("structureEditor.filter"), t("structureEditor.comment"), t("structureEditor.actions")]);
const indexColLabels = computed(() => [
t("structureEditor.indexName"),
t("structureEditor.indexColumns"),
t("structureEditor.unique"),
t("structureEditor.indexType"),
t("structureEditor.includedColumns"),
t("structureEditor.filter"),
t("structureEditor.comment"),
t("structureEditor.concurrent"),
t("structureEditor.actions"),
]);
const filteredColumnRowIds = computed(() => {
const query = columnSearchText.value.trim().toLowerCase();
if (!query) return new Set<string>();
@@ -1089,7 +1116,15 @@ function restoreDraft(draft: TableStructureEditorDraft) {
tableComment.value = draft.tableComment || "";
originalTableComment.value = draft.originalTableComment || "";
columns.value = cloneDraftValue(draft.columns || []);
indexes.value = cloneDraftValue(draft.indexes || []);
// Existing-index edits never support Concurrent (the checkbox is disabled and
// the core builder rejects the request), so a stale `concurrently: true`
// saved in a restored draft must not be submitted or deadlock the save.
indexes.value = cloneDraftValue(draft.indexes || []).map((index) => (index.original ? { ...index, concurrently: false } : index));
// Re-run the availability normalization against the current inputs (e.g. a
// re-activated editor may already carry an unknown/unsupported partition
// status): restored new-index Concurrent choices that became illegal are
// normalized away with the same fail-closed invalidation as a probe failure.
normalizeConcurrentIndexDraftsForCurrentAvailability();
foreignKeys.value = cloneDraftValue(draft.foreignKeys || []);
constraints.value = cloneDraftValue(draft.constraints || []);
// Drafts created before constraint loading existed have no saved facet.
@@ -1306,11 +1341,23 @@ function structureChangeOptions(): BuildTableStructureChangeSqlOptions {
triggers: triggers.value,
tableComment: tableComment.value,
originalTableComment: isCreateMode.value ? undefined : originalTableComment.value,
partitioned: isPartitionedParent.value,
};
}
async function refreshSqlPreview() {
const requestId = ++sqlPreviewRequestId;
if (concurrentAvailabilityInvalidated.value) {
// Availability gap in effect: never regenerate SQL here — doing so would
// turn a lost Concurrent request into a silent blocking CREATE INDEX. Keep
// the explicit error visible until a later probe re-verifies the table.
pendingStatements.value = [];
warnings.value = [t("structureEditor.concurrentUnavailableBlocksSave")];
sqliteSchemaRevision.value = undefined;
sqlPreviewLoading.value = false;
sqlPreviewPending.value = false;
return;
}
if (!hasPendingStructureChanges()) {
pendingStatements.value = [];
warnings.value = [];
@@ -1319,6 +1366,18 @@ async function refreshSqlPreview() {
sqlPreviewPending.value = false;
return;
}
// Layer B: a stale `concurrently: true` whose availability is no longer
// enabled must never reach the SQL builder, even if normalization was
// skipped (race, restored draft, non-UI caller).
const blockingConcurrentWarning = concurrentIndexBlockingWarning();
if (blockingConcurrentWarning) {
pendingStatements.value = [];
warnings.value = [blockingConcurrentWarning];
sqliteSchemaRevision.value = undefined;
sqlPreviewLoading.value = false;
sqlPreviewPending.value = false;
return;
}
sqlPreviewLoading.value = true;
const options = structureChangeOptions();
try {
@@ -1350,6 +1409,7 @@ const canApply = computed(
!sqlPreviewPending.value &&
pendingStatements.value.length > 0 &&
warnings.value.length === 0 &&
!concurrentAvailabilityInvalidated.value &&
(!hasSqliteTypeChange.value || !!sqliteSchemaRevision.value) &&
!!props.connectionId &&
(isCreateMode.value ? !!newTableName.value.trim() : !!props.tableName),
@@ -1371,6 +1431,9 @@ function resetState() {
constraintsLoading.value = false;
triggersLoading.value = false;
errorMessage.value = "";
isPartitionedParent.value = false;
partitionStatusKnown.value = true;
concurrentAvailabilityInvalidated.value = false;
columns.value = [];
indexes.value = [];
pendingStatements.value = [];
@@ -1469,6 +1532,18 @@ async function loadStructure(
const metadataRequest = ddlRequest();
const forceMetadata = options.forceMetadata === true;
const partitionStatusPromise =
databaseType.value === "postgres" && !isCreateMode.value
? api
.getTablePartitionStatus(connectionId, database, schema, tableName)
// No reactive mutation inside the catch: a stale request must not
// overwrite a newer probe's result (the structureLoadRequestId
// guard below decides). Fail closed — without a verified partition
// status we cannot rule out a partitioned parent, so Concurrent is
// treated as unavailable until a later reload re-runs the probe.
.then((status) => ({ known: true, status }))
.catch(() => ({ known: false, status: { isPartitionedParent: false, isPartition: false } }))
: Promise.resolve({ known: true, status: { isPartitionedParent: false, isPartition: false } });
const columnsPromise = scope.columns ? loadObjectMetadataFacet(metadataRequest, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value) : Promise.resolve(undefined);
const indexesPromise = scope.indexes
? tableMetadataCapabilities.value.indexes
@@ -1520,6 +1595,20 @@ async function loadStructure(
tableComment.value = nextTableComment;
loadedMetadataFacets.add("comment");
}
const partitionStatus = await partitionStatusPromise;
if (requestId === structureLoadRequestId) {
partitionStatusKnown.value = partitionStatus.known;
isPartitionedParent.value = partitionStatus.status.isPartitionedParent;
// Availability inputs changed: fail closed while the status is unknown,
// but preserve the user's Concurrent intent so a later successful probe
// can regenerate the same SQL. Definitive unsupported states still clear
// the flag. A partitioned parent or unknown status keeps Save blocked.
normalizeConcurrentIndexDraftsForCurrentAvailability();
if (partitionStatus.known && !partitionStatus.status.isPartitionedParent && structureCapabilities.value.indexConcurrent && structureCapabilities.value.createIndex && concurrentAvailabilityInvalidated.value) {
concurrentAvailabilityInvalidated.value = false;
scheduleSqlPreviewRefresh();
}
}
const applySecondaryMetadata = async () => {
const [nextIndexes, nextForeignKeys, nextConstraints, nextTriggers] = await Promise.all([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]);
if (requestId !== structureLoadRequestId) return;
@@ -2345,6 +2434,7 @@ function addIndex() {
indexType: "",
includedColumns: [],
comment: "",
concurrently: false,
markedForDrop: false,
});
void nextTick(() => {
@@ -2427,6 +2517,89 @@ function canEditIndexDraft(index: EditableStructureIndex): boolean {
return structureCapabilities.value.rebuildIndex && structureCapabilities.value.createIndex && structureCapabilities.value.dropIndex;
}
/**
* Whether the Concurrent checkbox is actionable for this index draft.
*
* Plan A scope guard (PR #6361 review): concurrent builds apply only to newly
* created indexes on non-partitioned tables. Editing an existing index would
* require a `DROP INDEX CONCURRENTLY` + `CREATE INDEX CONCURRENTLY` replace
* flow (not implemented yet), PostgreSQL rejects `CREATE INDEX CONCURRENTLY`
* on partitioned parents, and an unverifiable partition status fails closed —
* all of those disable the checkbox here. The core SQL builder enforces the
* same scope as a hard error, so this is only the first layer.
*/
function concurrentIndexAvailability(index: EditableStructureIndex): ConcurrentIndexAvailability {
if (indexesLoading.value) return { enabled: false, reason: "unknown" };
return concurrentIndexAvailabilityState(index);
}
/** Same decision as [`concurrentIndexAvailability`], independent of the
* indexes-loading flag — state-normalization runs while metadata may still be
* in flight, and must decide on the availability inputs alone. */
function concurrentIndexAvailabilityState(index: EditableStructureIndex): ConcurrentIndexAvailability {
return getConcurrentIndexAvailability({
hasOriginal: !!index.original,
isPrimary: index.isPrimary,
markedForDrop: index.markedForDrop,
isPartitionedParent: isPartitionedParent.value,
partitionStatusKnown: partitionStatusKnown.value,
supportsIndexConcurrent: structureCapabilities.value.indexConcurrent,
supportsCreateIndex: structureCapabilities.value.createIndex,
});
}
/**
* Layer A — invalidate `concurrently: true` drafts whenever Concurrent becomes
* unavailable. Transiently unknown partition status preserves the flag so a
* later successful probe can recover the user's intent; definitive unsupported
* states clear it. In both cases, empty pending SQL and keep Save blocked until
* availability is verified again.
*/
function normalizeConcurrentIndexDraftsForCurrentAvailability(): boolean {
// Engines that cannot express Concurrent (non-PostgreSQL dialects) ignore a
// stale flag in the core builder and render no checkbox; blocking the save
// there would only trap a draft carried over from a PostgreSQL session.
if (!structureCapabilities.value.indexConcurrent) return false;
const { indexes: normalized, invalidatedIds } = normalizeUnsupportedConcurrentIndexes(indexes.value, concurrentIndexAvailabilityState);
if (invalidatedIds.length === 0) return false;
indexes.value = normalized;
concurrentAvailabilityInvalidated.value = true;
pendingStatements.value = [];
warnings.value = [t("structureEditor.concurrentUnavailableBlocksSave")];
sqlPreviewLoading.value = false;
sqlPreviewPending.value = false;
errorMessage.value = t("structureEditor.concurrentUnavailableBlocksSave");
return true;
}
/** Layer B — a `concurrently: true` draft whose availability is no longer
* enabled must never reach the SQL builder or the execute path. Returns the
* blocking message, or null when the request stays legal. */
function concurrentIndexBlockingWarning(): string | null {
// Non-concurrent-capable engines cannot express the request in SQL; the core
// builder ignores the flag, so there is no stale-concurrent hazard to block.
if (!structureCapabilities.value.indexConcurrent) return null;
const stale = indexes.value.find((index) => index.concurrently && !concurrentIndexAvailability(index).enabled);
return stale ? t("structureEditor.concurrentUnavailableBlocksSave") : null;
}
function canEditIndexConcurrent(index: EditableStructureIndex): boolean {
return concurrentIndexAvailability(index).enabled;
}
function concurrentIndexCellTitle(index: EditableStructureIndex): string {
switch (concurrentIndexAvailability(index).reason) {
case "existing":
return t("structureEditor.concurrentExistingIndexTooltip");
case "partitioned":
return t("structureEditor.concurrentPartitionedTooltip");
case "unknown":
return t("structureEditor.concurrentUnavailableTooltip");
default:
return t("structureEditor.concurrentTooltip");
}
}
function canEditIndexFilter(index: EditableStructureIndex): boolean {
return canEditIndexDraft(index) && structureCapabilities.value.indexFilter;
}
@@ -2571,6 +2744,14 @@ function toggleSqlPreviewCollapsed() {
async function applyChanges() {
if (!canApply.value || !props.connectionId || !props.database) return false;
// Layer B runtime guard: a stale `concurrently: true` whose availability is
// no longer enabled must never be executed — reject the save with an
// explicit error even if normalization was skipped (race / non-UI caller).
const blockingConcurrentWarning = concurrentIndexBlockingWarning() ?? (concurrentAvailabilityInvalidated.value ? t("structureEditor.concurrentUnavailableBlocksSave") : null);
if (blockingConcurrentWarning) {
errorMessage.value = blockingConcurrentWarning;
return false;
}
const sql = previewSqlText.value;
const connection = store.getConfig(props.connectionId);
const productionContext = productionContextForDatabase(connection, props.database);
@@ -2587,6 +2768,27 @@ async function applyChanges() {
saving.value = true;
errorMessage.value = "";
const refreshScope = captureStructureRefreshScope();
// Plan A guard: concurrent builds only run with a long-enough query timeout
// (a cancelled build leaves an INVALID index behind), and are blocked
// up-front when a same-name INVALID index already exists.
const hasConcurrentIndexBuild = pendingStatements.value.some((statement) => statement.includes("CONCURRENTLY"));
if (hasConcurrentIndexBuild && !isCreateMode.value && databaseType.value === "postgres" && props.tableName) {
const concurrentIndexNames = concurrentIndexNamesInStatements(pendingStatements.value);
if (concurrentIndexNames.length > 0) {
try {
const invalidIndexes = await api.listInvalidIndexes(props.connectionId, props.database, metadataSchema.value, props.tableName);
const blocked = concurrentIndexNames.filter((name) => invalidIndexes.includes(name));
if (blocked.length > 0) {
errorMessage.value = t("structureEditor.invalidIndexBlocksSave", { indexNames: blocked.join(", ") });
saving.value = false;
return false;
}
} catch {
// Metadata probe failure must not block the save; the failure-time
// hint below still surfaces leftovers if the build errors out.
}
}
}
const characterLengthUnitsAfterSave = new Map<string, string>();
if (supportsCharacterLengthUnits.value) {
for (const column of columns.value) {
@@ -2596,10 +2798,15 @@ async function applyChanges() {
}
}
const startedAt = Date.now();
// Concurrent batches get at least the dedicated 30-minute floor while
// preserving an unlimited setting (0) and any larger configured timeout;
// non-concurrent batches keep the configured timeout unchanged.
const configuredTimeoutSecs = queryTimeoutSecsForConnection(connection, settingsStore.editorSettings.globalQueryTimeoutSecs);
const executionTimeoutSecs = queryTimeoutSecsForConcurrentIndex(configuredTimeoutSecs, hasConcurrentIndexBuild);
try {
const result = hasSqliteTypeChange.value
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, queryTimeoutSecsForConnection(connection, settingsStore.editorSettings.globalQueryTimeoutSecs));
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs);
await recordStructureHistory(sql, startedAt, true, result);
if (!isCreateMode.value && props.tableName) {
invalidateTableMetadataCache({ connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName });
@@ -2629,7 +2836,11 @@ async function applyChanges() {
}
return true;
} catch (e: any) {
errorMessage.value = e?.message || String(e);
const rawMessage = e?.message || String(e);
// A cancelled/errored concurrent build leaves a same-name INVALID index
// behind; surface that so retries are not silently doomed.
const invalidIndexHint = hasConcurrentIndexBuild && /already exists/i.test(rawMessage) ? `\n\n${t("structureEditor.invalidIndexRetryHint")}` : "";
errorMessage.value = `${rawMessage}${invalidIndexHint}`;
await recordStructureHistory(sql, startedAt, false, undefined, errorMessage.value);
return false;
} finally {
@@ -2816,6 +3027,14 @@ watch(tableMetadataCapabilities, (capabilities) => {
if (!localIsStructureMetadataTabSupported(activeTab.value, capabilities)) activeTab.value = localFirstStructureMetadataTab(capabilities);
});
watch(structureCapabilities, () => {
// Capability loss (e.g. PostgreSQL < 11 without concurrent index support)
// invalidates any selected Concurrent flag the same way a probe failure
// does; the normalization is idempotent and no-ops while availability stays
// enabled.
normalizeConcurrentIndexDraftsForCurrentAvailability();
});
watch([() => props.initialTab, () => props.initialTabRequestId, () => props.initialTarget], () => {
if (props.initialTab) applyInitialStructureTab();
applyInitialStructureTarget();
@@ -3523,6 +3742,13 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<td :class="structureCellClass">
<Input v-model="index.comment" :class="[structureControlClass, indexSearchFieldClass(index, index.comment)]" :disabled="!canEditIndexComment(index)" />
</td>
<td :class="structureCellClass">
<label v-if="structureCapabilities.indexConcurrent" class="flex items-center gap-1.5" :title="concurrentIndexCellTitle(index)">
<input v-model="index.concurrently" type="checkbox" :class="structureCheckboxClass" :disabled="!canEditIndexConcurrent(index)" />
<span>{{ index.concurrently ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
</label>
<span v-else class="text-[length:var(--structure-font-size)] text-muted-foreground">—</span>
</td>
<td :class="structureLastCellClass">
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>
<Button v-else-if="index.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="!canDropIndex(index)" @click="toggleDropIndex(index)">
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, normalizeUnsupportedConcurrentIndexes } from "@/lib/table/concurrentIndexAvailability";
describe("getConcurrentIndexAvailability (Plan A scope guards)", () => {
const base = {
hasOriginal: false,
isPrimary: false,
markedForDrop: false,
isPartitionedParent: false,
partitionStatusKnown: true,
supportsIndexConcurrent: true,
supportsCreateIndex: true,
};
it("enables Concurrent for a new index on a normal non-partitioned PostgreSQL table", () => {
expect(getConcurrentIndexAvailability(base)).toEqual({ enabled: true });
});
it("disables Concurrent when editing an existing index", () => {
expect(getConcurrentIndexAvailability({ ...base, hasOriginal: true })).toEqual({
enabled: false,
reason: "existing",
});
});
it("disables Concurrent on a partitioned parent table", () => {
expect(getConcurrentIndexAvailability({ ...base, isPartitionedParent: true })).toEqual({
enabled: false,
reason: "partitioned",
});
});
it("fails closed when the partition status could not be verified", () => {
expect(getConcurrentIndexAvailability({ ...base, partitionStatusKnown: false })).toEqual({
enabled: false,
reason: "unknown",
});
});
it("disables Concurrent for primary indexes and drop-marked rows", () => {
expect(getConcurrentIndexAvailability({ ...base, isPrimary: true })).toEqual({
enabled: false,
reason: "primary",
});
expect(getConcurrentIndexAvailability({ ...base, markedForDrop: true })).toEqual({
enabled: false,
reason: "markedForDrop",
});
});
it("disables Concurrent when the engine does not advertise the capability (non-PostgreSQL)", () => {
expect(getConcurrentIndexAvailability({ ...base, supportsIndexConcurrent: false })).toEqual({
enabled: false,
reason: "unsupported",
});
expect(getConcurrentIndexAvailability({ ...base, supportsCreateIndex: false })).toEqual({
enabled: false,
reason: "unsupported",
});
});
});
describe("concurrentIndexNamesInStatements", () => {
it("extracts unquoted names from CREATE [UNIQUE] INDEX CONCURRENTLY statements", () => {
expect(
concurrentIndexNamesInStatements([
'CREATE INDEX CONCURRENTLY "idx_users_email" ON "public"."users" ("email");',
'CREATE UNIQUE INDEX CONCURRENTLY "uniq_users_name" ON "public"."users" ("name");',
'CREATE INDEX "plain_idx" ON "public"."users" ("id");',
'CREATE INDEX CONCURRENTLY "orders"."idx_orders_id" ON "orders"."orders" ("id");',
]),
).toEqual(["idx_users_email", "uniq_users_name", "idx_orders_id"]);
});
it("returns an empty list when no concurrent statements are present", () => {
expect(concurrentIndexNamesInStatements([])).toEqual([]);
expect(concurrentIndexNamesInStatements(['CREATE INDEX "idx" ON "public"."t" ("c");'])).toEqual([]);
});
});
describe("normalizeUnsupportedConcurrentIndexes", () => {
const enabled = () => ({ enabled: true });
const unavailable = (reason: string) => ({ enabled: false, reason });
it("keeps Concurrent on drafts whose availability is enabled", () => {
const indexes = [
{ id: "a", concurrently: true },
{ id: "b", concurrently: false },
];
expect(normalizeUnsupportedConcurrentIndexes(indexes, enabled)).toEqual({ indexes, invalidatedIds: [] });
});
it("preserves transiently unavailable intent while clearing definitively unsupported flags", () => {
const indexes = [
{ id: "a", concurrently: true },
{ id: "b", concurrently: true },
{ id: "c", concurrently: false },
];
const result = normalizeUnsupportedConcurrentIndexes(indexes, (index) => (index.id === "a" ? unavailable("existing") : unavailable("unknown")));
expect(result.indexes).toEqual([
{ id: "a", concurrently: false },
{ id: "b", concurrently: true },
{ id: "c", concurrently: false },
]);
expect(result.invalidatedIds).toEqual(["a", "b"]);
});
it("clears Concurrent when the table is confirmed partitioned", () => {
const indexes = [{ id: "a", concurrently: true }];
const result = normalizeUnsupportedConcurrentIndexes(indexes, () => unavailable("partitioned"));
expect(result.indexes).toEqual([{ id: "a", concurrently: false }]);
expect(result.invalidatedIds).toEqual(["a"]);
});
});
@@ -0,0 +1,378 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
connection: {
id: "structure-concurrent-transition",
name: "PostgreSQL",
db_type: "postgres",
driver_label: "PostgreSQL",
},
ensureConnected: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
getTablePartitionStatus: vi.fn(),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") });
return {
AlertTriangle: Icon,
Check: Icon,
ChevronDown: Icon,
ChevronUp: Icon,
Copy: Icon,
Database: Icon,
Info: Icon,
KeyRound: Icon,
ListChevronsUpDown: Icon,
Loader2: Icon,
Maximize2: Icon,
Plus: Icon,
RefreshCw: Icon,
Save: Icon,
Search: Icon,
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
name: "Button",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
}),
};
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
Input: defineComponent({
name: "Input",
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("input", {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
}),
}),
};
});
vi.mock("@/components/ui/badge", async () => {
const { defineComponent, h } = await import("vue");
return {
Badge: defineComponent({
name: "Badge",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("span", attrs, slots.default?.()),
}),
};
});
// Tabs mock that wires TabsTrigger clicks through `v-model` (the real tabs
// component does this internally via context; the primaryKey spec mock renders
// every tab pane unconditionally, which we keep so draft rows stay queryable
// regardless of the active tab).
vi.mock("@/components/ui/tabs", async () => {
const { defineComponent, h, inject, provide } = await import("vue");
const TabsKey = Symbol("tabs:model");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Tabs = defineComponent({
name: "MockTabs",
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup: (_props, { attrs, slots, emit }) => {
provide(TabsKey, (value: string) => emit("update:modelValue", value));
return () => h("div", attrs, slots.default?.());
},
});
const TabsTrigger = defineComponent({
name: "MockTabsTrigger",
inheritAttrs: false,
props: { value: { type: String, required: true } },
setup: (props, { attrs, slots }) => {
const select = inject<(value: string) => void>(TabsKey, () => {});
return () => h("button", { ...attrs, type: "button", onClick: () => select(props.value) }, slots.default?.());
},
});
return { Tabs, TabsContent: Div, TabsList: Div, TabsTrigger };
});
vi.mock("@/components/ui/dropdown-menu", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Button = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
});
return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div };
});
vi.mock("@/components/ui/popover", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div };
});
vi.mock("@/components/ui/tooltip", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div };
});
vi.mock("@/components/ui/searchable-select", async () => {
const { defineComponent, h } = await import("vue");
return {
SearchableSelect: defineComponent({
name: "SearchableSelect",
inheritAttrs: false,
props: {
modelValue: { type: String, default: "" },
options: { type: Array, default: () => [] },
allowCustom: { type: Boolean, default: false },
},
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("button", {
...attrs,
type: "button",
"data-searchable-select": "true",
"data-model-value": props.modelValue,
"data-options": JSON.stringify(props.options),
"data-allow-custom": String(props.allowCustom),
onClick: () => emit("update:modelValue", "custom_domain"),
}),
}),
};
});
vi.mock("@/components/ui/select", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div };
});
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: mocks.ensureConnected,
getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined),
}),
}));
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) }));
vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [] },
updateEditorSettings: mocks.updateEditorSettings,
}),
}));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) }));
vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
const mountedApps: App[] = [];
async function mountIndexesEditor() {
mocks.connection.db_type = "postgres";
mocks.connection.name = "postgres";
mocks.connection.driver_label = "postgres";
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.listDataTypes.mockResolvedValue([]);
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({
value: facet === "comment" ? "" : [],
cacheStatus: "remote",
}));
// The SQL builder mirrors the Concurrent checkbox onto the generated
// statement so the enabled phase of the regression is visibly concurrent.
mocks.buildTableStructureChangeSql.mockImplementation((options: { indexes?: Array<{ name: string; concurrently?: boolean }> }) => {
const newIndex = options.indexes?.[0];
const statements = newIndex ? [`${newIndex.concurrently ? "CREATE INDEX CONCURRENTLY" : "CREATE INDEX"} "${newIndex.name || "idx_users_email"}" ON "public"."users" ("email");`] : [];
return Promise.resolve({ statements, warnings: [] });
});
const root = document.createElement("div");
document.body.append(root);
const app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
schema: "public",
tableName: "users",
initialTab: "indexes",
});
mountedApps.push(app);
app.mount(root);
await nextTick();
await Promise.resolve();
await nextTick();
return root;
}
function buttonWithText(root: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Missing ${text} button`);
return button as HTMLButtonElement;
}
/** The Concurrent checkbox inside the given index row (identified by the
* tooltip the cell label carries: concurrentTooltip / ...ExistingIndex /
* ...Partitioned / ...Unavailable). */
function concurrentCheckboxInRow(row: HTMLElement): HTMLInputElement {
const input = Array.from(row.querySelectorAll<HTMLInputElement>('label[title*="concurrent"] input[type="checkbox"]'))[0];
if (!input) throw new Error("Missing Concurrent checkbox in index row");
return input;
}
beforeEach(() => {
vi.clearAllMocks();
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
});
afterEach(() => {
vi.useRealTimers();
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("TableStructureEditor concurrent availability transition", () => {
it("preserves Concurrent intent across a failed partition probe and restores its SQL after recovery", async () => {
const root = await mountIndexesEditor();
// Enabled phase: new index can select Concurrent and the preview builds
// a CREATE INDEX CONCURRENTLY statement.
await vi.waitFor(() => expect(buttonWithText(root, "structureEditor.addIndex").disabled).toBe(false), { timeout: 3000 });
buttonWithText(root, "structureEditor.addIndex").click();
await nextTick();
await vi.waitFor(
() => {
const row = root.querySelector<HTMLElement>('[data-new-index-row="true"]');
expect(row).not.toBeNull();
},
{ timeout: 3000 },
);
const row = root.querySelector<HTMLElement>('[data-new-index-row="true"]')!;
const concurrent = concurrentCheckboxInRow(row);
expect(concurrent.disabled).toBe(false);
expect(concurrent.checked).toBe(false);
concurrent.checked = true;
concurrent.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql.mock.calls.at(-1)?.[0]?.indexes?.[0]?.concurrently).toBe(true), { timeout: 3000 });
await vi.waitFor(() => expect(root.textContent).toContain('CREATE INDEX CONCURRENTLY "idx_users_email" ON "public"."users" ("email");'), { timeout: 3000 });
expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false);
const callsAtEnabledPhase = mocks.buildTableStructureChangeSql.mock.calls.length;
// Transition: the very next partition-status probe fails. A reload that
// re-runs the probe (here: switching to a tab with an unloaded metadata
// facet) must keep the draft index but treat Concurrent as unavailable.
mocks.getTablePartitionStatus.mockRejectedValue(new Error("partition probe unavailable"));
await vi.waitFor(() => expect(buttonWithText(root, "structureEditor.triggers")).toBeTruthy(), { timeout: 3000 });
buttonWithText(root, "structureEditor.triggers").click();
await vi.waitFor(() => expect(mocks.getTablePartitionStatus).toHaveBeenCalledTimes(2), { timeout: 3000 });
// Fail closed while preserving the user's transiently unavailable intent:
// no SQL reaches the builder, the checkbox is disabled, and Save stays blocked.
await vi.waitFor(() => expect(root.textContent).toContain("structureEditor.concurrentUnavailableBlocksSave"), { timeout: 3000 });
const unavailableRow = root.querySelector<HTMLElement>('[data-new-index-row="true"]');
expect(unavailableRow).not.toBeNull();
const unavailableConcurrent = concurrentCheckboxInRow(unavailableRow!);
expect(unavailableConcurrent.checked).toBe(true);
expect(unavailableConcurrent.disabled).toBe(true);
// Neither the stale concurrent request nor a silent blocking downgrade may
// reach the SQL builder or the preview after the transition.
expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(true);
await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql.mock.calls.length).toBe(callsAtEnabledPhase), { timeout: 3000 });
expect(root.textContent).not.toContain("CREATE INDEX CONCURRENTLY");
expect(root.textContent).not.toContain('CREATE INDEX "idx_users_email"');
// Recovery: a later successful probe must lift the blocker and regenerate
// the preview with the original Concurrent intent, never a blocking CREATE.
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
buttonWithText(root, "structureEditor.refresh").click();
await vi.waitFor(() => expect(mocks.getTablePartitionStatus).toHaveBeenCalledTimes(3), { timeout: 3000 });
await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql.mock.calls.length).toBeGreaterThan(callsAtEnabledPhase), { timeout: 3000 });
const recoveredRow = root.querySelector<HTMLElement>('[data-new-index-row="true"]');
expect(recoveredRow).not.toBeNull();
const recoveredConcurrent = concurrentCheckboxInRow(recoveredRow!);
expect(recoveredConcurrent.checked).toBe(true);
expect(recoveredConcurrent.disabled).toBe(false);
expect(mocks.buildTableStructureChangeSql.mock.calls.at(-1)?.[0]?.indexes?.[0]?.concurrently).toBe(true);
await vi.waitFor(() => expect(root.textContent).toContain('CREATE INDEX CONCURRENTLY "idx_users_email" ON "public"."users" ("email");'), { timeout: 3000 });
expect(root.textContent).not.toContain('CREATE INDEX "idx_users_email"');
expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false);
});
});