feat(dynamodb): support querying, editing, and exporting

This commit is contained in:
zipg authored and GitHub committed 2026-08-17 03:27:44 +08:00
1 parent 92ab9bb0dc
commit e97c9ab5d7
73 files changed
+4493 -134

No files matched your search

@@ -1075,6 +1075,14 @@ const driverProfiles: Record<
access: { type: "access", port: 0, user: "", label: "Microsoft Access", icon: "access" },
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
"mongodb-legacy": { type: "mongodb", port: 27017, user: "", label: "MongoDB (Legacy)", icon: "mongodb" },
dynamodb: {
type: "dynamodb",
port: 443,
user: "",
label: "Amazon DynamoDB",
icon: "dynamodb",
host: "dynamodb.us-east-1.amazonaws.com",
},
clickhouse: {
type: "clickhouse",
port: 8123,
@@ -2363,6 +2371,11 @@ function applyProfile(val: string, preserveConnectionFields = false) {
if (profile.type === "sqlite") {
form.value.database = undefined;
}
if (profile.type === "dynamodb") {
form.value.database = "us-east-1";
form.value.connection_string = undefined;
form.value.ssl = true;
}
if (profile.type === "h2") {
h2ConnectionMode.value = "file";
form.value.host = "";
@@ -2781,6 +2794,7 @@ const iconTypeMap: Record<string, string> = {
access: "access",
redis: "redis",
mongodb: "mongodb",
dynamodb: "dynamodb",
duckdb: "duckdb",
clickhouse: "clickhouse",
sqlserver: "sqlserver",
@@ -2871,6 +2885,7 @@ const dbOptions: DbOption[] = [
{ value: "opentenbase", label: "OpenTenBase" },
{ value: "mysql", label: "MySQL" },
{ value: "mongodb", label: "MongoDB" },
{ value: "dynamodb", label: "Amazon DynamoDB" },
{ value: "redis", label: "Redis" },
{ value: "oracle", label: "Oracle" },
{ value: "sqlite", label: "SQLite" },
@@ -2987,7 +3002,7 @@ const dbCategoryDefinitions: Array<{
{
key: "document",
titleKey: "connection.databaseCategoryDocument",
optionValues: ["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
optionValues: ["mongodb", "dynamodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
},
{
key: "graph_ai",
@@ -3799,6 +3814,15 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
throw new Error(t("connection.kingbaseDatabaseRequired"));
}
}
if (config.db_type === "dynamodb") {
config.database = config.database?.trim() || "us-east-1";
config.username = config.username.trim();
config.password = config.password.trim();
config.connection_string = config.connection_string?.trim() || undefined;
if (!config.username || !config.password) {
throw new Error(t("connection.dynamodbCredentialsRequired"));
}
}
if (config.db_type === "gaussdb") {
const serialized = serializeGaussdbHosts(gaussdbHostEntries.value);
config.host = serialized.host;
@@ -6659,6 +6683,38 @@ function openExternalUrl(url: string) {
</div>
</template>
<!-- DynamoDB: endpoint, region, AWS credentials -->
<template v-else-if="form.db_type === 'dynamodb'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbEndpoint") }}</Label>
<Input v-model="form.host" class="col-span-2" placeholder="dynamodb.us-east-1.amazonaws.com" />
<Input v-model.number="form.port" type="number" class="col-span-1" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input v-model="form.ssl" type="checkbox" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbRegion") }}</Label>
<Input v-model="form.database" class="col-span-3" placeholder="us-east-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbAccessKeyId") }}</Label>
<Input v-model="form.username" class="col-span-3" autocomplete="username" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbSecretAccessKey") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.dynamodbSessionToken") }}</Label>
<PasswordInput v-model="form.connection_string" class="col-span-3" :placeholder="t('connection.dynamodbSessionTokenPlaceholder')" />
</div>
</template>
<!-- MongoDB: URL or form -->
<template v-else-if="form.db_type === 'mongodb'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -18,6 +18,7 @@ import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Switch } from "@/components/ui/switch";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import type { DynamoDbIndexInfo, DynamoDbTableDescription } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
@@ -55,6 +56,7 @@ import {
type ElasticsearchQueryType,
} from "@/lib/app/documentStoreProvider";
import {
formatDocumentStoreIdLabel,
isDocumentStoreIdentityField,
normalizeDocumentStoreRouting,
parseDocumentStoreInputValue,
@@ -87,6 +89,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
@@ -95,6 +98,7 @@ import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
@@ -108,6 +112,7 @@ const props = defineProps<{
type JsonRecord = Record<string, unknown>;
type ViewMode = "document" | "table";
const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000;
const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
@@ -224,14 +229,15 @@ watch(
const pageTotal = computed(() => paginationTotal.value);
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
const canGoNextPage = computed(() =>
canGoNextDocumentPage({
const canGoNextPage = computed(() => {
if (documentStoreProvider.value.kind === "dynamodb") return dynamodbHasNextCursor.value;
return canGoNextDocumentPage({
page: page.value,
pageSize: pageSize.value,
rowCount: documents.value.length,
paginationTotal: pageTotal.value,
}),
);
});
});
const documentRequestLimit = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
@@ -244,6 +250,8 @@ const tableFindPaneStyle = computed(() => {
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
const documentStoreLabels = computed(() => ({
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, totalIsExact: totalIsExact.value, t }),
filterInputLabel: documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.filter") : documentStoreProvider.value.filterInputLabel,
sortInputLabel: documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.sortKey") : documentStoreProvider.value.sortInputLabel,
queryPreview: documentQueryPreview.value,
}));
@@ -269,6 +277,39 @@ const documentFilterFieldSearch = ref<Record<string, string>>({});
const documentFilterRules = ref<DocumentFilterRule[]>([]);
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
const dynamodbTableDescription = ref<DynamoDbTableDescription | null>(null);
const dynamodbIndexName = ref("__table__");
const dynamodbPageCursors = ref<Array<string | undefined>>([undefined]);
const dynamodbHasNextCursor = ref(false);
const dynamodbExactTotal = ref<number | undefined>();
let dynamodbExactCountKey: string | null = null;
const dynamodbIndexOptions = computed<Array<{ value: string; label: string; index?: DynamoDbIndexInfo }>>(() => [
{ value: "__table__", label: t("dynamodb.baseTable") },
...(dynamodbTableDescription.value?.indexes ?? []).map((index) => ({
value: index.name,
label: `${index.name} (${index.kind === "global" ? "GSI" : "LSI"} · ${index.projectionType})`,
index,
})),
]);
const dynamodbSelectedIndex = computed(() => {
if (dynamodbIndexName.value === "__table__") return undefined;
return dynamodbTableDescription.value?.indexes.find((index) => index.name === dynamodbIndexName.value);
});
const dynamodbPartialProjectionReadOnly = computed(() => documentStoreProvider.value.kind === "dynamodb" && !!dynamodbSelectedIndex.value && dynamodbSelectedIndex.value.projectionType !== "ALL");
const documentStoreEditable = computed(() => !dynamodbPartialProjectionReadOnly.value);
const documentStoreEditDisabledReason = computed(() => (dynamodbPartialProjectionReadOnly.value ? t("dynamodb.partialProjectionReadOnly", { projection: dynamodbSelectedIndex.value?.projectionType ?? "UNKNOWN" }) : undefined));
const dynamodbSelectedKey = computed(() => {
const table = dynamodbTableDescription.value;
if (!table) return null;
if (dynamodbIndexName.value === "__table__") {
return { partitionKey: table.partitionKey, sortKey: table.sortKey };
}
return dynamodbSelectedIndex.value ?? null;
});
const pendingDelete = ref<PendingDelete | null>(null);
const documentFilterComposingEditors = new Set<string>();
@@ -281,9 +322,7 @@ const selectedDoc = computed(() => {
});
const selectedDocumentIdLabel = computed(() => {
if (isNew.value) return "New";
const id = selectedDoc.value?._id;
if (id === undefined || id === null) return "";
return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id);
return formatDocumentStoreIdLabel(selectedDoc.value?._id, documentStoreProvider.value.kind);
});
const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`);
@@ -299,6 +338,12 @@ const deleteDetails = computed(() => {
if (!pending) return "";
if (pending.kind === "document") {
const id = documents.value[pending.index]?._id ?? "";
if (documentStoreProvider.value.kind === "dynamodb") {
return t("dynamodb.documentDetails", {
table: props.collection,
id: formatDocumentStoreIdLabel(id, "dynamodb"),
});
}
const displayId = mongoDocumentIdForGrid(id);
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch") {
const product = props.databaseType === "easysearch" ? "Easysearch" : props.databaseType === "meilisearch" ? "Meilisearch" : "Elasticsearch";
@@ -346,8 +391,9 @@ const gridResult = computed<QueryResult>(() => {
return { columns, column_types: columnTypes, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
if (documentStoreProvider.value.kind !== "mongodb") return undefined;
async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
const kind = documentStoreProvider.value.kind;
if (kind !== "mongodb" && kind !== "dynamodb") return undefined;
const connectionId = props.connectionId;
const database = props.database;
@@ -356,21 +402,23 @@ async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: numbe
const sort = currentDocumentSortJson(sortInput.value);
const exportSettings = settingsStore.editorSettings;
const batchSize = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : Number.POSITIVE_INFINITY;
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : kind === "dynamodb" ? DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT : Number.POSITIVE_INFINITY;
const exportExecutionId = uuid();
const exportStartedAt = performance.now();
const exportedDocuments: JsonRecord[] = [];
let exportedCopyDocuments: JsonRecord[] | undefined = [];
let exportedCopyDocuments: JsonRecord[] | undefined = kind === "mongodb" ? [] : undefined;
let totalRows: number | null = null;
let cursor: string | undefined;
const seenCursors = new Set<string>();
while (exportedDocuments.length < rowLimit) {
const requestLimit = Math.min(batchSize, rowLimit - exportedDocuments.length);
const requestLimit = Math.min(batchSize, kind === "dynamodb" ? 1000 : Number.POSITIVE_INFINITY, rowLimit - exportedDocuments.length);
if (requestLimit <= 0) break;
const result = await api.documentFindDocuments(connectionId, database, collection, exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId);
const result = await api.documentFindDocuments(connectionId, database, collection, kind === "dynamodb" ? 0 : exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId, cursor);
const pageDocuments = result.documents.slice(0, requestLimit).map(asRecord);
exportedDocuments.push(...pageDocuments);
if (exportedCopyDocuments) {
if (kind === "mongodb" && exportedCopyDocuments) {
if (result.extended_documents?.length === result.documents.length) {
exportedCopyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length).map(asRecord));
} else {
@@ -378,19 +426,36 @@ async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: numbe
}
}
if (result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
if (kind === "mongodb" && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
if (kind === "dynamodb") {
cursor = result.next_cursor;
if (!cursor) break;
if (seenCursors.has(cursor)) throw new Error(t("dynamodb.repeatedCursor"));
seenCursors.add(cursor);
continue;
}
const reachedExactTotal = result.total_is_exact !== false && exportedDocuments.length >= result.total;
if (pageDocuments.length === 0 || pageDocuments.length < requestLimit || reachedExactTotal) break;
}
if (kind === "dynamodb") {
const truncatedByLimit = !!cursor && exportedDocuments.length >= rowLimit;
totalRows = truncatedByLimit ? null : exportedDocuments.length;
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
if (truncatedByLimit) {
toast(t("dynamodb.exportLimitReached", { count: rowLimit }), 6000);
}
}
const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null);
if (result.columns.length === 0) result.columns = gridResult.value.columns;
result.column_types = mongoDocumentGridColumnTypes(exportedDocuments, result.columns);
result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : undefined;
result.affected_rows = exportedDocuments.length;
result.truncated = false;
result.has_more = false;
result.truncated = kind === "dynamodb" && !!cursor && exportedDocuments.length >= rowLimit;
result.has_more = result.truncated;
return result;
}
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
@@ -616,8 +681,38 @@ function resetDocumentFilterBuilder() {
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
}
function dynamodbCountFilterKey(filter: string | undefined): string {
return JSON.stringify([props.connectionId, props.collection, filter ?? ""]);
}
function resetDynamoDbExactCount() {
dynamodbExactCountKey = null;
dynamodbExactTotal.value = undefined;
}
function resetDynamoDbPagination(options: { preserveExactCount?: boolean } = {}) {
dynamodbPageCursors.value = [undefined];
dynamodbHasNextCursor.value = false;
paginationTotal.value = undefined;
if (!options.preserveExactCount) resetDynamoDbExactCount();
}
function currentDocumentFilter(): string | undefined {
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
const filter = currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
if (documentStoreProvider.value.kind !== "dynamodb" || dynamodbIndexName.value === "__table__") return filter;
const parsed = filter ? JSON.parse(filter) : {};
return JSON.stringify({ ...parsed, $index: dynamodbIndexName.value });
}
function selectDynamoDbIndex(value: unknown) {
const next = typeof value === "string" && value ? value : "__table__";
if (dynamodbIndexName.value === next) return;
dynamodbIndexName.value = next;
if (dynamodbPartialProjectionReadOnly.value && isEditing.value) cancelEdit();
sortInput.value = "";
page.value = 0;
resetDynamoDbPagination();
void load({ page: 0 });
}
function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
@@ -763,7 +858,30 @@ function documentStoreWriteApis(documentType?: string) {
};
}
function prepareDynamoDbDocumentIdentity(document: JsonRecord): { document: JsonRecord; id: string } {
const table = dynamodbTableDescription.value;
if (!table) throw new Error(t("dynamodb.tableMetadataUnavailable"));
const next = { ...document };
const metadataId = next._id && typeof next._id === "object" && !Array.isArray(next._id) ? (next._id as JsonRecord) : undefined;
const identity: JsonRecord = {};
const keys = [table.partitionKey, table.sortKey].filter((value): value is NonNullable<typeof value> => !!value);
for (const key of keys) {
const value = next[key.name] ?? metadataId?.[key.name];
if (value === undefined || value === null || value === "") {
throw new Error(t("dynamodb.keyRequired", { key: key.name }));
}
next[key.name] = value;
identity[key.name] = value;
}
next._id = identity;
return { document: next, id: serializeDocumentStoreId(identity, "dynamodb") };
}
async function gridSave(changes: DocumentGridChanges) {
if (!documentStoreEditable.value) {
throw new Error(documentStoreEditDisabledReason.value);
}
const cols = changes.columns;
const idColIdx = cols.indexOf("_id");
if (idColIdx < 0) throw new Error("No _id column");
@@ -818,8 +936,14 @@ async function gridSave(changes: DocumentGridChanges) {
if (!doc) continue;
const routing = isEs ? documentRoutingFromDocument(doc) : undefined;
const updated = buildPathIdentityUpdatedDocument(doc, dirtyCols, cols, kind);
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
const documentId = serializeDocumentStoreId(doc._id ?? id, kind);
if (kind === "dynamodb") {
const normalized = prepareDynamoDbDocumentIdentity(updated);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind, mode: "update" });
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind));
continue;
}
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind), routing);
continue;
}
@@ -848,7 +972,12 @@ async function gridSave(changes: DocumentGridChanges) {
const idValue = newRow[idColIdx];
const id = idValue === null || idValue === undefined || idValue === "" ? null : serializeDocumentStoreId(parseDocumentStoreInputValue(idValue, kind), kind);
const routing = isEs ? documentRoutingFromGridRow(newRow, cols) : undefined;
if (id) {
if (kind === "dynamodb") {
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, kind);
const normalized = prepareDynamoDbDocumentIdentity(doc);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind, mode: "insert" });
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(writeDocument, kind));
} else if (id) {
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, kind), routing);
} else {
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, kind), routing);
@@ -861,6 +990,10 @@ async function gridSave(changes: DocumentGridChanges) {
}
if (isEs) resetElasticsearchTotals({ preservePaginationTotal: true });
if (kind === "dynamodb") {
page.value = 0;
resetDynamoDbPagination();
}
await load();
}
@@ -920,6 +1053,14 @@ function buildElasticsearchPartialUpdateDocument(changes: Map<number, MongoInput
return document;
}
function formatDynamoDbOperationPreview(action: "insert" | "put" | "delete", id: unknown, document?: Record<string, unknown>): string {
const operation = action === "insert" ? "INSERT ITEM" : action === "put" ? "PUT ITEM" : "DELETE ITEM";
const lines = [`DBX DYNAMODB ${operation}`, `table: ${JSON.stringify(props.collection)}`];
if (id !== undefined) lines.push("key:", stringifyDocumentStoreValue(id, "dynamodb", 2));
if (document) lines.push("item:", stringifyDocumentStoreValue(document, "dynamodb", 2));
return lines.join("\n");
}
async function previewDocumentChanges(changes: DocumentGridChanges): Promise<string[]> {
const { dirtyRows, deletedRows, newRows, newRowMeta, columns, rows } = changes;
const idColIdx = columns.indexOf("_id");
@@ -938,6 +1079,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const updateDoc = buildElasticsearchPartialUpdateDocument(dirtyCols, columns);
const routing = documentRoutingFromGridRow(row, columns);
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${stringifyDocumentStoreValue({ doc: updateDoc.$set ?? updateDoc }, "elasticsearch", 2)}`);
} else if (kind === "dynamodb") {
const sourceDocument = documents.value[rowIdx];
if (!sourceDocument) continue;
const documentId = sourceDocument._id ?? id;
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, "dynamodb");
const normalized = prepareDynamoDbDocumentIdentity(updated);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind: "dynamodb", mode: "update" });
stmts.push(formatDynamoDbOperationPreview("put", documentId, writeDocument));
} else {
const sourceDocument = documents.value[rowIdx];
if (!sourceDocument) continue;
@@ -957,6 +1106,10 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const id = row?.[idColIdx];
if (id == null) continue;
if (isPathIdentityStore) {
if (kind === "dynamodb") {
stmts.push(formatDynamoDbOperationPreview("delete", documents.value[rowIdx]?._id ?? id));
continue;
}
if (!isEs) {
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
continue;
@@ -971,6 +1124,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
for (const [newRowIndex, newRow] of newRows.entries()) {
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, columns, kind) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
if (isPathIdentityStore) {
if (kind === "dynamodb") {
const idValue = idColIdx >= 0 ? newRow[idColIdx] : undefined;
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, "dynamodb");
const normalized = prepareDynamoDbDocumentIdentity(doc);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind: "dynamodb", mode: "insert" });
stmts.push(formatDynamoDbOperationPreview("insert", JSON.parse(normalized.id), writeDocument));
continue;
}
if (!isEs) {
const idValue = idColIdx >= 0 ? newRow[idColIdx] : null;
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, "meilisearch");
@@ -1132,13 +1293,24 @@ async function load(options: { page?: number } = {}) {
const collection = props.collection;
const storeKind = documentStoreProvider.value.kind;
const filter = currentDocumentFilter();
if (storeKind === "dynamodb") {
const countKey = dynamodbCountFilterKey(filter);
if (dynamodbExactCountKey !== countKey) {
dynamodbExactCountKey = countKey;
dynamodbExactTotal.value = undefined;
}
}
const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind };
if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
resetElasticsearchTotals();
}
const sort = currentDocumentSortJson(sortInput.value);
const skip = requestPage * pageSize.value;
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId);
const cursor = storeKind === "dynamodb" ? dynamodbPageCursors.value[requestPage] : undefined;
if (storeKind === "dynamodb" && requestPage > 0 && !cursor) {
throw new Error(t("dynamodb.pageCursorUnavailable"));
}
const skip = storeKind === "dynamodb" ? 0 : requestPage * pageSize.value;
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId, cursor);
if (documentLoadExecutionId.value !== executionId) return;
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
const nextDocuments =
@@ -1159,6 +1331,12 @@ async function load(options: { page?: number } = {}) {
copyDocuments.value = nextCopyDocuments;
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
loadedDocumentQueryTotalCountRequest = countRequest;
if (storeKind === "dynamodb") {
const nextCursors = dynamodbPageCursors.value.slice(0, requestPage + 1);
nextCursors[requestPage + 1] = result.next_cursor;
dynamodbPageCursors.value = nextCursors;
dynamodbHasNextCursor.value = !!result.next_cursor;
}
if (nextDocuments.length > 0) {
const keySet = new Set<string>();
keySet.add("_id");
@@ -1172,6 +1350,13 @@ async function load(options: { page?: number } = {}) {
}
if (storeKind === "elasticsearch") {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
} else if (storeKind === "dynamodb") {
cancelElasticsearchCount();
const lowerBound = requestPage * pageSize.value + nextDocuments.length + (result.next_cursor ? 1 : 0);
const exactTotal = dynamodbExactTotal.value ?? (!result.next_cursor ? lowerBound : undefined);
total.value = exactTotal ?? lowerBound;
totalIsExact.value = exactTotal !== undefined;
paginationTotal.value = exactTotal;
} else {
cancelElasticsearchCount();
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, {
@@ -1212,11 +1397,15 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
const exactCount = request.storeKind === "dynamodb" ? await api.documentCountDocuments(request.connectionId, request.collection, request.filter) : await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
}
if (request.storeKind === "dynamodb") {
dynamodbExactCountKey = dynamodbCountFilterKey(request.filter);
dynamodbExactTotal.value = exactCount;
}
const totals = resolveDocumentQueryTotals(exactCount, true);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
@@ -1226,6 +1415,7 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
async function refreshDocuments() {
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbExactCount();
await load();
}
@@ -1248,20 +1438,49 @@ async function cancelDocumentLoad() {
function applyFilter() {
page.value = 0;
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals();
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbPagination();
void load();
}
function paginate(offset: number, limit: number) {
async function paginate(offset: number, limit: number) {
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
const pageSizeChanged = normalizedLimit !== pageSize.value;
pageSize.value = normalizedLimit;
if (documentStoreProvider.value.kind === "dynamodb" && pageSizeChanged) {
page.value = 0;
resetDynamoDbPagination({ preserveExactCount: true });
await load({ page: 0 });
return;
}
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
void load({ page: nextPage });
if (documentStoreProvider.value.kind !== "dynamodb") {
await load({ page: nextPage });
return;
}
for (let cursorPage = 0; cursorPage <= nextPage; cursorPage += 1) {
if (cursorPage > 0 && !dynamodbPageCursors.value[cursorPage]) {
error.value = t("dynamodb.pageCursorUnavailable");
return;
}
if (cursorPage === nextPage) {
await load({ page: cursorPage });
return;
}
if (!dynamodbPageCursors.value[cursorPage + 1]) {
await load({ page: cursorPage });
}
}
}
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
if (documentStoreProvider.value.kind === "dynamodb" && direction && dynamodbSelectedKey.value?.sortKey?.name !== column) {
error.value = t("dynamodb.sortKeyOnly", { key: dynamodbSelectedKey.value?.sortKey?.name || t("dynamodb.none") });
return;
}
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
page.value = 0;
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbPagination({ preserveExactCount: true });
void load();
}
@@ -1275,7 +1494,7 @@ function asRecord(value: unknown): JsonRecord {
function documentIdentity(doc: JsonRecord | undefined): string | null {
const id = doc?._id;
if (id === null || id === undefined) return null;
return typeof id === "object" ? JSON.stringify(id) : String(id);
return serializeDocumentStoreId(id, documentStoreProvider.value.kind);
}
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
@@ -1385,6 +1604,7 @@ function selectDoc(idx: number) {
}
function startNew() {
if (!documentStoreEditable.value) return;
selectedIdx.value = null;
editJson.value = emptyDocumentJson();
editFields.value = [createEditNode("", "", false, false)];
@@ -1395,6 +1615,7 @@ function startNew() {
}
function startEdit() {
if (!documentStoreEditable.value) return;
const doc = selectedDoc.value;
if (!doc) return;
// Issue #2952: open whole-document JSON editing by default (DBeaver-style), not field tree.
@@ -1563,6 +1784,9 @@ function resolveDocumentStorePathId(id: unknown): string | null {
function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, currentRouting: string | undefined): { writeId: string; writeRouting?: string } | null {
const kind = documentStoreProvider.value.kind;
if (kind === "dynamodb") {
return { writeId: prepareDynamoDbDocumentIdentity(doc).id };
}
const hasPayloadId = Object.prototype.hasOwnProperty.call(doc, "_id");
const writeId = hasPayloadId ? resolveDocumentStorePathId(doc._id) : resolveDocumentStorePathId(currentId);
if (!writeId) return null;
@@ -1571,7 +1795,7 @@ function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, cur
}
async function saveDoc() {
if (isSavingDocument.value) return;
if (isSavingDocument.value || !documentStoreEditable.value) return;
error.value = "";
isSavingDocument.value = true;
try {
@@ -1579,13 +1803,14 @@ async function saveDoc() {
if (!doc) return;
const kind = documentStoreProvider.value.kind;
const writeDocument = kind === "dynamodb" ? prepareDynamoDbDocumentIdentity(doc).document : doc;
if (isNew.value) {
const apis = documentStoreWriteApis();
const explicitId = kind === "mongodb" || doc._id === undefined || doc._id === null || doc._id === "" ? null : resolveDocumentStorePathId(doc._id);
const explicitId = kind === "mongodb" || writeDocument._id === undefined || writeDocument._id === null || writeDocument._id === "" ? null : resolveDocumentStorePathId(writeDocument._id);
await insertDocumentStoreDocumentCore({
kind,
document: doc,
document: writeDocument,
explicitId,
routing: normalizeDocumentStoreRouting(doc._routing),
apis,
@@ -1605,7 +1830,7 @@ async function saveDoc() {
}
const currentRouting = documentRoutingFromDocument(current);
const apis = documentStoreWriteApis(kind === "elasticsearch" ? documentTypeFromDocument(current) : undefined);
const write = resolveWriteIdentityFromEditor(doc, currentId, currentRouting);
const write = resolveWriteIdentityFromEditor(writeDocument, currentId, currentRouting);
if (!write) {
error.value = t("mongo.jsonIdRequired");
return;
@@ -1615,8 +1840,7 @@ async function saveDoc() {
write: { id: write.writeId, routing: write.writeRouting },
current: { id: deleteId, routing: kind === "elasticsearch" ? currentRouting : undefined },
});
// Rekey writes first then deletes; write failure leaves the old document intact.
await applyDocumentStoreIdentityPlan({ kind, plan, document: doc, apis });
await applyDocumentStoreIdentityPlan({ kind, plan, document: writeDocument, apis });
} else {
return;
}
@@ -1626,6 +1850,10 @@ async function saveDoc() {
documentEditMode.value = "fields";
editFields.value = [];
if (kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
if (kind === "dynamodb") {
page.value = 0;
resetDynamoDbPagination();
}
await load();
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
editJson.value = stringifyDocumentStoreValue(documents.value[selectedIdx.value], documentStoreProvider.value.kind, 2);
@@ -1649,6 +1877,10 @@ async function applyDeleteDoc(idx: number) {
editJson.value = "";
}
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
if (documentStoreProvider.value.kind === "dynamodb") {
page.value = 0;
resetDynamoDbPagination();
}
await load();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
@@ -1656,6 +1888,7 @@ async function applyDeleteDoc(idx: number) {
}
function requestDeleteDoc(idx: number) {
if (!documentStoreEditable.value) return;
if (!settingsStore.editorSettings.confirmDangerousSqlExecution) {
void applyDeleteDoc(idx);
return;
@@ -1688,7 +1921,7 @@ function nextPage() {
}
function docPreview(doc: JsonRecord): string {
const id = doc._id || "";
const id = formatDocumentStoreIdLabel(doc._id, documentStoreProvider.value.kind);
const keys = Object.keys(doc)
.filter((k) => k !== "_id")
.slice(0, 3);
@@ -1724,6 +1957,15 @@ watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
documentViewerSearchActive.value = false;
});
async function loadDynamoDbTableDescription() {
if (documentStoreProvider.value.kind !== "dynamodb") return;
try {
dynamodbTableDescription.value = await api.dynamodbDescribeTable(props.connectionId, props.collection);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
}
onMounted(async () => {
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
try {
@@ -1731,6 +1973,7 @@ onMounted(async () => {
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
await loadDynamoDbTableDescription();
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
@@ -1807,7 +2050,26 @@ defineExpose({ focusSearch });
<span class="shrink-0 ml-1">{{ documentStoreLabels.documentsLabel }}</span>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
<div v-if="documentStoreProvider.kind === 'dynamodb' && dynamodbTableDescription" class="ml-1 flex min-w-0 items-center gap-1.5">
<Select :model-value="dynamodbIndexName" @update:model-value="selectDynamoDbIndex">
<SelectTrigger class="h-6 w-44 min-w-0 text-xs" :title="t('dynamodb.index')">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in dynamodbIndexOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<span v-if="dynamodbSelectedKey" class="max-w-64 truncate font-mono text-[11px] text-muted-foreground" :title="t('dynamodb.keySummary', { partitionKey: dynamodbSelectedKey.partitionKey.name, sortKey: dynamodbSelectedKey.sortKey?.name || t('dynamodb.none') })">
PK: {{ dynamodbSelectedKey.partitionKey.name }}<template v-if="dynamodbSelectedKey.sortKey"> · SK: {{ dynamodbSelectedKey.sortKey.name }}</template>
</span>
<Badge v-if="dynamodbPartialProjectionReadOnly" variant="outline" class="h-5 rounded border-amber-500/50 px-1.5 text-[10px] text-amber-600 dark:text-amber-400" :title="documentStoreEditDisabledReason">
{{ dynamodbSelectedIndex?.projectionType }}
</Badge>
</div>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click="startNew"><Plus class="h-3 w-3" /></Button>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="refreshDocuments"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
@@ -1988,7 +2250,7 @@ defineExpose({ focusSearch });
context="results"
:database-type="props.databaseType"
:mongo-update-target="mongoUpdateTarget"
editable
:editable="documentStoreEditable"
:custom-save-handler="customSaveHandler"
:loading="loading"
:sql="documentStoreLabels.queryPreview"
@@ -1999,7 +2261,7 @@ defineExpose({ focusSearch });
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:count-total-rows="countExactDocumentTotal"
:full-export-result="documentStoreProvider.kind === 'mongodb' ? exportAllMongoDocuments : undefined"
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' ? exportAllDocumentStoreDocuments : undefined"
@sort="onSort"
@reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
@@ -2210,7 +2472,7 @@ defineExpose({ focusSearch });
</div>
</PopoverContent>
</Popover>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreLabels.filterInputLabel }}</span>
<textarea
ref="filterInputRef"
v-model="filterInput"
@@ -2249,7 +2511,7 @@ defineExpose({ focusSearch });
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
</button>
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreLabels.sortInputLabel }}</span>
<textarea
ref="sortInputRef"
v-model="sortInput"
@@ -2290,7 +2552,7 @@ defineExpose({ focusSearch });
<div class="flex-1 overflow-y-auto">
<div v-for="(doc, idx) in documents" :key="idx" class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group" :class="{ 'bg-accent': selectedIdx === idx }" @click="selectDoc(idx)">
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click.stop="requestDeleteDoc(idx)">
<Trash2 class="w-3 h-3" />
</Button>
</div>
@@ -2310,7 +2572,7 @@ defineExpose({ focusSearch });
<input class="min-w-0 w-full cursor-text select-text appearance-none border-0 bg-transparent p-0 text-inherit outline-none focus:ring-0" :value="selectedDocumentIdLabel" :aria-label="`_id: ${selectedDocumentIdLabel}`" readonly spellcheck="false" />
</Badge>
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click="startEdit">{{ t("mongo.edit") }}</Button>
<template v-if="isEditing">
<div class="flex items-center border rounded-md overflow-hidden mr-1">
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'json' }" :disabled="isSavingDocument" @click="setDocumentEditMode('json')">{{ t("mongo.editModeJson") }}</Button>
@@ -6,8 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const backend = vi.hoisted(() => ({
getColumns: vi.fn(),
documentFindDocuments: vi.fn(),
documentCountDocuments: vi.fn(),
dynamodbDescribeTable: vi.fn(),
cancelQuery: vi.fn(),
ensureConnected: vi.fn(),
documentInsertDocument: vi.fn(),
documentUpdateDocument: vi.fn(),
documentDeleteDocument: vi.fn(),
documentSaveMeilisearchBatch: vi.fn(),
}));
@@ -24,6 +28,8 @@ const dataGrid = vi.hoisted(() => ({
column_types?: string[];
rows: Array<Array<string | number | boolean | null>>;
mongo_copy_documents?: unknown[];
truncated?: boolean;
has_more?: boolean;
}
| undefined
>)
@@ -31,8 +37,12 @@ const dataGrid = vi.hoisted(() => ({
customSaveHandler: undefined as
| {
save: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<void>;
preview?: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<string[]>;
}
| undefined,
countTotalRows: undefined as (() => Promise<number | undefined>) | undefined,
paginate: undefined as ((offset: number, limit: number) => Promise<void>) | undefined,
editable: true,
}));
const settings = vi.hoisted(() => ({
@@ -54,13 +64,19 @@ const settings = vi.hoisted(() => ({
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({ t: (key: string) => key }),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (key === "dynamodb.items" ? `${params?.count} Items` : key),
}),
}));
vi.mock("@/lib/backend/api", () => ({
getColumns: backend.getColumns,
documentFindDocuments: backend.documentFindDocuments,
documentCountDocuments: backend.documentCountDocuments,
dynamodbDescribeTable: backend.dynamodbDescribeTable,
cancelQuery: backend.cancelQuery,
documentInsertDocument: backend.documentInsertDocument,
documentUpdateDocument: backend.documentUpdateDocument,
documentDeleteDocument: backend.documentDeleteDocument,
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
}));
@@ -90,10 +106,14 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
columnLayoutScopeKey: { type: String, default: "" },
fullExportResult: { type: Function, default: undefined },
customSaveHandler: { type: Object, default: undefined },
countTotalRows: { type: Function, default: undefined },
editable: { type: Boolean, default: false },
},
setup(props, { expose, slots }) {
setup(props, { attrs, expose, slots }) {
dataGrid.fullExportResult = props.fullExportResult as typeof dataGrid.fullExportResult;
dataGrid.customSaveHandler = props.customSaveHandler as typeof dataGrid.customSaveHandler;
dataGrid.countTotalRows = props.countTotalRows as typeof dataGrid.countTotalRows;
dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate;
expose({
visibleColumnCount: 2,
displayableColumnCount: 2,
@@ -113,8 +133,9 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
multiRowTranspose: false,
setMultiRowTranspose: vi.fn(),
});
return () =>
h(
return () => {
dataGrid.editable = props.editable;
return h(
"div",
{
"data-testid": "data-grid",
@@ -124,6 +145,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
"data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
"data-result-column-types": JSON.stringify((props.result as { column_types?: string[] }).column_types ?? []),
"data-result-rows": JSON.stringify((props.result as { rows?: unknown[] }).rows ?? []),
"data-editable": String(props.editable),
},
[
slots["search-bar"]?.({
@@ -134,6 +156,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
}),
],
);
};
},
}),
};
@@ -215,8 +238,9 @@ vi.mock("@/components/ui/select", async () => {
props: {
modelValue: { type: String, default: "" },
},
setup(props, { slots }) {
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue }, slots.default?.());
emits: ["update:modelValue"],
setup(props, { emit, slots }) {
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue, onClick: () => props.modelValue === "__table__" && emit("update:modelValue", "by_status") }, slots.default?.());
},
});
const passthrough = (name: string) =>
@@ -284,14 +308,23 @@ beforeEach(async () => {
});
backend.getColumns.mockReset();
backend.documentFindDocuments.mockReset();
backend.documentCountDocuments.mockReset();
backend.dynamodbDescribeTable.mockReset();
backend.cancelQuery.mockReset();
backend.ensureConnected.mockReset();
backend.documentInsertDocument.mockReset();
backend.documentUpdateDocument.mockReset();
backend.documentDeleteDocument.mockReset();
backend.documentSaveMeilisearchBatch.mockReset();
dataGrid.fullExportResult = undefined;
dataGrid.customSaveHandler = undefined;
dataGrid.countTotalRows = undefined;
dataGrid.paginate = undefined;
dataGrid.editable = true;
documentJsonEditor.openSearch.mockClear();
backend.documentDeleteDocument.mockResolvedValue(undefined);
backend.documentInsertDocument.mockResolvedValue("created");
backend.documentUpdateDocument.mockResolvedValue(1);
backend.documentSaveMeilisearchBatch.mockResolvedValue(0);
settings.editorSettings.mongoViewMode = "table";
settings.editorSettings.columnWidthDensity = "standard";
@@ -306,6 +339,16 @@ beforeEach(async () => {
settings.updateEditorSettings.mockReset();
settings.updateEditorSettings.mockImplementation((partial: Partial<typeof settings.editorSettings>) => Object.assign(settings.editorSettings, partial));
backend.ensureConnected.mockResolvedValue(undefined);
backend.documentCountDocuments.mockResolvedValue(0);
backend.dynamodbDescribeTable.mockResolvedValue({
name: "orders",
status: "ACTIVE",
itemCount: 0,
sizeBytes: 0,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [],
});
backend.getColumns.mockResolvedValue([
{ name: "buyers", data_type: "nested" },
{ name: "buyers.email", data_type: "text" },
@@ -443,6 +486,315 @@ describe("DocumentBrowser Elasticsearch field search", () => {
});
describe("DocumentBrowser MongoDB filter value types", () => {
it("loads DynamoDB table metadata before requesting the first page", async () => {
app?.unmount();
let resolveDescription!: (value: { name: string; status: string; itemCount: number; sizeBytes: number; partitionKey: { name: string; attributeType: string }; sortKey: { name: string; attributeType: string }; indexes: never[] }) => void;
backend.dynamodbDescribeTable.mockReturnValue(
new Promise((resolve) => {
resolveDescription = resolve;
}),
);
backend.documentFindDocuments.mockClear();
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(backend.dynamodbDescribeTable).toHaveBeenCalledOnce();
expect(backend.documentFindDocuments).not.toHaveBeenCalled();
resolveDescription({
name: "orders",
status: "ACTIVE",
itemCount: 0,
sizeBytes: 0,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [],
});
await flushUi();
expect(backend.documentFindDocuments).toHaveBeenCalledOnce();
});
it("labels new DynamoDB rows as conditional inserts in the executable preview", async () => {
app?.unmount();
backend.documentFindDocuments.mockResolvedValue({
documents: [{ _id: { tenant_id: "tenant-04", order_id: "ORD-000994" }, tenant_id: "tenant-04", order_id: "ORD-000994", note: "existing" }],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
const preview = await dataGrid.customSaveHandler!.preview!({
dirtyRows: new Map(),
deletedRows: new Set(),
newRows: [['{"tenant_id":"tenant-04","order_id":"ORD-new"}', "tenant-04", "ORD-new", "new item"]],
newRowMeta: [{}],
columns: ["_id", "tenant_id", "order_id", "note"],
rows: [['{"tenant_id":"tenant-04","order_id":"ORD-000994"}', "tenant-04", "ORD-000994", "existing"]],
});
expect(preview).toHaveLength(1);
expect(preview[0]).toContain('DBX DYNAMODB INSERT ITEM\ntable: "orders"');
expect(preview[0]).toContain('"order_id": "ORD-new"');
});
it("uses one backend update for a DynamoDB key migration and previews the old source key", async () => {
app?.unmount();
backend.documentFindDocuments.mockResolvedValue({
documents: [{ _id: { tenant_id: "tenant-04", order_id: "ORD-old" }, tenant_id: "tenant-04", order_id: "ORD-old", note: "existing" }],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
const changes = {
dirtyRows: new Map([[0, new Map([[2, "ORD-new"]])]]),
deletedRows: new Set<number>(),
newRows: [],
newRowMeta: [],
columns: ["_id", "tenant_id", "order_id", "note"],
rows: [['{"tenant_id":"tenant-04","order_id":"ORD-old"}', "tenant-04", "ORD-old", "existing"]],
};
const preview = await dataGrid.customSaveHandler!.preview!(changes);
expect(preview).toHaveLength(1);
expect(preview[0]).toContain('key:\n{\n "tenant_id": "tenant-04",\n "order_id": "ORD-old"\n}');
expect(preview[0]).toContain('"order_id": "ORD-new"');
await dataGrid.customSaveHandler!.save(changes);
expect(backend.documentUpdateDocument).toHaveBeenCalledTimes(1);
expect(backend.documentUpdateDocument.mock.calls[0]?.slice(0, 4)).toEqual(["dynamodb-1", "us-east-1", "orders", '{"tenant_id":"tenant-04","order_id":"ORD-old"}']);
expect(JSON.parse(backend.documentUpdateDocument.mock.calls[0]?.[4])).toMatchObject({ tenant_id: "tenant-04", order_id: "ORD-new", note: "existing" });
expect(backend.documentInsertDocument).not.toHaveBeenCalled();
expect(backend.documentDeleteDocument).not.toHaveBeenCalled();
});
it("makes partial-projection DynamoDB index results read-only", async () => {
app?.unmount();
backend.dynamodbDescribeTable.mockResolvedValue({
name: "orders",
status: "ACTIVE",
itemCount: 1,
sizeBytes: 100,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [
{
name: "by_status",
kind: "global",
partitionKey: { name: "status", attributeType: "S" },
sortKey: { name: "created_at", attributeType: "N" },
projectionType: "KEYS_ONLY",
nonKeyAttributes: [],
},
],
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(dataGrid.editable).toBe(true);
root!.querySelector<HTMLElement>('[data-testid="select"][data-model-value="__table__"]')!.click();
await flushUi();
expect(dataGrid.editable).toBe(false);
expect(root!.textContent).toContain("KEYS_ONLY");
await expect(
dataGrid.customSaveHandler!.save({
dirtyRows: new Map(),
deletedRows: new Set(),
newRows: [],
newRowMeta: [],
columns: ["_id"],
rows: [],
}),
).rejects.toThrow("dynamodb.partialProjectionReadOnly");
});
it("keeps ALL-projection DynamoDB index results editable", async () => {
app?.unmount();
backend.dynamodbDescribeTable.mockResolvedValue({
name: "orders",
status: "ACTIVE",
itemCount: 1,
sizeBytes: 100,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [
{
name: "by_status",
kind: "global",
partitionKey: { name: "status", attributeType: "S" },
sortKey: { name: "created_at", attributeType: "N" },
projectionType: "ALL",
nonKeyAttributes: [],
},
],
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
root!.querySelector<HTMLElement>('[data-testid="select"][data-model-value="__table__"]')!.click();
await flushUi();
expect(dataGrid.editable).toBe(true);
});
it("keeps an exact DynamoDB count while moving between cursor pages", async () => {
app?.unmount();
backend.documentCountDocuments.mockResolvedValue(250);
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments
.mockResolvedValueOnce({
documents: Array.from({ length: 100 }, (_, index) => ({ _id: { tenant_id: "tenant-a", order_id: String(index) } })),
total: 101,
total_is_exact: false,
next_cursor: "cursor-1",
})
.mockResolvedValueOnce({
documents: Array.from({ length: 100 }, (_, index) => ({ _id: { tenant_id: "tenant-a", order_id: String(index + 100) } })),
total: 101,
total_is_exact: false,
next_cursor: "cursor-2",
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(await dataGrid.countTotalRows!()).toBe(250);
await flushUi();
expect(root!.textContent).toContain("250 Items");
await dataGrid.paginate!(100, 100);
await flushUi();
expect(backend.documentFindDocuments.mock.calls.at(-1)?.[10]).toBe("cursor-1");
expect(root!.textContent).toContain("250 Items");
});
it("exports all DynamoDB pages with opaque cursors instead of offsets", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 2;
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments
.mockResolvedValueOnce({
documents: [{ _id: { tenant_id: "tenant-a", order_id: "visible" }, tenant_id: "tenant-a", order_id: "visible" }],
total: 2,
total_is_exact: false,
next_cursor: "visible-cursor",
})
.mockResolvedValueOnce({
documents: [
{ _id: { tenant_id: "tenant-a", order_id: "001" }, tenant_id: "tenant-a", order_id: "001", amount: { $dbxDynamoDb: { version: 1, type: "number", value: "9007199254740993" } } },
{ _id: { tenant_id: "tenant-a", order_id: "002" }, tenant_id: "tenant-a", order_id: "002", tags: { $dbxDynamoDb: { version: 1, type: "stringSet", value: ["new", "paid"] } } },
],
total: 3,
total_is_exact: false,
next_cursor: "cursor-1",
})
.mockResolvedValueOnce({
documents: [{ _id: { tenant_id: "tenant-b", order_id: "003" }, tenant_id: "tenant-b", order_id: "003" }],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(dataGrid.fullExportResult).toBeTypeOf("function");
const progress = vi.fn();
const result = await dataGrid.fullExportResult!(progress);
expect(backend.documentFindDocuments.mock.calls.slice(1).map((call) => [call[3], call[4], call[10]])).toEqual([
[0, 2, undefined],
[0, 2, "cursor-1"],
]);
expect(result?.rows).toHaveLength(3);
expect(result?.rows[0]).toContain('{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}');
expect(result?.rows[1]).toContain('{"$dbxDynamoDb":{"version":1,"type":"stringSet","value":["new","paid"]}}');
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 3, totalRows: 3 });
});
it("caps an otherwise unlimited DynamoDB export at 10,000 items", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 1000;
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockResolvedValueOnce({
documents: [{ _id: { tenant_id: "visible", order_id: "visible" } }],
total: 2,
total_is_exact: false,
next_cursor: "visible-cursor",
});
for (let pageIndex = 0; pageIndex < 10; pageIndex += 1) {
backend.documentFindDocuments.mockResolvedValueOnce({
documents: Array.from({ length: 1000 }, (_, rowIndex) => ({
_id: { tenant_id: `tenant-${pageIndex}`, order_id: String(rowIndex) },
tenant_id: `tenant-${pageIndex}`,
order_id: String(rowIndex),
})),
total: 1001,
total_is_exact: false,
next_cursor: `cursor-${pageIndex + 1}`,
});
}
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
const progress = vi.fn();
const result = await dataGrid.fullExportResult!(progress);
expect(result?.rows).toHaveLength(10_000);
expect(result?.truncated).toBe(true);
expect(result?.has_more).toBe(true);
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(11);
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 10_000, totalRows: null });
});
it("exports all matching MongoDB documents without changing the visible page", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 2;
@@ -22,6 +22,7 @@ const assetIcons: Record<string, string> = {
redis: "redis",
mongodb: "mongodb",
mongodb_legacy: "mongodb",
dynamodb: "dynamodb",
clickhouse: "clickhouse",
duckdb: "duckdb",
mariadb: "mariadb",
@@ -397,7 +397,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
connection: ["connection"],
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "consul-root", "mongo-db"],
schema: ["schema"],
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
table: ["table", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"],
view: ["view"],
};
@@ -679,7 +679,7 @@ const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
let sidebarCommentMeasureFrame = 0;
const sidebarTreeContentWidth = ref(0);
let sidebarTreeContentMeasureFrame = 0;
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]);
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
const sidebarStorageDisplayTypes = new Set<TreeNodeType>(["database", "table", "materialized_view"]);
function sidebarCommentLabel(node: TreeNode): string {
@@ -1365,6 +1365,8 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
await store.loadRedisDatabases(connId);
} else if (config.db_type === "mongodb") {
await store.loadMongoDatabases(connId);
} else if (config.db_type === "dynamodb") {
await store.loadDynamoDbTables(connId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
await store.loadElasticsearchIndices(connId);
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
@@ -752,6 +752,8 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadConsulRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await connectionStore.loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
// Expand: list indices (like other db types list databases).
await connectionStore.loadElasticsearchIndices(node.connectionId);
@@ -1317,6 +1319,18 @@ function openMongoTreeData(node: TreeNode) {
queryStore.openMongoBucket(node.connectionId, node.database, node.label);
return;
}
if (node.type === "dynamodb-table") {
const tab = queryStore.createTab(node.connectionId, node.database, `${node.database}.${node.label}`, "mongo");
queryStore.updateSql(tab, node.label);
queryStore.setTableMeta(tab, {
database: node.database,
tableName: node.label,
tableType: "DYNAMODB TABLE",
columns: [],
primaryKeys: [],
});
return;
}
if (node.type !== "mongo-collection") return;
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
queryStore.updateSql(tab, node.label);
@@ -3765,7 +3779,7 @@ const canOpenSqlFileExecution = computed(() => {
const canExportAllDatabases = computed(() => {
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
return !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
});
const canOpenDiagram = computed(() => {
@@ -4889,6 +4903,13 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "dynamodb-table") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties });
return true;
}
if (node.type === "mongo-collection") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
@@ -304,6 +304,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Archive, colorClass: "text-cyan-400" };
case "mongo-collection":
return { icon: Table, colorClass: "text-green-400" };
case "dynamodb-table":
return { icon: Table, colorClass: "text-amber-500" };
case "vector-collection":
return { icon: TableProperties, colorClass: "text-cyan-400" };
case "elasticsearch-index":
@@ -385,7 +387,7 @@ function treeNodeSecondaryValue(node: TreeNode): string | undefined {
function visibleLabel(node: TreeNode): string {
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
return withValidity(sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes));
}
return withValidity(displayLabel(node));
@@ -44,7 +44,7 @@ async function copy() {
<!-- card: 卡片类报错信息面板 -->
<div v-if="variant === 'card'" class="mx-3 my-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3 shadow-sm shrink-0 select-text flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 border-b border-destructive/20 pb-2">
<div class="flex items-center gap-2 font-semibold text-xs text-destructive">
<div data-native-clipboard class="flex items-center gap-2 font-semibold text-xs text-destructive">
<TriangleAlert class="h-4 w-4 text-destructive shrink-0" aria-hidden="true" />
<span>{{ displayTitle }}</span>
</div>
@@ -58,14 +58,14 @@ async function copy() {
</Button>
</div>
</div>
<div class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
<div data-native-clipboard class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
{{ message }}
</div>
</div>
<!-- banner: 紧凑内联横幅 -->
<div v-else-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
<span class="flex-1 min-w-0 break-all">{{ message }}</span>
<span data-native-clipboard class="flex-1 min-w-0 break-all">{{ message }}</span>
<button v-if="copyMode === 'label'" type="button" class="shrink-0 hover:underline" :aria-label="t('grid.copy')" @click.stop="copy">
{{ t("grid.copy") }}
</button>
@@ -78,7 +78,7 @@ async function copy() {
<!-- centered: 居中占满 -->
<div v-else class="flex-1 min-h-0 flex flex-col items-center justify-center gap-2 px-6 py-4 text-center">
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
<div class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
<div data-native-clipboard class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
<div class="text-sm font-medium">{{ displayTitle }}</div>
<div class="text-xs break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
</div>
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import { eventTargetAllowsNativeClipboard } from "@/lib/common/clipboard";
const mountedContainers: HTMLDivElement[] = [];
function findNativeClipboardRegion(container: HTMLElement, text: string): HTMLElement {
const region = Array.from(container.querySelectorAll<HTMLElement>("[data-native-clipboard]")).find((element) => element.textContent?.trim() === text);
if (!region) throw new Error(`Native clipboard region not found: ${text}`);
return region;
}
function selectRegion(region: HTMLElement) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(region);
selection?.removeAllRanges();
selection?.addRange(range);
}
async function mountCardBanner() {
const container = document.createElement("div");
mountedContainers.push(container);
document.body.append(container);
const app = createApp(ErrorBanner, {
variant: "card",
title: "Save failed",
message: "Conditional request failed",
});
app.use(
createI18n({
legacy: false,
locale: "en",
messages: { en: { grid: { copy: "Copy", dismiss: "Dismiss" } } },
missingWarn: false,
fallbackWarn: false,
}),
);
app.mount(container);
await nextTick();
return container;
}
afterEach(() => {
window.getSelection()?.removeAllRanges();
for (const container of mountedContainers.splice(0)) container.remove();
});
describe("ErrorBanner native clipboard selection", () => {
it.each(["Save failed", "Conditional request failed"])("preserves native copy for selected %s text", async (text) => {
const container = await mountCardBanner();
selectRegion(findNativeClipboardRegion(container, text));
expect(
eventTargetAllowsNativeClipboard({
key: "c",
metaKey: true,
target: container,
}),
).toBe(true);
});
it("leaves grid copy shortcuts active without a text selection", async () => {
const container = await mountCardBanner();
expect(
eventTargetAllowsNativeClipboard({
key: "c",
metaKey: true,
target: container,
}),
).toBe(false);
});
});
+24
View File
@@ -443,6 +443,13 @@ export default {
sqliteExtensionBrowse: "Select SQLite extension",
user: "User",
password: "Password",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Region",
dynamodbAccessKeyId: "Access Key ID",
dynamodbSecretAccessKey: "Secret Access Key",
dynamodbSessionToken: "Session Token",
dynamodbSessionTokenPlaceholder: "Optional",
dynamodbCredentialsRequired: "DynamoDB requires an Access Key ID and Secret Access Key",
savePassword: "Save password",
savePasswordHint: "The password is saved, so you won't need to enter it again.",
savePasswordSessionHint: "The password is kept only for this session and cleared when you quit the app.",
@@ -4510,6 +4517,23 @@ export default {
filterPlaceholder: "Filter...",
sortPlaceholder: "Sort...",
},
dynamodb: {
tableMetadataUnavailable: "DynamoDB table metadata is not loaded. Refresh and try again.",
keyRequired: "DynamoDB items require the key attribute {key}",
baseTable: "Base table",
filter: "filter",
sortKey: "sort key",
items: "{count} Items",
index: "DynamoDB index",
keySummary: "Partition key: {partitionKey}; sort key: {sortKey}",
none: "none",
sortKeyOnly: "DynamoDB Query can sort only by the current index sort key: {key}",
pageCursorUnavailable: "The DynamoDB page cursor is unavailable. Reload from the first page.",
repeatedCursor: "DynamoDB returned a repeated page cursor. Export was stopped.",
partialProjectionReadOnly: "This {projection} index does not contain the complete item. Editing is disabled to prevent unprojected attributes from being overwritten.",
exportLimitReached: "DynamoDB export stopped at {count} items to limit memory and read usage. Set an export row limit to choose a different boundary.",
documentDetails: "DynamoDB table: {table}\nItem key: {id}",
},
vector: {
collectionFallback: "collection",
productCollection: "{product} collection",
+24
View File
@@ -445,6 +445,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Seleccionar extensión de SQLite",
user: "Usuario",
password: "Contraseña",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Región",
dynamodbAccessKeyId: "ID de clave de acceso",
dynamodbSecretAccessKey: "Clave de acceso secreta",
dynamodbSessionToken: "Token de sesión",
dynamodbSessionTokenPlaceholder: "Opcional",
dynamodbCredentialsRequired: "DynamoDB requiere un ID de clave de acceso y una clave de acceso secreta",
savePassword: "Guardar contraseña",
savePasswordHint: "La contraseña se guardará para que no tengas que volver a introducirla.",
savePasswordSessionHint: "La contraseña se conserva solo durante esta sesión y se borra al salir de la aplicación.",
@@ -4352,6 +4359,23 @@ export default withEnglishFallback({
indexOtherOptions: "Otras opciones",
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
},
dynamodb: {
tableMetadataUnavailable: "Los metadatos de la tabla DynamoDB no se han cargado. Actualice e inténtelo de nuevo.",
keyRequired: "Los elementos de DynamoDB requieren el atributo de clave {key}",
baseTable: "Tabla base",
filter: "filtro",
sortKey: "clave de ordenación",
items: "{count} elementos",
index: "Índice de DynamoDB",
keySummary: "Clave de partición: {partitionKey}; clave de ordenación: {sortKey}",
none: "ninguna",
sortKeyOnly: "DynamoDB Query solo puede ordenar por la clave de ordenación del índice actual: {key}",
pageCursorUnavailable: "El cursor de página de DynamoDB no está disponible. Recargue desde la primera página.",
repeatedCursor: "DynamoDB devolvió un cursor de página repetido. Se detuvo la exportación.",
partialProjectionReadOnly: "Este índice {projection} no contiene el elemento completo. La edición está deshabilitada para evitar sobrescribir atributos no proyectados.",
exportLimitReached: "La exportación de DynamoDB se detuvo en {count} elementos para limitar el uso de memoria y lecturas. Configure un límite de filas de exportación para elegir otro alcance.",
documentDetails: "Tabla de DynamoDB: {table}\nClave del elemento: {id}",
},
vector: {
collectionFallback: "colección",
productCollection: "Colección de {product}",
+24
View File
@@ -444,6 +444,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Seleziona estensione SQLite",
user: "Utente",
password: "Password",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Regione",
dynamodbAccessKeyId: "ID chiave di accesso",
dynamodbSecretAccessKey: "Chiave di accesso segreta",
dynamodbSessionToken: "Token di sessione",
dynamodbSessionTokenPlaceholder: "Opzionale",
dynamodbCredentialsRequired: "DynamoDB richiede un ID chiave di accesso e una chiave di accesso segreta",
savePassword: "Salva password",
savePasswordHint: "La password verrà salvata, quindi non dovrai inserirla di nuovo.",
savePasswordSessionHint: "La password viene conservata solo per questa sessione e cancellata alla chiusura dell'app.",
@@ -4350,6 +4357,23 @@ export default withEnglishFallback({
indexOtherOptions: "Altre opzioni",
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
},
dynamodb: {
tableMetadataUnavailable: "I metadati della tabella DynamoDB non sono caricati. Aggiorna e riprova.",
keyRequired: "Gli elementi DynamoDB richiedono l'attributo chiave {key}",
baseTable: "Tabella base",
filter: "filtro",
sortKey: "chiave di ordinamento",
items: "{count} elementi",
index: "Indice DynamoDB",
keySummary: "Chiave di partizione: {partitionKey}; chiave di ordinamento: {sortKey}",
none: "nessuna",
sortKeyOnly: "DynamoDB Query può ordinare solo per la chiave di ordinamento dell'indice corrente: {key}",
pageCursorUnavailable: "Il cursore di pagina DynamoDB non è disponibile. Ricarica dalla prima pagina.",
repeatedCursor: "DynamoDB ha restituito un cursore di pagina ripetuto. L'esportazione è stata interrotta.",
partialProjectionReadOnly: "Questo indice {projection} non contiene l'elemento completo. La modifica è disabilitata per evitare di sovrascrivere attributi non proiettati.",
exportLimitReached: "L'esportazione DynamoDB si è fermata a {count} elementi per limitare memoria e letture. Imposta un limite di righe per scegliere un intervallo diverso.",
documentDetails: "Tabella DynamoDB: {table}\nChiave elemento: {id}",
},
vector: {
collectionFallback: "collezione",
productCollection: "Collezione {product}",
+24
View File
@@ -444,6 +444,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "SQLite拡張を選択",
user: "ユーザー",
password: "パスワード",
dynamodbEndpoint: "エンドポイント",
dynamodbRegion: "リージョン",
dynamodbAccessKeyId: "アクセスキー ID",
dynamodbSecretAccessKey: "シークレットアクセスキー",
dynamodbSessionToken: "セッショントークン",
dynamodbSessionTokenPlaceholder: "任意",
dynamodbCredentialsRequired: "DynamoDB にはアクセスキー ID とシークレットアクセスキーが必要です",
savePassword: "パスワードを保存",
savePasswordHint: "パスワードを保存し、次回以降の接続では再入力を不要にします。",
savePasswordSessionHint: "パスワードは今回の実行中のみ保持され、アプリ終了時に消去されます。",
@@ -4380,6 +4387,23 @@ export default withEnglishFallback({
indexOtherOptions: "その他のオプション",
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
},
dynamodb: {
tableMetadataUnavailable: "DynamoDB テーブルのメタデータが読み込まれていません。更新して再試行してください。",
keyRequired: "DynamoDB 項目にはキー属性 {key} が必要です",
baseTable: "ベーステーブル",
filter: "フィルター",
sortKey: "ソートキー",
items: "{count} 件",
index: "DynamoDB インデックス",
keySummary: "パーティションキー: {partitionKey}; ソートキー: {sortKey}",
none: "なし",
sortKeyOnly: "DynamoDB Query は現在のインデックスのソートキー {key} でのみ並べ替えできます",
pageCursorUnavailable: "DynamoDB のページカーソルを利用できません。最初のページから再読み込みしてください。",
repeatedCursor: "DynamoDB が同じページカーソルを返したため、エクスポートを停止しました。",
partialProjectionReadOnly: "この {projection} インデックスには項目全体が含まれないため、未投影属性の上書きを防ぐ目的で編集を無効にしました。",
exportLimitReached: "メモリと読み取り使用量を制限するため、DynamoDB のエクスポートは {count} 件で停止しました。別の範囲にするにはエクスポート行数上限を設定してください。",
documentDetails: "DynamoDB テーブル: {table}\n項目キー: {id}",
},
vector: {
collectionFallback: "コレクション",
productCollection: "{product} コレクション",
+24
View File
@@ -437,6 +437,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "SQLite 확장 선택",
user: "사용자",
password: "비밀번호",
dynamodbEndpoint: "엔드포인트",
dynamodbRegion: "리전",
dynamodbAccessKeyId: "액세스 키 ID",
dynamodbSecretAccessKey: "비밀 액세스 키",
dynamodbSessionToken: "세션 토큰",
dynamodbSessionTokenPlaceholder: "선택",
dynamodbCredentialsRequired: "DynamoDB에는 액세스 키 ID와 비밀 액세스 키가 필요합니다",
savePassword: "비밀번호 저장",
savePasswordHint: "비밀번호를 저장하여 다음 연결부터 다시 입력하지 않아도 됩니다.",
savePasswordSessionHint: "비밀번호는 이번 실행 동안만 유지되며 앱을 종료하면 삭제됩니다.",
@@ -3995,6 +4002,23 @@ export default withEnglishFallback({
indexOtherOptions: "기타 옵션",
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
},
dynamodb: {
tableMetadataUnavailable: "DynamoDB 테이블 메타데이터가 로드되지 않았습니다. 새로 고친 후 다시 시도하세요.",
keyRequired: "DynamoDB 항목에는 키 속성 {key}이(가) 필요합니다",
baseTable: "기본 테이블",
filter: "필터",
sortKey: "정렬 키",
items: "항목 {count}개",
index: "DynamoDB 인덱스",
keySummary: "파티션 키: {partitionKey}; 정렬 키: {sortKey}",
none: "없음",
sortKeyOnly: "DynamoDB Query는 현재 인덱스의 정렬 키 {key}로만 정렬할 수 있습니다",
pageCursorUnavailable: "DynamoDB 페이지 커서를 사용할 수 없습니다. 첫 페이지부터 다시 불러오세요.",
repeatedCursor: "DynamoDB가 중복 페이지 커서를 반환하여 내보내기를 중지했습니다.",
partialProjectionReadOnly: "이 {projection} 인덱스에는 전체 항목이 포함되지 않으므로 투영되지 않은 속성이 덮어쓰이지 않도록 편집을 비활성화했습니다.",
exportLimitReached: "메모리와 읽기 사용량을 제한하기 위해 DynamoDB 내보내기가 {count}개 항목에서 중지되었습니다. 다른 범위를 사용하려면 내보내기 행 제한을 설정하세요.",
documentDetails: "DynamoDB 테이블: {table}\n항목 키: {id}",
},
vector: {
collectionFallback: "컬렉션",
productCollection: "{product} 컬렉션",
+24
View File
@@ -445,6 +445,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Selecionar extensão SQLite",
user: "Usuário",
password: "Senha",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Região",
dynamodbAccessKeyId: "ID da chave de acesso",
dynamodbSecretAccessKey: "Chave de acesso secreta",
dynamodbSessionToken: "Token de sessão",
dynamodbSessionTokenPlaceholder: "Opcional",
dynamodbCredentialsRequired: "O DynamoDB requer um ID de chave de acesso e uma chave de acesso secreta",
savePassword: "Salvar senha",
savePasswordHint: "A senha será salva para que você não precise digitá-la novamente.",
savePasswordSessionHint: "A senha é mantida apenas nesta sessão e apagada ao sair do aplicativo.",
@@ -4352,6 +4359,23 @@ export default withEnglishFallback({
indexOtherOptions: "Outras opções",
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
},
dynamodb: {
tableMetadataUnavailable: "Os metadados da tabela DynamoDB não foram carregados. Atualize e tente novamente.",
keyRequired: "Os itens do DynamoDB exigem o atributo de chave {key}",
baseTable: "Tabela base",
filter: "filtro",
sortKey: "chave de ordenação",
items: "{count} itens",
index: "Índice do DynamoDB",
keySummary: "Chave de partição: {partitionKey}; chave de ordenação: {sortKey}",
none: "nenhuma",
sortKeyOnly: "DynamoDB Query só pode ordenar pela chave de ordenação do índice atual: {key}",
pageCursorUnavailable: "O cursor de página do DynamoDB não está disponível. Recarregue a partir da primeira página.",
repeatedCursor: "O DynamoDB retornou um cursor de página repetido. A exportação foi interrompida.",
partialProjectionReadOnly: "Este índice {projection} não contém o item completo. A edição foi desativada para evitar a substituição de atributos não projetados.",
exportLimitReached: "A exportação do DynamoDB parou em {count} itens para limitar o uso de memória e leituras. Defina um limite de linhas para escolher outro intervalo.",
documentDetails: "Tabela do DynamoDB: {table}\nChave do item: {id}",
},
vector: {
collectionFallback: "coleção",
productCollection: "coleção {product}",
+24
View File
@@ -368,6 +368,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "选择 SQLite 扩展库",
user: "用户名",
password: "密码",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "区域",
dynamodbAccessKeyId: "Access Key ID",
dynamodbSecretAccessKey: "Secret Access Key",
dynamodbSessionToken: "Session Token",
dynamodbSessionTokenPlaceholder: "可选",
dynamodbCredentialsRequired: "DynamoDB 需要填写 Access Key ID 和 Secret Access Key",
savePassword: "保存密码",
savePasswordHint: "密码将被保存,后续连接无需再次输入。",
savePasswordSessionHint: "密码仅在本次运行期间保留,退出应用后清除。",
@@ -4498,6 +4505,23 @@ export default withEnglishFallback({
filterPlaceholder: "过滤条件...",
sortPlaceholder: "排序条件...",
},
dynamodb: {
tableMetadataUnavailable: "尚未加载 DynamoDB 表结构,请刷新后重试",
keyRequired: "DynamoDB 项目必须填写键字段 {key}",
baseTable: "主表",
filter: "筛选",
sortKey: "排序键",
items: "{count} 个项目",
index: "DynamoDB 索引",
keySummary: "分区键:{partitionKey};排序键:{sortKey}",
none: "无",
sortKeyOnly: "DynamoDB Query 只能按当前索引的排序键 {key} 排序",
pageCursorUnavailable: "当前 DynamoDB 页游标不可用,请从第一页重新加载",
repeatedCursor: "DynamoDB 返回了重复的分页游标,已停止导出",
partialProjectionReadOnly: "当前 {projection} 索引不包含完整项目,为避免覆盖未投影属性,已禁用编辑",
exportLimitReached: "为限制内存和读取用量,DynamoDB 导出已在 {count} 条停止;可设置导出行数上限来调整范围",
documentDetails: "DynamoDB 表:{table}\n项目键:{id}",
},
vector: {
collectionFallback: "集合",
productCollection: "{product} 集合",
+24
View File
@@ -445,6 +445,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "選擇 SQLite 擴充庫",
user: "使用者名稱",
password: "密碼",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "區域",
dynamodbAccessKeyId: "Access Key ID",
dynamodbSecretAccessKey: "Secret Access Key",
dynamodbSessionToken: "Session Token",
dynamodbSessionTokenPlaceholder: "選填",
dynamodbCredentialsRequired: "DynamoDB 需要填寫 Access Key ID 和 Secret Access Key",
savePassword: "儲存密碼",
savePasswordHint: "密碼將被儲存,後續連線無需再次輸入。",
savePasswordSessionHint: "密碼僅在本次執行期間保留,退出應用程式後清除。",
@@ -3694,6 +3701,23 @@ export default withEnglishFallback({
invalidNativeJson: "原生 JSON 格式無效:{error}",
nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。",
},
dynamodb: {
tableMetadataUnavailable: "尚未載入 DynamoDB 表結構,請重新整理後再試",
keyRequired: "DynamoDB 項目必須填寫鍵欄位 {key}",
baseTable: "主表",
filter: "篩選",
sortKey: "排序鍵",
items: "{count} 個項目",
index: "DynamoDB 索引",
keySummary: "分區鍵:{partitionKey};排序鍵:{sortKey}",
none: "無",
sortKeyOnly: "DynamoDB Query 只能依目前索引的排序鍵 {key} 排序",
pageCursorUnavailable: "目前 DynamoDB 頁面游標不可用,請從第一頁重新載入",
repeatedCursor: "DynamoDB 傳回重複的分頁游標,已停止匯出",
partialProjectionReadOnly: "目前 {projection} 索引不包含完整項目,為避免覆寫未投影屬性,已停用編輯",
exportLimitReached: "為限制記憶體和讀取用量,DynamoDB 匯出已在 {count} 筆停止;可設定匯出列數上限來調整範圍",
documentDetails: "DynamoDB 表:{table}\n項目鍵:{id}",
},
vector: {
collectionFallback: "集合",
productCollection: "{product} 集合",
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { formatDocumentStoreIdLabel, serializeDocumentStoreId } from "@/lib/app/documentJsonValues";
import { buildDocumentFilterCondition, currentDocumentFilterJson, documentStoreProviderFor } from "@/lib/app/documentStoreProvider";
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument } from "@/lib/app/documentStoreSave";
describe("DynamoDB document store provider", () => {
it("uses a dedicated provider and describes a cursor-backed read", () => {
const provider = documentStoreProviderFor("dynamodb");
expect(provider.kind).toBe("dynamodb");
expect(
provider.queryPreview({
collection: "orders",
filterJson: '{"tenant_id":"tenant-a","$index":"by_status"}',
sortJson: '{"created_at":-1}',
skip: 100,
limit: 50,
}),
).toContain('DBX DYNAMODB QUERY / SCAN\ntable: "orders"\nlimit: 50');
});
it("keeps composite key identity and large-number wrappers intact", () => {
const id = {
tenant_id: "tenant-a",
sequence: { $dbxDynamoDb: { version: 1, type: "number", value: "9007199254740993" } },
};
expect(serializeDocumentStoreId(id, "dynamodb")).toBe('{"tenant_id":"tenant-a","sequence":{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}}');
expect(formatDocumentStoreIdLabel(id, "dynamodb")).toBe('{"tenant_id":"tenant-a","sequence":{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}}');
});
it("builds DynamoDB-compatible structured filters", () => {
expect(currentDocumentFilterJson("", { amount: { $gte: 50 } }, "dynamodb")).toBe('{"amount":{"$gte":50}}');
expect(buildDocumentFilterCondition({ id: "contains", fieldName: "customer", mode: "like", rawValue: "Acme", conjunction: "AND" }, { kind: "dynamodb" })).toEqual({ customer: { $contains: "Acme" } });
expect(buildDocumentFilterCondition({ id: "not-contains", fieldName: "customer", mode: "not-like", rawValue: "Test", conjunction: "AND" }, { kind: "dynamodb" })).toEqual({ customer: { $notContains: "Test" } });
});
it("uses conditional insert semantics even when a new item has an explicit identity", async () => {
const insert = vi.fn(async () => "created");
const update = vi.fn(async () => 1);
await insertDocumentStoreDocument({
kind: "dynamodb",
explicitId: '{"tenant_id":"tenant-a","order_id":"order-1001"}',
document: { tenant_id: "tenant-a", order_id: "order-1001", amount: 42 },
apis: { insert, update },
});
expect(insert).toHaveBeenCalledWith('{"tenant_id":"tenant-a","order_id":"order-1001","amount":42}', undefined);
expect(update).not.toHaveBeenCalled();
});
it("delegates a DynamoDB rekey to one atomic backend update", async () => {
const calls: string[] = [];
const insert = vi.fn(async () => {
calls.push("insert");
return "created";
});
const update = vi.fn(async () => {
calls.push("update");
return 1;
});
const remove = vi.fn(async () => {
calls.push("delete");
return 1;
});
await applyDocumentStoreIdentityPlan({
kind: "dynamodb",
plan: {
action: "rekey",
writeId: '{"tenant_id":"tenant-a","order_id":"order-1002"}',
deleteId: '{"tenant_id":"tenant-a","order_id":"order-1001"}',
},
document: { tenant_id: "tenant-a", order_id: "order-1002", amount: 42 },
apis: { insert, update, delete: remove },
});
expect(calls).toEqual(["update"]);
expect(update).toHaveBeenCalledWith('{"tenant_id":"tenant-a","order_id":"order-1001"}', '{"tenant_id":"tenant-a","order_id":"order-1002","amount":42}', undefined);
expect(insert).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { connectionDisplayUrlScheme, connectionUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import { connectionCanChooseVisibleDatabases } from "@/lib/connection/connectionVisibleDatabases";
import type { ConnectionConfig } from "@/types/database";
describe("DynamoDB connection presentation", () => {
it("uses a DynamoDB endpoint URL as the connection placeholder", () => {
expect(connectionUrlPlaceholder("dynamodb")).toBe("https://dynamodb.us-east-1.amazonaws.com");
expect(connectionDisplayUrlScheme({ db_type: "dynamodb", ssl: true })).toBe("https");
expect(connectionDisplayUrlScheme({ db_type: "dynamodb", ssl: false })).toBe("http");
});
it("parses an HTTPS endpoint while DynamoDB is selected", () => {
expect(parseConnectionUrl("https://dynamodb.eu-west-1.amazonaws.com", "dynamodb")).toMatchObject({
dbType: "dynamodb",
driverProfile: "dynamodb",
host: "dynamodb.eu-west-1.amazonaws.com",
port: 443,
database: "eu-west-1",
ssl: true,
});
});
it("preserves the selected region for a custom endpoint URL", () => {
const parsed = parseConnectionUrl("http://127.0.0.1:8000", "dynamodb");
const config = applyParsedConnectionUrl(
{
db_type: "dynamodb",
driver_profile: "dynamodb",
name: "DynamoDB Local",
host: "127.0.0.1",
port: 8000,
username: "local",
password: "local",
database: "ap-southeast-1",
} as Omit<ConnectionConfig, "id">,
parsed,
);
expect(config.database).toBe("ap-southeast-1");
expect(config.username).toBe("local");
expect(config.password).toBe("local");
});
it("does not offer database visibility selection", () => {
expect(connectionCanChooseVisibleDatabases({ db_type: "dynamodb" })).toBe(false);
});
});
@@ -5,8 +5,10 @@ describe("databaseDriverManifest", () => {
it("uses agent cursor only for agent or external runtimes", () => {
expect(databaseRuntimeMode("gaussdb")).toBe("native");
expect(databaseRuntimeMode("meilisearch")).toBe("native");
expect(databaseRuntimeMode("dynamodb")).toBe("native");
expect(usesAgentCursorForQuery("gaussdb")).toBe(false);
expect(usesAgentCursorForQuery("meilisearch")).toBe(false);
expect(usesAgentCursorForQuery("dynamodb")).toBe(false);
expect(usesAgentCursorForQuery("mysql")).toBe(false);
expect(usesAgentCursorForQuery("jdbc")).toBe(true);
expect(usesAgentCursorForQuery("prestosql")).toBe(true);
@@ -28,6 +28,7 @@ describe("sidebar tree item layout", () => {
});
it("renders navigation leaf actions without expanders", () => {
expect(canTreeNodeShowExpander({ type: "dynamodb-table", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "nacos-namespace", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "nacos-access-control", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
@@ -12,6 +12,11 @@ import {
} from "@/lib/sidebar/treeNodeClick";
describe("treeNodeClick", () => {
it("opens DynamoDB tables in the document browser on single or double activation", () => {
expect(treeNodeRowAction("dynamodb-table", false, "single", "dynamodb")).toBe("toggle");
expect(treeNodeRowDoubleClickAction("dynamodb-table", false, "double", false, "dynamodb")).toBe("toggle");
});
it("opens synonym nodes as synonym source", () => {
expect(objectSourceKindForTreeNode("synonym")).toBe("SYNONYM");
expect(treeNodeRowAction("synonym", false)).toBe("open-source");
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { classifySqlRisk } from "@/lib/sql/sqlRisk";
describe("DynamoDB statement risk", () => {
it("treats generated scan statements as reads", () => {
expect(classifySqlRisk('DBX DYNAMODB SCAN\ntable: "orders"\nlimit: 1000', { dialect: "dynamodb" }).risk).toBe("read");
expect(classifySqlRisk('DBX DYNAMODB QUERY / SCAN\ntable: "orders"\nfilter:\n{"status":"SHIPPED"}', { dialect: "dynamodb" }).risk).toBe("read");
});
it("treats generated item changes as writes", () => {
for (const operation of ["INSERT ITEM", "PUT ITEM", "DELETE ITEM"]) {
expect(classifySqlRisk(`DBX DYNAMODB ${operation}\ntable: "orders"`, { dialect: "dynamodb" }).risk).toBe("write");
}
});
it("keeps unknown DynamoDB operations unsafe", () => {
expect(classifySqlRisk('DBX DYNAMODB UNKNOWN\ntable: "orders"', { dialect: "dynamodb" }).risk).toBe("unknown");
});
});
@@ -42,9 +42,16 @@ export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
if (kind === "elasticsearch") return String(value);
if (kind === "meilisearch") return typeof value === "string" ? `__dbx_meilisearch_string_id__${JSON.stringify(value)}` : String(value);
if (kind === "dynamodb") return stringifyJsonPreservingLargeNumbers(value);
return serializeMongoDocumentId(value);
}
export function formatDocumentStoreIdLabel(value: unknown, kind: DocumentStoreKind): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return stringifyDocumentStoreValue(value, kind);
return String(value);
}
/**
* Parse a whole document JSON payload for MongoDB / Elasticsearch editors.
* Accepts standard JSON and Extended JSON objects; rejects non-object roots.
@@ -4,7 +4,7 @@ import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "elasticsearch" | "meilisearch";
export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
@@ -150,6 +150,23 @@ const meilisearchDocumentProvider: DocumentStoreProvider = {
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
const dynamodbDocumentProvider: DocumentStoreProvider = {
kind: "dynamodb",
filterInputLabel: "filter",
sortInputLabel: "sort key",
documentsLabel: ({ total, totalIsExact, t }) => `${totalIsExact ? "" : "≥"}${t("dynamodb.items", { count: total })}`,
queryPreview: ({ collection, filterJson, sortJson, limit }) => {
const filter = documentStorePreviewJson(filterJson);
const sort = documentStorePreviewJson(sortJson);
const operation = filterJson?.includes('"$index"') || (filterJson && filterJson !== "{}") ? "QUERY / SCAN" : "SCAN";
const lines = [`DBX DYNAMODB ${operation}`, `table: ${JSON.stringify(collection)}`, `limit: ${limit}`];
if (filter) lines.push("filter:", filter);
if (sort) lines.push("sort:", sort);
return lines.join("\n");
},
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
function documentStorePreviewJson(value?: string): string | null {
const trimmed = value?.trim();
if (!trimmed || trimmed === "{}") return null;
@@ -161,6 +178,7 @@ function documentStorePreviewJson(value?: string): string | null {
}
export function documentStoreProviderFor(databaseType?: DatabaseType): DocumentStoreProvider {
if (databaseType === "dynamodb") return dynamodbDocumentProvider;
if (isElasticsearchCompatibleDatabaseType(databaseType)) return elasticsearchDocumentProvider;
if (isMeilisearchDatabaseType(databaseType)) return meilisearchDocumentProvider;
return mongoDocumentProvider;
@@ -482,8 +500,10 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
case "not-equals":
return { [rule.fieldName]: { $ne: value } };
case "like":
if (options.kind === "dynamodb") return { [rule.fieldName]: { $contains: value } };
return { [rule.fieldName]: { $regex: escapeRegexLiteral(textValue), $options: "i" } };
case "not-like":
if (options.kind === "dynamodb") return { [rule.fieldName]: { $notContains: value } };
return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } };
case "greater-than":
return { [rule.fieldName]: { $gt: value } };
@@ -670,6 +690,7 @@ type DocumentQueryInputNormalizer = (input: string) => string | null;
const documentQueryInputNormalizers: Record<DocumentStoreKind, DocumentQueryInputNormalizer> = {
mongodb: normalizeJsonArgument,
dynamodb: quoteUnquotedObjectKeys,
elasticsearch: quoteUnquotedObjectKeys,
meilisearch: quoteUnquotedObjectKeys,
};
+16 -3
View File
@@ -37,12 +37,26 @@ export async function writeDocumentStoreDocument(options: { kind: DocumentStoreK
/**
* Apply an identity plan for an existing document save.
* Rekey always writes first, then deletes the old identity — a failed write never deletes.
* DynamoDB rekeys atomically in the backend. Other stores write first, then
* delete the old identity so a failed write never deletes the source.
* Plan coordinates are assumed distinct for rekey (same identity is always `replace`).
*/
export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentStoreKind; plan: DocumentStoreIdentityPlan; document: Record<string, unknown>; apis: DocumentStoreWriteApis }): Promise<void> {
const { kind, plan, document, apis } = options;
// DynamoDB handles both same-key replacement and key migration in the
// backend. Rekey uses one TransactWriteItems request, keyed by the old id.
if (kind === "dynamodb") {
await writeDocumentStoreDocument({
kind,
op: "put",
id: plan.action === "rekey" ? plan.deleteId : plan.writeId,
document,
apis,
});
return;
}
if (plan.action === "replace") {
await writeDocumentStoreDocument({
kind,
@@ -55,7 +69,6 @@ export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentSt
return;
}
// Path-identity stores write under the new id first; Mongo inserts a new document.
if (kind !== "mongodb") {
await writeDocumentStoreDocument({
kind,
@@ -81,7 +94,7 @@ export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentSt
/** Insert a new document (optional explicit path identity uses put). */
export async function insertDocumentStoreDocument(options: { kind: DocumentStoreKind; document: Record<string, unknown>; explicitId?: string | null; routing?: string; apis: Pick<DocumentStoreWriteApis, "insert" | "update"> }): Promise<void> {
const { kind, document, explicitId, routing, apis } = options;
if (kind !== "mongodb" && explicitId) {
if (kind !== "mongodb" && kind !== "dynamodb" && explicitId) {
await writeDocumentStoreDocument({
kind,
op: "put",
+5
View File
@@ -698,6 +698,8 @@ export const mongoDropCollection = forward("mongoDropCollection");
export const mongoRenameCollection = forward("mongoRenameCollection");
export const mongoCloneCollection = forward("mongoCloneCollection");
export const documentFindDocuments = forward("documentFindDocuments");
export const documentCountDocuments = forward("documentCountDocuments");
export const dynamodbDescribeTable = forward("dynamodbDescribeTable");
export const elasticsearchCountDocuments = forward("elasticsearchCountDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
export const mongoParseShellCommand = forward("mongoParseShellCommand");
@@ -856,6 +858,9 @@ export type {
EtcdPreflightResponse,
EtcdDangerousApproval,
DocumentQueryResult,
DynamoDbKeyInfo,
DynamoDbIndexInfo,
DynamoDbTableDescription,
MongoDocumentResult,
HistoryEntry,
HistoryConnectionFilter,
+16 -1
View File
@@ -98,6 +98,7 @@ import type {
EtcdWatchPollResponse,
EtcdLeaseListResponse,
DocumentQueryResult,
DynamoDbTableDescription,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoCloneCollectionResult,
@@ -3561,7 +3562,7 @@ export async function mongoFindOne(connectionId: string, database: string, colle
});
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string): Promise<DocumentQueryResult> {
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string, cursor?: string): Promise<DocumentQueryResult> {
return post("/api/document-store/find-documents", {
connectionId,
database,
@@ -3572,10 +3573,24 @@ export async function documentFindDocuments(connectionId: string, database: stri
projection,
sort,
collation,
cursor,
executionId,
});
}
export async function documentCountDocuments(connectionId: string, collection: string, filter?: string, executionId?: string): Promise<number> {
return post("/api/document-store/count-documents", {
connectionId,
collection,
filter,
executionId,
});
}
export async function dynamodbDescribeTable(connectionId: string, table: string): Promise<DynamoDbTableDescription> {
return post("/api/document-store/dynamodb-describe-table", { connectionId, table });
}
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise<number> {
return post("/api/document-store/elasticsearch-count-documents", {
connectionId,
+40 -1
View File
@@ -3286,6 +3286,31 @@ export interface DocumentQueryResult {
extended_documents?: any[];
total: number;
total_is_exact?: boolean;
next_cursor?: string;
}
export interface DynamoDbKeyInfo {
name: string;
attributeType: "S" | "N" | "B" | string;
}
export interface DynamoDbIndexInfo {
name: string;
kind: "global" | "local" | string;
partitionKey: DynamoDbKeyInfo;
sortKey?: DynamoDbKeyInfo;
projectionType: "ALL" | "KEYS_ONLY" | "INCLUDE" | string;
nonKeyAttributes: string[];
}
export interface DynamoDbTableDescription {
name: string;
status: string;
itemCount: number;
sizeBytes: number;
partitionKey: DynamoDbKeyInfo;
sortKey?: DynamoDbKeyInfo;
indexes: DynamoDbIndexInfo[];
}
// Kept for callers that are specifically using MongoDB APIs.
@@ -3448,7 +3473,7 @@ export async function mongoParseShellCommand(source: string): Promise<MongoComma
return normalizeRustMongoCommand(raw);
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string): Promise<DocumentQueryResult> {
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string, cursor?: string): Promise<DocumentQueryResult> {
return invoke("document_find_documents", {
connectionId,
database,
@@ -3459,10 +3484,24 @@ export async function documentFindDocuments(connectionId: string, database: stri
projection,
sort,
collation,
cursor,
executionId,
});
}
export async function documentCountDocuments(connectionId: string, collection: string, filter?: string, executionId?: string): Promise<number> {
return invoke("document_count_documents", {
connectionId,
collection,
filter,
executionId,
});
}
export async function dynamodbDescribeTable(connectionId: string, table: string): Promise<DynamoDbTableDescription> {
return invoke("dynamodb_describe_table", { connectionId, table });
}
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise<number> {
return invoke("elasticsearch_count_documents", {
connectionId,
@@ -136,6 +136,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
case "chromadb":
case "rqlite":
case "turso":
case "dynamodb":
case "mq":
case "consul":
return connection.ssl ? "https" : "http";
@@ -197,6 +198,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
case "mongodb":
return "mongodb://user:password@host:port/database";
case "dynamodb":
return "https://dynamodb.us-east-1.amazonaws.com";
case "clickhouse":
return "clickhouse://user:password@host:port/database";
@@ -49,6 +49,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
zookeeper: { type: "zookeeper", profile: "zookeeper", label: "Apache ZooKeeper", defaultPort: 2181 },
mongodb: { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
"mongodb+srv": { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
dynamodb: { type: "dynamodb", profile: "dynamodb", label: "Amazon DynamoDB", defaultPort: 443 },
clickhouse: { type: "clickhouse", profile: "clickhouse", label: "ClickHouse", defaultPort: 8123 },
sqlserver: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
mssql: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
@@ -83,6 +84,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
clickhouse: SCHEME_PROFILES.clickhouse,
dynamodb: SCHEME_PROFILES.dynamodb,
elasticsearch: SCHEME_PROFILES.elasticsearch,
easysearch: SCHEME_PROFILES.easysearch,
meilisearch: SCHEME_PROFILES.meilisearch,
@@ -190,6 +192,10 @@ function databaseFromPath(pathname: string): string | undefined {
return decodeUrlPart(value.split("/")[0]);
}
function dynamodbRegionFromHost(hostname: string): string | undefined {
return hostname.toLowerCase().match(/^dynamodb(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|api\.aws)$/)?.[1];
}
function parseZooKeeperUrl(source: string): ParsedConnectionUrl | null {
const match = source.match(/^zookeeper:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?$/i);
if (!match) return null;
@@ -665,7 +671,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}),
username: mysqlCredentials?.username ?? decodeUrlPart(parsed.username),
password: mysqlCredentials?.password ?? decodeUrlPart(parsed.password),
database: profile.type === "victoriametrics" ? "metrics" : isMeilisearch ? undefined : databaseFromPath(parsed.pathname),
database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : databaseFromPath(parsed.pathname),
urlParams: effectiveUrlParams,
ssl: scheme === "rediss" || scheme === "https" || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)),
...(profile.type === "victoriametrics" ? { apiPath: parsed.pathname.replace(/\/+$/, "") } : {}),
@@ -759,7 +765,7 @@ export function applyParsedConnectionUrl(config: Omit<ConnectionConfig, "id">, p
name: parsed.name?.trim() || config.name,
username: applyParsedUsername(config, parsed),
password: applyParsedPassword(config, parsed),
database: parsed.database,
database: parsed.dbType === "dynamodb" ? parsed.database || config.database || "us-east-1" : parsed.database,
url_params: parsed.urlParams,
ssl: parsed.ssl,
connection_string: parsed.connectionString,
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
// non-database services expose their own root objects rather than database namespaces.
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
@@ -27,6 +27,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
clickhouse: { connection: "database" },
sqlserver: { connection: "database", database: "schema" },
mongodb: { connection: "special" },
dynamodb: { deferred: "table creation requires dedicated DynamoDB key and capacity options" },
oracle: { deferred: "Oracle schemas are users; database creation is not a normal connected DDL action" },
elasticsearch: { deferred: "index creation is not modeled as database creation" },
easysearch: { deferred: "index creation is not modeled as database creation" },
@@ -27,6 +27,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
clickhouse: { deferred: "database property editing not verified for first pass" },
sqlserver: { deferred: "database options are broad administrative settings and need a dedicated editor" },
mongodb: { deferred: "database options are not modeled as SQL database properties" },
dynamodb: { deferred: "table settings require a dedicated DynamoDB workflow" },
oracle: { deferred: "Oracle database properties are instance/user/tablespace administration" },
elasticsearch: { deferred: "index settings are not database properties" },
easysearch: { deferred: "index settings are not database properties" },
@@ -27,6 +27,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"consul-overview",
"mongo-gridfs",
"mongo-bucket",
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"user-admin",
@@ -38,7 +39,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"extension",
]);
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"]);
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
const emptyContainerTypes: Set<TreeNodeType> = new Set(["saved-sql-root", "saved-sql-folder", "type"]);
@@ -58,12 +59,13 @@ const pinnableTypes: Set<TreeNodeType> = new Set([
"mongo-gridfs",
"mongo-bucket",
"mongo-collection",
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"nacos-namespace",
]);
const commentTypes: Set<TreeNodeType> = new Set(["connection", "schema", "table", "view", "materialized_view", "column", "mongo-collection", "vector-collection", "elasticsearch-index"]);
const commentTypes: Set<TreeNodeType> = new Set(["connection", "schema", "table", "view", "materialized_view", "column", "mongo-collection", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
export const SIDEBAR_INDENT_DEFAULT_PX = 16;
@@ -8,7 +8,7 @@ export type SidebarSelectionCopyAction = "copy-name" | "none";
export type SidebarActivation = "single" | "double";
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket", "dynamodb-table"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>([
"redis-db",
"mq-tenant",
@@ -24,6 +24,7 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
"mongo-gridfs",
"mongo-collection",
"mongo-bucket",
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"user-admin",
+20 -1
View File
@@ -50,10 +50,29 @@ export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlR
return { ...highest, statements };
}
export function classifySqlStatementRisk(sql: string, _options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
const dynamodbRisk = classifyDynamoDbStatementRisk(sql, options.dialect);
if (dynamodbRisk) return dynamodbRisk;
return classifyTokens(tokenizeSqlForRisk(sql));
}
function classifyDynamoDbStatementRisk(sql: string, dialect?: DatabaseType | string): SqlRiskStatementAssessment | null {
if (dialect !== "dynamodb") return null;
const header = sql
.split(/\r?\n/)
.find((line) => line.trim())
?.trim()
.toUpperCase();
if (header === "DBX DYNAMODB SCAN" || header === "DBX DYNAMODB QUERY / SCAN") {
return { risk: "read", firstKeyword: "dbx" };
}
if (header === "DBX DYNAMODB INSERT ITEM" || header === "DBX DYNAMODB PUT ITEM" || header === "DBX DYNAMODB DELETE ITEM") {
return { risk: "write", firstKeyword: "dbx" };
}
if (header?.startsWith("DBX DYNAMODB")) return { risk: "unknown", firstKeyword: "dbx" };
return null;
}
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
+43 -2
View File
@@ -1732,7 +1732,7 @@ export const useConnectionStore = defineStore("connection", () => {
function invalidateMetadataCachesForNode(node: TreeNode) {
if (!node.connectionId) return;
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined);
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const match = {
connectionId: node.connectionId,
database: node.database || undefined,
@@ -3135,6 +3135,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadConsulRoot(connectionId);
} else if (config.db_type === "mongodb") {
await loadMongoDatabases(connectionId);
} else if (config.db_type === "dynamodb") {
await loadDynamoDbTables(connectionId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
// Reload: list indices.
await loadElasticsearchIndices(connectionId);
@@ -3795,7 +3797,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
if (!connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
@@ -4215,6 +4217,42 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadDynamoDbTables(connectionId: string) {
const node = findConnectionNode(connectionId);
if (!node) return;
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const config = getConfig(connectionId);
const region = config?.database?.trim() || "us-east-1";
const tables = await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, region), "DynamoDB tables");
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(
targetNode,
[...tables]
.sort((left, right) => compareSidebarNames(left.name, right.name))
.map((table) => ({
id: `${connectionId}:__dynamodb_table:${table.id}`,
label: table.name,
type: "dynamodb-table" as const,
connectionId,
database: region,
tableName: table.name,
isExpanded: false,
})),
);
targetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
throw e;
} finally {
finishTreeNodeLoad(load);
}
}
/**
* Connect an Elasticsearch root without expanding or listing indices.
* Used when first opening a connection (test/connect) — connectivity uses
@@ -5888,6 +5926,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadConsulRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
await loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
@@ -8006,6 +8046,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadNacosNamespaces,
updateRedisDbKeyStats,
loadMongoDatabases,
loadDynamoDbTables,
loadMilvusDatabases,
openElasticsearchConnectionTree,
loadElasticsearchIndices,
+2
View File
@@ -14,6 +14,7 @@ export type DatabaseType =
| "clickhouse"
| "sqlserver"
| "mongodb"
| "dynamodb"
| "oracle"
| "elasticsearch"
| "easysearch"
@@ -960,6 +961,7 @@ export type TreeNodeType =
| "mongo-buckets"
| "mongo-bucket"
| "mongo-collection"
| "dynamodb-table"
| "vector-database"
| "vector-collection"
| "elasticsearch-index"