feat(postgres): support concurrent index creation
This commit is contained in:
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)">
|
||||
|
||||
+114
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Vendored
+378
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user