From aa6d60533c5542b23ff0f06c412b0ac7d69a5c30 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Mon, 17 Aug 2026 16:31:44 +0800 Subject: [PATCH] feat(postgres): support concurrent index creation --- .../TableStructureEditor.primaryKey.spec.ts | 6 + .../structure/TableStructureEditor.vue | 246 ++++++++- .../TableStructureEditor.concurrent.spec.ts | 114 +++++ ...ructureEditor.concurrentTransition.spec.ts | 378 ++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 9 + apps/desktop/src/i18n/locales/es.ts | 9 + apps/desktop/src/i18n/locales/it.ts | 9 + apps/desktop/src/i18n/locales/ja.ts | 9 + apps/desktop/src/i18n/locales/ko.ts | 8 + apps/desktop/src/i18n/locales/pt-BR.ts | 9 + apps/desktop/src/i18n/locales/zh-CN.ts | 8 + apps/desktop/src/i18n/locales/zh-TW.ts | 8 + .../lib/__tests__/sql/queryTimeout.spec.ts | 19 +- .../table/tableStructureCapabilities.spec.ts | 16 + apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/http.ts | 16 +- apps/desktop/src/lib/backend/tauri.ts | 23 + apps/desktop/src/lib/diagram/draft-table.ts | 1 + apps/desktop/src/lib/sql/queryTimeout.ts | 28 ++ .../lib/table/concurrentIndexAvailability.ts | 78 +++ .../lib/table/tableStructureCapabilities.ts | 5 +- .../src/lib/table/tableStructureEditorSql.ts | 6 + crates/dbx-core/src/db/postgres.rs | 24 + crates/dbx-core/src/schema.rs | 57 +++ crates/dbx-core/src/table_structure_sql.rs | 9 +- .../src/table_structure_sql/create_table.rs | 7 +- .../src/table_structure_sql/dialect.rs | 50 +- .../src/table_structure_sql/indexes.rs | 14 +- .../src/table_structure_sql/sqlite_rebuild.rs | 3 + .../dbx-core/src/table_structure_sql/tests.rs | 467 ++++++++++++++++++ .../dbx-core/src/table_structure_sql/types.rs | 9 + .../src/table_structure_sql/validation.rs | 34 ++ .../tests/live_postgres_concurrent_index.rs | 217 ++++++++ .../tests/live_sqlserver_completion.rs | 1 + crates/dbx-web/src/main.rs | 2 + crates/dbx-web/src/routes/schema.rs | 26 + src-tauri/src/commands/schema.rs | 22 + src-tauri/src/lib.rs | 2 + 38 files changed, 1917 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/components/structure/__tests__/TableStructureEditor.concurrent.spec.ts create mode 100644 apps/desktop/src/components/structure/__tests__/TableStructureEditor.concurrentTransition.spec.ts create mode 100644 apps/desktop/src/lib/table/concurrentIndexAvailability.ts create mode 100644 crates/dbx-core/tests/live_postgres_concurrent_index.rs diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts index 9c8d90fdfe..8410e1be0d 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -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(() => { diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index b23596e886..d8a79455c2 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -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([]); +/** 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([]); const warnings = ref([]); const sqliteSchemaRevision = ref(); @@ -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(); @@ -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(); 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]) => { + + + — + {{ t("structureEditor.primary") }}