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);
});
});
+9
View File
@@ -3622,6 +3622,15 @@ export default {
includedColumns: "Included",
includedColumnsPlaceholder: "Select columns...",
filter: "Filter",
concurrent: "Concurrent",
concurrentTooltip: "Build the index concurrently to avoid blocking writes. This usually takes longer and cannot run inside a transaction block.",
concurrentExistingIndexTooltip: "Available only for new indexes. Rebuilding an existing index concurrently (DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY) is not supported yet.",
concurrentPartitionedTooltip: "Not available on partitioned parent tables. PostgreSQL rejects concurrent index creation on partitioned parents; build child indexes concurrently and attach them instead.",
invalidIndexBlocksSave: 'Save blocked: an INVALID index (left behind by a cancelled concurrent build) already exists with the same name: {indexNames}. Drop it first (e.g. DROP INDEX "schema"."name"), then retry.',
concurrentUnavailableTooltip: "Unavailable while the table\u2019s partition status cannot be verified. Concurrent creation is only offered on non-partitioned tables.",
concurrentUnavailableBlocksSave:
"Concurrent index creation is no longer available (the table's partition status could not be verified). The selected Concurrent option was cleared and this change is blocked until the table is re-verified. Refresh to retry, or remove the index to continue with other changes.",
invalidIndexRetryHint: "This may be caused by a same-name INVALID index left behind by a previously cancelled concurrent build. Drop that index first, then retry.",
foreignKeyName: "Foreign key",
referencedSchema: "Ref schema",
referencedTable: "Ref table",
+9
View File
@@ -3458,6 +3458,15 @@ export default withEnglishFallback({
includedColumns: "Incluidas",
includedColumnsPlaceholder: "Seleccionar columnas...",
filter: "Filtro",
concurrent: "Concurrente",
concurrentTooltip: "Crea el índice de forma concurrente para evitar bloquear escrituras. Suele tardar más y no puede ejecutarse dentro de un bloque de transacción.",
concurrentExistingIndexTooltip: "Solo disponible para índices nuevos. Aún no se admite reconstruir un índice existente de forma concurrente (DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY).",
concurrentPartitionedTooltip: "No disponible en tablas padre particionadas. PostgreSQL rechaza la creación concurrente de índices en tablas padre particionadas; cree los índices hijos de forma concurrente y adjúntelos.",
invalidIndexBlocksSave: 'Guardado bloqueado: ya existe un índice INVALID con el mismo nombre (residuo de una construcción concurrente cancelada): {indexNames}. Elimínelo primero (p. ej. DROP INDEX "schema"."name") y vuelva a intentarlo.',
concurrentUnavailableTooltip: "No disponible mientras no se pueda verificar el estado de partición de la tabla. La creación concurrente solo se ofrece en tablas no particionadas.",
concurrentUnavailableBlocksSave:
"La creación concurrente de índices ya no está disponible (no se pudo verificar el estado de partición de la tabla). Se canceló la opción Concurrente seleccionada y este cambio está bloqueado hasta que la tabla se vuelva a verificar. Actualice para reintentar o elimine el índice para continuar con otros cambios.",
invalidIndexRetryHint: "Esto puede deberse a un índice INVALID con el mismo nombre que dejó una construcción concurrente cancelada anteriormente. Elimine ese índice primero y vuelva a intentarlo.",
foreignKeyName: "Clave foránea",
referencedSchema: "Esquema ref",
referencedTable: "Tabla ref",
+9
View File
@@ -3456,6 +3456,15 @@ export default withEnglishFallback({
includedColumns: "Incluse",
includedColumnsPlaceholder: "Seleziona colonne...",
filter: "Filtro",
concurrent: "Concorrente",
concurrentTooltip: "Crea l'indice in modo concorrente per evitare di bloccare le scritture. Di solito richiede più tempo e non può essere eseguito all'interno di un blocco di transazione.",
concurrentExistingIndexTooltip: "Disponibile solo per nuovi indici. La ricostruzione concorrente di un indice esistente (DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY) non è ancora supportata.",
concurrentPartitionedTooltip: "Non disponibile sulle tabelle padre partizionate. PostgreSQL rifiuta la creazione concorrente di indici sulle tabelle padre partizionate; crea gli indici figli in modo concorrente e collegalili.",
invalidIndexBlocksSave: 'Salvataggio bloccato: esiste già un indice INVALID con lo stesso nome (residuo di una build concorrente annullata): {indexNames}. Eliminalo prima (es. DROP INDEX "schema"."name"), poi riprova.',
concurrentUnavailableTooltip: "Non disponibile finché non è possibile verificare lo stato di partizione della tabella. La creazione concorrente è disponibile solo su tabelle non partizionate.",
concurrentUnavailableBlocksSave:
"La creazione concorrente dell'indice non è più disponibile (lo stato di partizione della tabella non può essere verificato). L'opzione Concurrente selezionata è stata annullata e questa modifica è bloccata finché la tabella non viene riverificata. Aggiorna per riprovare o rimuovi l'indice per continuare con altre modifiche.",
invalidIndexRetryHint: "Potrebbe essere causato da un indice INVALID con lo stesso nome lasciato da una build concorrente annullata in precedenza. Elimina prima quell'indice, poi riprova.",
foreignKeyName: "Chiave esterna",
referencedSchema: "Schema rif.",
referencedTable: "Tabella rif.",
+9
View File
@@ -3517,6 +3517,15 @@ export default withEnglishFallback({
includedColumns: "付加列",
includedColumnsPlaceholder: "列を選択...",
filter: "フィルター",
concurrent: "同時実行",
concurrentTooltip: "書き込みのブロックを避けるためにインデックスを同時実行で作成します。通常は時間がかかり、トランザクションブロック内では実行できません。",
concurrentExistingIndexTooltip: "新規インデックスのみ利用可能です。既存インデックスの同時再構築(DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY)には未対応です。",
concurrentPartitionedTooltip: "パーティション親テーブルでは利用できません。PostgreSQL はパーティション親テーブルへの同時インデックス作成を拒否します。子インデックスを同時作成してアタッチしてください。",
invalidIndexBlocksSave: '保存がブロックされました: 同じ名前の INVALID インデックス(キャンセルされた同時ビルドの残骸)がすでに存在します: {indexNames}。先に削除(例: DROP INDEX "schema"."name")してから再試行してください。',
concurrentUnavailableTooltip: "テーブルのパーティション状態を確認できない間は利用できません。並行作成は非パーティションのテーブルでのみ提供されます。",
concurrentUnavailableBlocksSave:
"インデックスの並行作成は利用できなくなりました(テーブルのパーティション状態を確認できません)。選択した「並行」オプションは解除され、テーブルが再確認されるまでこの変更はブロックされます。再試行するには更新、または他の変更を続けるにはインデックスを削除してください。",
invalidIndexRetryHint: "以前にキャンセルされた同時ビルドが残した同名の INVALID インデックスが原因の可能性があります。そのインデックスを削除してから再試行してください。",
foreignKeyName: "外部キー",
referencedSchema: "参照スキーマ",
referencedTable: "参照テーブル",
+8
View File
@@ -3440,6 +3440,14 @@ export default withEnglishFallback({
includedColumns: "포함",
includedColumnsPlaceholder: "컬럼 선택...",
filter: "필터",
concurrent: "동시 생성",
concurrentTooltip: "쓰기 차단을 피하기 위해 인덱스를 동시에 생성합니다. 일반적으로 더 오래 걸리며 트랜잭션 블록 내에서 실행할 수 없습니다.",
concurrentExistingIndexTooltip: "새 인덱스에서만 사용할 수 있습니다. 기존 인덱스의 동시 재구축(DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY)은 아직 지원되지 않습니다.",
concurrentPartitionedTooltip: "파티션 부모 테이블에서는 사용할 수 없습니다. PostgreSQL은 파티션 부모 테이블에서의 동시 인덱스 생성을 거부합니다. 하위 인덱스를 동시에 생성한 후 연결하세요.",
invalidIndexBlocksSave: '저장이 차단되었습니다: 취소된 동시 빌드가 남긴 같은 이름의 INVALID 인덱스가 이미 존재합니다: {indexNames}. 먼저 삭제(예: DROP INDEX "schema"."name")한 후 다시 시도하세요.',
concurrentUnavailableTooltip: "테이블의 파티션 상태를 확인할 수 없는 동안에는 사용할 수 없습니다. 동시 생성은 파티션이 아닌 테이블에서만 제공됩니다.",
concurrentUnavailableBlocksSave: "인덱스 동시 생성은 더 이상 사용할 수 없습니다(테이블의 파티션 상태를 확인할 수 없습니다). 선택한 동시 옵션이 해제되었으며 테이블이 다시 확인될 때까지 이 변경은 차단됩니다. 다시 시도하려면 새로 고치거나 다른 변경을 계속하려면 인덱스를 제거하세요.",
invalidIndexRetryHint: "이전에 취소된 동시 빌드가 남긴 같은 이름의 INVALID 인덱스 때문일 수 있습니다. 해당 인덱스를 먼저 삭제한 후 다시 시도하세요.",
foreignKeyName: "외래 키",
referencedSchema: "참조 스키마",
referencedTable: "참조 테이블",
+9
View File
@@ -3458,6 +3458,15 @@ export default withEnglishFallback({
includedColumns: "Incluídas",
includedColumnsPlaceholder: "Selecione as colunas...",
filter: "Filtro",
concurrent: "Concorrente",
concurrentTooltip: "Cria o índice de forma concorrente para evitar bloquear gravações. Geralmente leva mais tempo e não pode ser executado dentro de um bloco de transação.",
concurrentExistingIndexTooltip: "Disponível apenas para novos índices. Reconstruir um índice existente de forma concorrente (DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY) ainda não é suportado.",
concurrentPartitionedTooltip: "Não disponível em tabelas pai particionadas. O PostgreSQL rejeita a criação concorrente de índices em tabelas pai particionadas; crie os índices filhos de forma concorrente e anexe-os.",
invalidIndexBlocksSave: 'Salvamento bloqueado: já existe um índice INVALID com o mesmo nome (resíduo de uma construção concorrente cancelada): {indexNames}. Remova-o primeiro (ex.: DROP INDEX "schema"."name") e tente novamente.',
concurrentUnavailableTooltip: "Indisponível enquanto o status de partição da tabela não puder ser verificado. A criação concorrente só é oferecida em tabelas não particionadas.",
concurrentUnavailableBlocksSave:
"A criação concorrente do índice não está mais disponível (o status de partição da tabela não pôde ser verificado). A opção Concorrente selecionada foi cancelada e esta alteração fica bloqueada até que a tabela seja reverificada. Atualize para tentar novamente ou remova o índice para continuar com outras alterações.",
invalidIndexRetryHint: "Isso pode ser causado por um índice INVALID com o mesmo nome deixado por uma construção concorrente cancelada anteriormente. Remova esse índice primeiro e tente novamente.",
foreignKeyName: "Chave estrangeira",
referencedSchema: "Schema ref.",
referencedTable: "Tabela ref.",
+8
View File
@@ -3610,6 +3610,14 @@ export default withEnglishFallback({
includedColumns: "包含列",
includedColumnsPlaceholder: "选择列...",
filter: "过滤条件",
concurrent: "并发创建",
concurrentTooltip: "并发创建索引,避免阻塞写入。构建通常更慢,且不能在事务块内执行。",
concurrentExistingIndexTooltip: "仅可用于新建索引。尚不支持并发重建已有索引(DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY)。",
concurrentPartitionedTooltip: "分区父表不可用。PostgreSQL 拒绝在分区父表上并发建索引;请改为并发构建子索引后 attach。",
invalidIndexBlocksSave: '保存已阻止:存在同名 INVALID 索引(通常由被取消的并发构建残留):{indexNames}。请先删除该索引(例如 DROP INDEX "schema"."name")后再重试。',
concurrentUnavailableTooltip: "在无法确认表的分区状态前不可用。仅非分区表支持并发创建索引。",
concurrentUnavailableBlocksSave: "并发创建索引已不可用(无法确认表的分区状态)。已清除选中的“并发”选项,在重新确认表状态前将阻止保存。点击刷新重试,或移除该索引后继续保存其他改动。",
invalidIndexRetryHint: "这可能是之前被取消的并发构建残留的同名 INVALID 索引导致。请先删除该索引,再重试。",
foreignKeyName: "外键名",
referencedSchema: "引用库",
referencedTable: "引用表",
+8
View File
@@ -3214,6 +3214,14 @@ export default withEnglishFallback({
includedColumns: "包含",
includedColumnsPlaceholder: "選擇欄位……",
filter: "過濾條件",
concurrent: "並行建立",
concurrentTooltip: "並行建立索引以避免封鎖寫入。通常需要更長時間,且無法在交易區塊內執行。",
concurrentExistingIndexTooltip: "僅可用於新建索引。尚不支援並行重建既有索引(DROP INDEX CONCURRENTLY + CREATE INDEX CONCURRENTLY)。",
concurrentPartitionedTooltip: "分割父資料表不可用。PostgreSQL 拒絕在分割父資料表上並行建立索引;請改為並行建立子索引後再 attach。",
invalidIndexBlocksSave: '儲存已封鎖:已存在同名的 INVALID 索引(通常由已取消的並行建置殘留):{indexNames}。請先刪除該索引(例如 DROP INDEX "schema"."name")後再重試。',
concurrentUnavailableTooltip: "在無法確認資料表的分割狀態前不可用。僅非分割資料表支援並行建立索引。",
concurrentUnavailableBlocksSave: "並行建立索引已不可用(無法確認資料表的分割狀態)。已清除選取的「並行」選項,在重新確認資料表狀態前將阻止儲存。按一下重新整理重試,或移除該索引後繼續儲存其他變更。",
invalidIndexRetryHint: "這可能是先前被取消的並行建置殘留的同名 INVALID 索引所致。請先刪除該索引,再重試。",
foreignKeyName: "外鍵名稱",
referencedSchema: "引用資料庫",
referencedTable: "引用資料表",
+18 -1
View File
@@ -1,7 +1,24 @@
import { describe, expect, it } from "vitest";
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { CONCURRENT_INDEX_QUERY_TIMEOUT_SECS, frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
describe("queryTimeout", () => {
it("gives CREATE INDEX CONCURRENTLY a dedicated long budget instead of the 30s default", () => {
expect(CONCURRENT_INDEX_QUERY_TIMEOUT_SECS).toBe(1800);
expect(CONCURRENT_INDEX_QUERY_TIMEOUT_SECS).toBeGreaterThan(30);
expect(frontendQueryTimeoutDelayMs(CONCURRENT_INDEX_QUERY_TIMEOUT_SECS)).toBe(1_800_000);
});
it.each([
["non-concurrent", 30, false, 30],
["non-concurrent", 3600, false, 3600],
["concurrent + 0 (unlimited)", 0, true, 0],
["concurrent + 30 (below floor)", 30, true, 1800],
["concurrent + 600 (below floor)", 600, true, 1800],
["concurrent + 1800 (floor)", 1800, true, 1800],
["concurrent + 3600 (above floor)", 3600, true, 3600],
] as const)("queryTimeoutSecsForConcurrentIndex preserves %s", (_name, configured, concurrent, expected) => {
expect(queryTimeoutSecsForConcurrentIndex(configured, concurrent)).toBe(expected);
});
it("lets PostgreSQL row queries use the backend inactivity timeout", () => {
expect(frontendQueryTimeoutSecsForSql("SELECT * FROM sample_records LIMIT 2000", "postgres", 30)).toBe(0);
expect(frontendQueryTimeoutSecsForSql("/* page */\nWITH rows AS (SELECT 1) SELECT * FROM rows", "postgres", 30)).toBe(0);
@@ -38,6 +38,22 @@ describe("tableStructureCapabilities", () => {
expect(getTableStructureCapabilities("sqlserver", "sqlserver", "10.0").indexInclude).toBe(true);
});
it("enables concurrent index creation only for PostgreSQL", () => {
// PostgreSQL (including pre-11 versions that disable INCLUDE) advertises it.
expect(getTableStructureCapabilities("postgres", "postgres").indexConcurrent).toBe(true);
expect(getTableStructureCapabilities("postgres", "postgres", "10.15").indexConcurrent).toBe(true);
// PostgreSQL-family engines sharing the Postgres dialect stay off until verified.
for (const databaseType of ["kingbase", "gaussdb", "opengauss", "highgo", "uxdb", "vastbase", "kwdb", "firebird"] as const) {
expect(getTableStructureCapabilities(databaseType, databaseType).indexConcurrent).toBe(false);
}
// Unrelated engines never advertise it.
for (const databaseType of ["mysql", "sqlite", "sqlserver", "oracle", "dameng", "duckdb", "questdb"] as const) {
expect(getTableStructureCapabilities(databaseType, databaseType).indexConcurrent).toBe(false);
}
});
it("removes unsupported included columns before SQL generation", () => {
const index = {
id: "new:index",
+2
View File
@@ -171,6 +171,8 @@ export const listForeignKeys = forward("listForeignKeys");
export const listTriggers = forward("listTriggers");
export const listConstraints = forward("listConstraints");
export const listPartitions = forward("listPartitions");
export const getTablePartitionStatus = forward("getTablePartitionStatus");
export const listInvalidIndexes = forward("listInvalidIndexes");
export const listSubpartitions = forward("listSubpartitions");
export const getTableDdl = forward("getTableDdl");
export const getTableDisplayDdl = forward("getTableDisplayDdl");
+15 -1
View File
@@ -869,6 +869,19 @@ export async function listPartitions(connectionId: string, database: string, sch
return get(`/api/schema/partitions?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
}
export interface TablePartitionStatus {
isPartitionedParent: boolean;
isPartition: boolean;
}
export async function getTablePartitionStatus(connectionId: string, database: string, schema: string, table: string): Promise<TablePartitionStatus> {
return get(`/api/schema/table-partition-status?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function listInvalidIndexes(connectionId: string, database: string, schema: string, table: string): Promise<string[]> {
return get(`/api/schema/invalid-indexes?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function listSubpartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<SubpartitionInfo[]> {
return get(`/api/schema/subpartitions?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
}
@@ -1105,12 +1118,13 @@ export async function closeClientConnectionSession(connectionId: string, databas
});
}
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string): Promise<QueryResult> {
export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number): Promise<QueryResult> {
return post("/api/query/execute-batch", {
connectionId,
database,
statements,
schema,
timeoutSecs,
});
}
+23
View File
@@ -1644,6 +1644,29 @@ export async function listPartitions(connectionId: string, database: string, sch
});
}
export interface TablePartitionStatus {
isPartitionedParent: boolean;
isPartition: boolean;
}
export async function getTablePartitionStatus(connectionId: string, database: string, schema: string, table: string): Promise<TablePartitionStatus> {
return invoke("get_table_partition_status", {
connectionId,
database,
schema,
table,
});
}
export async function listInvalidIndexes(connectionId: string, database: string, schema: string, table: string): Promise<string[]> {
return invoke("list_invalid_indexes", {
connectionId,
database,
schema,
table,
});
}
export async function listSubpartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<SubpartitionInfo[]> {
return invoke("list_subpartitions", {
connectionId,
@@ -39,6 +39,7 @@ export function createDraftIndex(tableName: string, columns: string[], existingI
indexType: "",
includedColumns: [],
comment: "",
concurrently: false,
markedForDrop: false,
};
}
+28
View File
@@ -3,6 +3,21 @@ import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
export const DEFAULT_QUERY_TIMEOUT_SECS = 30;
/**
* Query timeout (seconds) the table structure editor applies when the change
* script contains a PostgreSQL `CREATE INDEX CONCURRENTLY`.
*
* Concurrent builds scan the whole table while only taking `ShareUpdateExclusiveLock`,
* so they routinely run far beyond the default 30s query timeout — the default
* budget would cancel a legitimate build and leave an INVALID index behind.
* PostgreSQL itself does not impose a build deadline
* (https://www.postgresql.org/docs/current/sql-createindex.html). This is the
* dedicated 30-minute floor used by `queryTimeoutSecsForConcurrentIndex` for
* such statements; it never overrides an unlimited setting (0) and never
* shortens a larger configured timeout.
*/
export const CONCURRENT_INDEX_QUERY_TIMEOUT_SECS = 1800;
const MAX_BROWSER_TIMER_DELAY_MS = 2_147_483_647;
const POSTGRES_ROW_STATEMENT_KEYWORDS = new Set(["select", "show", "explain", "table", "with"]);
@@ -17,6 +32,19 @@ export function queryTimeoutSecsForConnection(connection?: Pick<ConnectionConfig
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_QUERY_TIMEOUT_SECS;
}
/**
* Effective query timeout (seconds) for a table-structure change script.
*
* Concurrent builds use at least the dedicated 30-minute floor while
* preserving an unlimited setting (0) and any larger configured timeout.
* Non-concurrent scripts keep the configured timeout unchanged.
*/
export function queryTimeoutSecsForConcurrentIndex(configuredTimeoutSecs: number, hasConcurrentIndexBuild: boolean): number {
if (!hasConcurrentIndexBuild) return configuredTimeoutSecs;
if (configuredTimeoutSecs === 0) return 0;
return Math.max(configuredTimeoutSecs, CONCURRENT_INDEX_QUERY_TIMEOUT_SECS);
}
export function frontendQueryTimeoutSecsForSql(sql: string, databaseType: DatabaseType | undefined, queryTimeoutSecs: number): number {
if (queryTimeoutSecs === 0) return 0;
@@ -0,0 +1,78 @@
/**
* Pure decision helper for the PostgreSQL `CREATE INDEX CONCURRENTLY` checkbox
* in the table structure editor (PR #6361).
*
* Concurrent builds are scoped to *new* indexes on *non-partitioned* tables:
* editing an existing index would require a `DROP INDEX CONCURRENTLY` +
* `CREATE INDEX CONCURRENTLY` replace flow (not implemented), and PostgreSQL
* rejects `CREATE INDEX CONCURRENTLY` on partitioned parent tables. The core
* SQL builder enforces the same scope as a hard error, so this only decides
* whether the option is actionable in the UI.
*/
export type ConcurrentIndexUnavailableReason = "existing" | "partitioned" | "unknown" | "primary" | "markedForDrop" | "unsupported";
export interface ConcurrentIndexAvailability {
enabled: boolean;
reason?: ConcurrentIndexUnavailableReason;
}
export interface ConcurrentIndexAvailabilityInput {
/** The draft edits an index that already exists on the table. */
hasOriginal: boolean;
isPrimary: boolean;
markedForDrop: boolean;
/** `relkind = 'p'` partitioned parent table. */
isPartitionedParent: boolean;
/**
* The partition status could not be verified (probe failed). Fail closed:
* do not offer Concurrent when we cannot rule out a partitioned parent.
*/
partitionStatusKnown: boolean;
supportsIndexConcurrent: boolean;
supportsCreateIndex: boolean;
}
export function getConcurrentIndexAvailability(input: ConcurrentIndexAvailabilityInput): ConcurrentIndexAvailability {
if (input.markedForDrop) return { enabled: false, reason: "markedForDrop" };
if (input.isPrimary) return { enabled: false, reason: "primary" };
if (input.hasOriginal) return { enabled: false, reason: "existing" };
if (input.isPartitionedParent) return { enabled: false, reason: "partitioned" };
if (!input.partitionStatusKnown) return { enabled: false, reason: "unknown" };
if (!input.supportsIndexConcurrent || !input.supportsCreateIndex) return { enabled: false, reason: "unsupported" };
return { enabled: true };
}
/**
* Unquoted index names referenced by `CREATE [UNIQUE] INDEX CONCURRENTLY`
* statements, used to detect same-name INVALID leftovers before applying a
* concurrent build. The PG builder emits the index name as a single
* double-quoted identifier (optionally schema-qualified).
*/
export function concurrentIndexNamesInStatements(statements: string[]): string[] {
const names: string[] = [];
for (const sql of statements) {
const match = sql.match(/CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:(?:"[^"]+")\s*\.\s*)?"([^"]+)"/i);
if (match?.[1]) names.push(match[1]);
}
return names;
}
/**
* Report index drafts whose Concurrent availability is no longer `enabled`.
* Definitive states (partitioned parent, existing index, capability loss, ...)
* clear the stale flag. A transiently unknown partition status preserves the
* user's intent so a later successful probe can restore the concurrent SQL.
* Callers must gate save/SQL generation on every reported invalidation.
*/
export function normalizeUnsupportedConcurrentIndexes<IIndex extends { id: string; concurrently?: boolean }>(indexes: readonly IIndex[], availabilityResolver: (index: IIndex) => ConcurrentIndexAvailability): { indexes: IIndex[]; invalidatedIds: string[] } {
const invalidatedIds: string[] = [];
const normalized = indexes.map((index) => {
if (!index.concurrently) return index;
const availability = availabilityResolver(index);
if (availability.enabled) return index;
invalidatedIds.push(index.id);
if (availability.reason === "unknown") return index;
return { ...index, concurrently: false };
});
return { indexes: normalized, invalidatedIds };
}
@@ -25,6 +25,7 @@ export interface TableStructureCapabilities {
indexInclude: boolean;
indexFilter: boolean;
indexComment: boolean;
indexConcurrent: boolean;
foreignKey: boolean;
}
@@ -49,6 +50,7 @@ const unsupportedCapabilities: TableStructureCapabilities = {
indexInclude: false,
indexFilter: false,
indexComment: false,
indexConcurrent: false,
foreignKey: false,
};
@@ -115,6 +117,7 @@ const postgresCapabilities = capabilities({
const postgresBefore11Capabilities = capabilities({
...postgresCapabilities,
indexInclude: false,
indexConcurrent: true,
});
const redshiftCapabilities = capabilities({
@@ -318,7 +321,7 @@ const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>
oscar: damengCapabilities,
databend: mysqlCapabilities,
gbase: gbaseCapabilities,
postgres: postgresCapabilities,
postgres: capabilities({ ...postgresCapabilities, indexConcurrent: true }),
gaussdb: postgresCapabilities,
kwdb: postgresCapabilities,
opengauss: postgresCapabilities,
@@ -44,6 +44,8 @@ export interface EditableStructureIndex {
indexType: string;
includedColumns: string[];
comment: string;
/** Build the index with PostgreSQL `CREATE INDEX CONCURRENTLY` (PostgreSQL only, default off). */
concurrently?: boolean;
original?: IndexInfo;
markedForDrop: boolean;
}
@@ -81,6 +83,10 @@ export interface BuildTableStructureChangeSqlOptions {
triggers?: EditableStructureTrigger[];
tableComment?: string;
originalTableComment?: string;
/** The target table is a PostgreSQL partitioned parent (`relkind = 'p'`);
* the backend rejects `CREATE INDEX CONCURRENTLY` on such tables (fail
* closed) instead of downgrading to a blocking `CREATE INDEX`. */
partitioned?: boolean;
}
export interface TableStructureChangeSql {