+
{{ displayTitle }}
{{ message }}
diff --git a/apps/desktop/src/components/ui/__tests__/ErrorBannerClipboard.spec.ts b/apps/desktop/src/components/ui/__tests__/ErrorBannerClipboard.spec.ts
new file mode 100644
index 0000000000..168b24ba4b
--- /dev/null
+++ b/apps/desktop/src/components/ui/__tests__/ErrorBannerClipboard.spec.ts
@@ -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
("[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);
+ });
+});
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 8752703613..0a35d478ff 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -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",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 7d38e738c7..86c70e2cdf 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -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}",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 07c87dd019..eea46cff3c 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -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}",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index dbf463b87e..07e09163e6 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -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} ใณใฌใฏใทใงใณ",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index ba1c1f509d..a82e355f96 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -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} ์ปฌ๋ ์
",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 66478cc84c..a2f90ee760 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -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}",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index f567788a85..47cd22a920 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -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} ้ๅ",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 53d7c80fff..92d1e2b5e9 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -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} ้ๅ",
diff --git a/apps/desktop/src/lib/__tests__/app/documentStoreProvider.dynamodb.spec.ts b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.dynamodb.spec.ts
new file mode 100644
index 0000000000..e1809c88af
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.dynamodb.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/connection/dynamodbConnectionPresentation.spec.ts b/apps/desktop/src/lib/__tests__/connection/dynamodbConnectionPresentation.spec.ts
new file mode 100644
index 0000000000..ba832d8355
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/connection/dynamodbConnectionPresentation.spec.ts
@@ -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,
+ 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);
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts
index 3c2eade8d8..e313f9d260 100644
--- a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts
+++ b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts
@@ -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);
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts
index 18c0f7ef71..6ee3b61436 100644
--- a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts
@@ -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);
diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts
index 98a26a97f1..8d7ba4a202 100644
--- a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts
@@ -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");
diff --git a/apps/desktop/src/lib/__tests__/sql/sqlRisk.dynamodb.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlRisk.dynamodb.spec.ts
new file mode 100644
index 0000000000..17ca66dec2
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sql/sqlRisk.dynamodb.spec.ts
@@ -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");
+ });
+});
diff --git a/apps/desktop/src/lib/app/documentJsonValues.ts b/apps/desktop/src/lib/app/documentJsonValues.ts
index c8b656cf89..295973bd45 100644
--- a/apps/desktop/src/lib/app/documentJsonValues.ts
+++ b/apps/desktop/src/lib/app/documentJsonValues.ts
@@ -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.
diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts
index db8afd2bcd..1a053889c4 100644
--- a/apps/desktop/src/lib/app/documentStoreProvider.ts
+++ b/apps/desktop/src/lib/app/documentStoreProvider.ts
@@ -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 = {
mongodb: normalizeJsonArgument,
+ dynamodb: quoteUnquotedObjectKeys,
elasticsearch: quoteUnquotedObjectKeys,
meilisearch: quoteUnquotedObjectKeys,
};
diff --git a/apps/desktop/src/lib/app/documentStoreSave.ts b/apps/desktop/src/lib/app/documentStoreSave.ts
index 62f20577cc..0cdfb0565e 100644
--- a/apps/desktop/src/lib/app/documentStoreSave.ts
+++ b/apps/desktop/src/lib/app/documentStoreSave.ts
@@ -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; apis: DocumentStoreWriteApis }): Promise {
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; explicitId?: string | null; routing?: string; apis: Pick }): Promise {
const { kind, document, explicitId, routing, apis } = options;
- if (kind !== "mongodb" && explicitId) {
+ if (kind !== "mongodb" && kind !== "dynamodb" && explicitId) {
await writeDocumentStoreDocument({
kind,
op: "put",
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index cdd95dd5df..b09f97af3e 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -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,
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index ce487c97dd..ee66e5271d 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -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 {
+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 {
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 {
+ return post("/api/document-store/count-documents", {
+ connectionId,
+ collection,
+ filter,
+ executionId,
+ });
+}
+
+export async function dynamodbDescribeTable(connectionId: string, table: string): Promise {
+ return post("/api/document-store/dynamodb-describe-table", { connectionId, table });
+}
+
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise {
return post("/api/document-store/elasticsearch-count-documents", {
connectionId,
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index bc1963b8cf..0ad63b2943 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -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 {
+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 {
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 {
+ return invoke("document_count_documents", {
+ connectionId,
+ collection,
+ filter,
+ executionId,
+ });
+}
+
+export async function dynamodbDescribeTable(connectionId: string, table: string): Promise {
+ return invoke("dynamodb_describe_table", { connectionId, table });
+}
+
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise {
return invoke("elasticsearch_count_documents", {
connectionId,
diff --git a/apps/desktop/src/lib/connection/connectionPresentation.ts b/apps/desktop/src/lib/connection/connectionPresentation.ts
index 09f7320cef..bbe2d42d18 100644
--- a/apps/desktop/src/lib/connection/connectionPresentation.ts
+++ b/apps/desktop/src/lib/connection/connectionPresentation.ts
@@ -136,6 +136,7 @@ export function connectionDisplayUrlScheme(connection: Pick = {
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 = {
const HTTP_SELECTED_PROFILES: Record = {
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, 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,
diff --git a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
index d9c964b6f2..2c4e2fc9cc 100644
--- a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
+++ b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
@@ -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(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
+const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
index 107d666857..7266dd1b35 100644
--- a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
+++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
@@ -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" },
diff --git a/apps/desktop/src/lib/database/databasePropertyEditing.ts b/apps/desktop/src/lib/database/databasePropertyEditing.ts
index 9d737b12e8..6d68de740b 100644
--- a/apps/desktop/src/lib/database/databasePropertyEditing.ts
+++ b/apps/desktop/src/lib/database/databasePropertyEditing.ts
@@ -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" },
diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts
index 33b47f48c1..3d50955767 100644
--- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts
@@ -27,6 +27,7 @@ const leafTypes: Set = new Set([
"consul-overview",
"mongo-gridfs",
"mongo-bucket",
+ "dynamodb-table",
"vector-collection",
"elasticsearch-index",
"user-admin",
@@ -38,7 +39,7 @@ const leafTypes: Set = new Set([
"extension",
]);
-const fullWidthLabelTypes: Set = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"]);
+const fullWidthLabelTypes: Set = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
const emptyContainerTypes: Set = new Set(["saved-sql-root", "saved-sql-folder", "type"]);
@@ -58,12 +59,13 @@ const pinnableTypes: Set = new Set([
"mongo-gridfs",
"mongo-bucket",
"mongo-collection",
+ "dynamodb-table",
"vector-collection",
"elasticsearch-index",
"nacos-namespace",
]);
-const commentTypes: Set = new Set(["connection", "schema", "table", "view", "materialized_view", "column", "mongo-collection", "vector-collection", "elasticsearch-index"]);
+const commentTypes: Set = new Set(["connection", "schema", "table", "view", "materialized_view", "column", "mongo-collection", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
export const SIDEBAR_INDENT_DEFAULT_PX = 16;
diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts
index 3c628a07cb..1033751386 100644
--- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts
+++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts
@@ -8,7 +8,7 @@ export type SidebarSelectionCopyAction = "copy-name" | "none";
export type SidebarActivation = "single" | "double";
const dataNodeTypes = new Set(["table", "view", "materialized_view"]);
-const documentBrowserNodeTypes = new Set(["mongo-collection", "mongo-bucket"]);
+const documentBrowserNodeTypes = new Set(["mongo-collection", "mongo-bucket", "dynamodb-table"]);
const toggleLeafNodeTypes = new Set([
"redis-db",
"mq-tenant",
@@ -24,6 +24,7 @@ const toggleLeafNodeTypes = new Set([
"mongo-gridfs",
"mongo-collection",
"mongo-bucket",
+ "dynamodb-table",
"vector-collection",
"elasticsearch-index",
"user-admin",
diff --git a/apps/desktop/src/lib/sql/sqlRisk.ts b/apps/desktop/src/lib/sql/sqlRisk.ts
index f3e9b5639b..890d3bd383 100644
--- a/apps/desktop/src/lib/sql/sqlRisk.ts
+++ b/apps/desktop/src/lib/sql/sqlRisk.ts
@@ -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";
}
diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts
index 43da6253e7..4fc316cbd7 100644
--- a/apps/desktop/src/stores/connectionStore.ts
+++ b/apps/desktop/src/stores/connectionStore.ts
@@ -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,
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index 67df284034..490e11b5fd 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -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"
diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml
index b6974bacba..94b3a5807a 100644
--- a/crates/dbx-core/Cargo.toml
+++ b/crates/dbx-core/Cargo.toml
@@ -5,8 +5,9 @@ edition = "2021"
license = "Apache-2.0"
[features]
-default = ["duckdb-sidecar", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
+default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
duckdb-sidecar = []
+dynamodb = ["dep:aws-sdk-dynamodb"]
mq-admin = ["dep:rumqttc"]
sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"]
openapi = ["dep:utoipa"]
@@ -50,6 +51,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str
prometheus-parse = "=0.2.5"
futures = "0.3"
mongodb = { version = "3.2.5", features = ["socks5-proxy"] }
+aws-sdk-dynamodb = { version = "=1.104.0", optional = true }
rumqttc = { version = "0.24", features = ["websocket"], optional = true }
russh = "0.60"
pageant = "0.2"
diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json
index 48cd627ef8..ed4ba76ba4 100644
--- a/crates/dbx-core/assets/database-drivers.manifest.json
+++ b/crates/dbx-core/assets/database-drivers.manifest.json
@@ -296,6 +296,35 @@
"driverManagement": false
}
},
+ {
+ "dbType": "dynamodb",
+ "label": "Amazon DynamoDB",
+ "runtimeMode": "native",
+ "mcpMode": "bridge",
+ "singleConnectionPool": true,
+ "metadataConnectionScoped": false,
+ "skipTcpProbe": false,
+ "defaultPort": 8000,
+ "supportLevel": "operate",
+ "capabilities": {
+ "queryExecution": false,
+ "metadataBrowse": true,
+ "objectBrowser": false,
+ "objectSource": false,
+ "schemaSearch": false,
+ "diagram": false,
+ "tableDataEdit": true,
+ "tableStructureEdit": false,
+ "tableImport": false,
+ "dataTransfer": false,
+ "sqlFileExecution": false,
+ "databaseCreate": false,
+ "fieldLineage": false,
+ "sqlExplain": false,
+ "userAdmin": false,
+ "driverManagement": false
+ }
+ },
{
"dbType": "oracle",
"label": "Oracle",
diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs
index fa4b6f91cf..6088e7121f 100644
--- a/crates/dbx-core/src/connection.rs
+++ b/crates/dbx-core/src/connection.rs
@@ -87,6 +87,7 @@ pub enum PoolKind {
Redis(db::redis_driver::RedisConnection),
DuckDbWorker(DuckDbWorkerHandle),
MongoDb(mongodb::Client),
+ DynamoDb(db::dynamodb_driver::DynamoDbClient),
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc>),
Elasticsearch(db::elasticsearch_driver::EsClient),
@@ -2022,6 +2023,11 @@ impl AppState {
}
}
}
+ DatabaseType::DynamoDb => {
+ let client = db::dynamodb_driver::connect(&db_config, &host, port)?;
+ db::dynamodb_driver::test_connection(&client, connect_timeout).await?;
+ PoolKind::DynamoDb(client)
+ }
DatabaseType::ClickHouse => {
let username = if db_config.username.is_empty() { None } else { Some(db_config.username.clone()) };
let password = if db_config.password.is_empty() { None } else { Some(db_config.password.clone()) };
@@ -3100,6 +3106,18 @@ impl AppState {
}
}
}
+ PoolKind::DynamoDb(client) => {
+ let client = client.clone();
+ drop(connections);
+ let timeout = crate::db::connection_timeout();
+ match db::dynamodb_driver::test_connection(&client, timeout).await {
+ Ok(()) => false,
+ Err(err) => {
+ log::warn!("DynamoDB connection pool '{pool_key}' is stale: {err}");
+ true
+ }
+ }
+ }
PoolKind::ClickHouse(client) => {
let client = client.clone();
drop(connections);
@@ -4051,6 +4069,13 @@ impl AppState {
false
}
},
+ PoolKind::DynamoDb(client) => match db::dynamodb_driver::test_connection(client, timeout).await {
+ Ok(()) => true,
+ Err(e) => {
+ log::warn!("DynamoDB connection pool '{key}' is unhealthy: {e}");
+ false
+ }
+ },
PoolKind::ClickHouse(client) => match db::clickhouse_driver::test_connection(client, timeout).await {
Ok(()) => true,
Err(e) => {
@@ -4844,6 +4869,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
#[cfg(not(feature = "duckdb-sidecar"))]
PoolKind::DuckDbWorker(_) => PoolKind::DuckDbWorker(()),
PoolKind::MongoDb(client) => PoolKind::MongoDb(client.clone()),
+ PoolKind::DynamoDb(client) => PoolKind::DynamoDb(client.clone()),
PoolKind::ClickHouse(client) => PoolKind::ClickHouse(client.clone()),
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
@@ -4888,6 +4914,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
PoolKind::MongoDb(client) => {
drop(client);
}
+ PoolKind::DynamoDb(client) => {
+ drop(client);
+ }
PoolKind::ClickHouse(client) => {
drop(client);
}
diff --git a/crates/dbx-core/src/database_capabilities.rs b/crates/dbx-core/src/database_capabilities.rs
index 4570deb90d..0d66774607 100644
--- a/crates/dbx-core/src/database_capabilities.rs
+++ b/crates/dbx-core/src/database_capabilities.rs
@@ -18,6 +18,7 @@ pub fn is_single_connection_pool(db_type: &DatabaseType) -> bool {
| DatabaseType::Turso
| DatabaseType::CloudflareD1
| DatabaseType::MongoDb
+ | DatabaseType::DynamoDb
| DatabaseType::Hbase
| DatabaseType::Oracle
| DatabaseType::Dameng
diff --git a/crates/dbx-core/src/db/document_result.rs b/crates/dbx-core/src/db/document_result.rs
index c35b4529e6..1ce153d897 100644
--- a/crates/dbx-core/src/db/document_result.rs
+++ b/crates/dbx-core/src/db/document_result.rs
@@ -13,6 +13,10 @@ pub struct DocumentQueryResult {
// unchanged and only send the flag when Elasticsearch reports a lower bound.
#[serde(default = "default_total_is_exact", skip_serializing_if = "is_true")]
pub total_is_exact: bool,
+ /// Opaque document-store cursor for APIs such as DynamoDB that do not
+ /// support offset pagination.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub next_cursor: Option,
}
fn default_total_is_exact() -> bool {
@@ -35,6 +39,7 @@ mod tests {
extended_documents: None,
total: 1,
total_is_exact: true,
+ next_cursor: None,
};
let serialized = serde_json::to_value(result).unwrap();
diff --git a/crates/dbx-core/src/db/dynamodb_driver.rs b/crates/dbx-core/src/db/dynamodb_driver.rs
new file mode 100644
index 0000000000..196a813a6b
--- /dev/null
+++ b/crates/dbx-core/src/db/dynamodb_driver.rs
@@ -0,0 +1,2129 @@
+use std::collections::{BTreeSet, HashMap};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use aws_sdk_dynamodb::config::{Credentials, Region};
+use aws_sdk_dynamodb::error::ProvideErrorMetadata;
+use aws_sdk_dynamodb::primitives::Blob;
+use aws_sdk_dynamodb::types::{AttributeValue, Delete, KeySchemaElement, KeyType, Put, Select, TransactWriteItem};
+use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
+use base64::Engine;
+use serde_json::{Map, Value};
+use tokio::sync::RwLock;
+
+use crate::db::document_result::DocumentQueryResult;
+use crate::models::connection::ConnectionConfig;
+use crate::types::QueryResult;
+
+const DEFAULT_REGION: &str = "us-east-1";
+const MAX_PAGE_SIZE: i32 = 1000;
+const MAX_JAVASCRIPT_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
+const DYNAMODB_JSON_TYPE_TAG: &str = "$dbxDynamoDb";
+const DYNAMODB_JSON_TYPE_VERSION: u64 = 1;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DynamoDbStatementOperation {
+ Read,
+ Insert,
+ Put,
+ Delete,
+}
+
+#[derive(Debug, PartialEq)]
+struct DynamoDbStatement {
+ operation: DynamoDbStatementOperation,
+ table: String,
+ limit: Option,
+ filter: Option,
+ sort: Option,
+ cursor: Option,
+ key: Option,
+ item: Option,
+}
+
+macro_rules! dynamodb_sdk_error {
+ ($context:expr, $error:expr) => {{
+ let error = $error;
+ if let Some(service_error) = error.as_service_error() {
+ format!(
+ "{}: {}: {}",
+ $context,
+ service_error.code().unwrap_or("ServiceError"),
+ service_error.message().unwrap_or("No details returned by DynamoDB")
+ )
+ } else {
+ format!("{}: {}", $context, error)
+ }
+ }};
+}
+
+#[derive(Clone)]
+pub struct DynamoDbClient {
+ client: aws_sdk_dynamodb::Client,
+ pub region: String,
+ pub endpoint_url: String,
+ table_cache: Arc>>,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct DynamoDbKeyInfo {
+ pub name: String,
+ pub attribute_type: String,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct DynamoDbIndexInfo {
+ pub name: String,
+ pub kind: String,
+ pub partition_key: DynamoDbKeyInfo,
+ pub sort_key: Option,
+ pub projection_type: String,
+ pub non_key_attributes: Vec,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct DynamoDbTableDescription {
+ pub name: String,
+ pub status: String,
+ pub item_count: u64,
+ pub size_bytes: u64,
+ pub partition_key: DynamoDbKeyInfo,
+ pub sort_key: Option,
+ pub indexes: Vec,
+}
+
+#[derive(Debug, Clone)]
+struct KeySchema {
+ partition_key: String,
+ sort_key: Option,
+}
+
+#[derive(Debug)]
+struct ReadPlan {
+ index_name: Option,
+ key_condition: Option,
+ filter: Option,
+ scan_index_forward: bool,
+}
+
+#[derive(Debug, Default)]
+struct CompiledExpression {
+ expression: String,
+ names: HashMap,
+ values: HashMap,
+}
+
+struct ExpressionCompiler {
+ namespace: &'static str,
+ names: HashMap,
+ values: HashMap,
+ next_name: usize,
+ next_value: usize,
+}
+
+struct ConditionClause {
+ expression: String,
+ precedence: u8,
+}
+
+impl ExpressionCompiler {
+ fn new(namespace: &'static str) -> Self {
+ Self { namespace, names: HashMap::new(), values: HashMap::new(), next_name: 0, next_value: 0 }
+ }
+
+ fn path(&mut self, field: &str) -> Result {
+ let segments = field.split('.').map(str::trim).collect::>();
+ if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) {
+ return Err(format!("Invalid DynamoDB attribute path: {field}"));
+ }
+ Ok(segments
+ .into_iter()
+ .map(|segment| {
+ let token = format!("#{}n{}", self.namespace, self.next_name);
+ self.next_name += 1;
+ self.names.insert(token.clone(), segment.to_string());
+ token
+ })
+ .collect::>()
+ .join("."))
+ }
+
+ fn value(&mut self, value: &Value) -> Result {
+ let token = format!(":{}v{}", self.namespace, self.next_value);
+ self.next_value += 1;
+ self.values.insert(token.clone(), json_to_attribute_value(value)?);
+ Ok(token)
+ }
+
+ fn compile(mut self, value: &Value) -> Result {
+ let expression = self.condition(value)?;
+ Ok(CompiledExpression { expression, names: self.names, values: self.values })
+ }
+
+ fn compile_key_condition(mut self, value: &Value) -> Result {
+ let mut clauses = Vec::new();
+ for term in flatten_and_terms(value)? {
+ let object = term.as_object().ok_or_else(|| "DynamoDB key condition must be an object".to_string())?;
+ if object.len() != 1 {
+ return Err("DynamoDB key condition terms must contain one attribute".to_string());
+ }
+ let (field, condition) = object.iter().next().expect("checked length");
+ clauses.push(self.field_condition(field, condition)?);
+ }
+ if clauses.is_empty() {
+ return Err("DynamoDB key condition does not contain any attributes".to_string());
+ }
+ Ok(CompiledExpression { expression: clauses.join(" AND "), names: self.names, values: self.values })
+ }
+
+ fn condition(&mut self, value: &Value) -> Result {
+ self.condition_clause(value).map(|clause| clause.expression)
+ }
+
+ fn condition_clause(&mut self, value: &Value) -> Result {
+ let object = value.as_object().ok_or_else(|| "DynamoDB filter must be a JSON object".to_string())?;
+ let mut clauses = Vec::new();
+ for (field, condition) in object {
+ match field.as_str() {
+ "$index" => continue,
+ "$and" | "$or" => {
+ let entries =
+ condition.as_array().ok_or_else(|| format!("{field} must be an array of filter objects"))?;
+ if entries.is_empty() {
+ continue;
+ }
+ let operator = if field == "$and" { " AND " } else { " OR " };
+ let precedence = if field == "$and" { 2 } else { 1 };
+ let nested =
+ entries.iter().map(|entry| self.condition_clause(entry)).collect::, _>>()?;
+ clauses.push(join_condition_clauses(nested, operator, precedence));
+ }
+ field if field.starts_with('$') => {
+ return Err(format!("Unsupported DynamoDB filter operator: {field}"));
+ }
+ _ => {
+ clauses.push(ConditionClause { expression: self.field_condition(field, condition)?, precedence: 3 })
+ }
+ }
+ }
+ if clauses.is_empty() {
+ return Err("DynamoDB filter does not contain any conditions".to_string());
+ }
+ Ok(join_condition_clauses(clauses, " AND ", 2))
+ }
+
+ fn field_condition(&mut self, field: &str, condition: &Value) -> Result {
+ let path = self.path(field)?;
+ let Some(operators) = filter_operator_object(condition) else {
+ let value = self.value(condition)?;
+ return Ok(format!("{path} = {value}"));
+ };
+
+ let mut clauses = Vec::new();
+ for (operator, operand) in operators {
+ let clause = match operator.as_str() {
+ "$eq" => format!("{path} = {}", self.value(operand)?),
+ "$ne" => format!("{path} <> {}", self.value(operand)?),
+ "$gt" => format!("{path} > {}", self.value(operand)?),
+ "$gte" => format!("{path} >= {}", self.value(operand)?),
+ "$lt" => format!("{path} < {}", self.value(operand)?),
+ "$lte" => format!("{path} <= {}", self.value(operand)?),
+ "$contains" => format!("contains({path}, {})", self.value(operand)?),
+ "$notContains" => format!("NOT contains({path}, {})", self.value(operand)?),
+ "$beginsWith" => format!("begins_with({path}, {})", self.value(operand)?),
+ "$exists" => {
+ if operand.as_bool().unwrap_or(false) {
+ format!("attribute_exists({path})")
+ } else {
+ format!("attribute_not_exists({path})")
+ }
+ }
+ "$between" => {
+ let values = operand
+ .as_array()
+ .filter(|values| values.len() == 2)
+ .ok_or_else(|| format!("DynamoDB $between for {field} must contain exactly two values"))?;
+ format!("{path} BETWEEN {} AND {}", self.value(&values[0])?, self.value(&values[1])?)
+ }
+ "$in" => {
+ let values = operand
+ .as_array()
+ .filter(|values| !values.is_empty())
+ .ok_or_else(|| format!("DynamoDB $in for {field} must contain at least one value"))?;
+ let tokens = values.iter().map(|value| self.value(value)).collect::, _>>()?;
+ format!("{path} IN ({})", tokens.join(", "))
+ }
+ "$regex" => {
+ let text = operand
+ .as_str()
+ .ok_or_else(|| format!("DynamoDB contains filter for {field} must be a string"))?;
+ format!("contains({path}, {})", self.value(&Value::String(text.to_string()))?)
+ }
+ "$options" => continue,
+ "$not" => {
+ let nested = operand
+ .as_object()
+ .and_then(|object| object.get("$regex"))
+ .and_then(Value::as_str)
+ .ok_or_else(|| format!("Unsupported DynamoDB $not condition for {field}"))?;
+ format!("NOT contains({path}, {})", self.value(&Value::String(nested.to_string()))?)
+ }
+ other => return Err(format!("Unsupported DynamoDB filter operator: {other}")),
+ };
+ clauses.push(clause);
+ }
+ if clauses.is_empty() {
+ return Err(format!("DynamoDB filter for {field} does not contain a condition"));
+ }
+ Ok(clauses.join(" AND "))
+ }
+}
+
+fn join_condition_clauses(clauses: Vec, operator: &str, precedence: u8) -> ConditionClause {
+ if clauses.len() == 1 {
+ return clauses.into_iter().next().expect("checked length");
+ }
+ let expression = clauses
+ .into_iter()
+ .map(
+ |clause| {
+ if clause.precedence < precedence {
+ format!("({})", clause.expression)
+ } else {
+ clause.expression
+ }
+ },
+ )
+ .collect::>()
+ .join(operator);
+ ConditionClause { expression, precedence }
+}
+
+pub fn endpoint_url(config: &ConnectionConfig, host: &str, port: u16) -> String {
+ let scheme = if config.ssl { "https" } else { "http" };
+ format!("{scheme}://{host}:{port}")
+}
+
+pub fn region(config: &ConnectionConfig) -> String {
+ config.database.as_deref().map(str::trim).filter(|value| !value.is_empty()).unwrap_or(DEFAULT_REGION).to_string()
+}
+
+pub fn connect(config: &ConnectionConfig, host: &str, port: u16) -> Result {
+ let region = region(config);
+ let endpoint_url = endpoint_url(config, host, port);
+ let access_key = config.username.trim();
+ let secret_key = config.password.trim();
+ if access_key.is_empty() {
+ return Err("DynamoDB Access Key ID is required".to_string());
+ }
+ if secret_key.is_empty() {
+ return Err("DynamoDB Secret Access Key is required".to_string());
+ }
+ let session_token = config.connection_string.as_deref().map(str::trim).filter(|value| !value.is_empty());
+ let credentials = Credentials::new(access_key, secret_key, session_token.map(str::to_string), None, "dbx");
+ let sdk_config = aws_sdk_dynamodb::config::Builder::new()
+ .behavior_version_latest()
+ .region(Region::new(region.clone()))
+ .credentials_provider(credentials)
+ .endpoint_url(endpoint_url.clone())
+ .build();
+ Ok(DynamoDbClient {
+ client: aws_sdk_dynamodb::Client::from_conf(sdk_config),
+ region,
+ endpoint_url,
+ table_cache: Arc::new(RwLock::new(HashMap::new())),
+ })
+}
+
+pub async fn test_connection(client: &DynamoDbClient, timeout: Duration) -> Result<(), String> {
+ tokio::time::timeout(timeout, client.client.list_tables().limit(1).send())
+ .await
+ .map_err(|_| format!("DynamoDB connection timed out ({}s)", timeout.as_secs()))?
+ .map(|_| ())
+ .map_err(|error| dynamodb_sdk_error!("DynamoDB connection failed", error))
+}
+
+pub async fn execute_statement(client: &DynamoDbClient, source: &str, max_rows: usize) -> Result {
+ let started = Instant::now();
+ let statement = parse_dynamodb_statement(source)?;
+ match statement.operation {
+ DynamoDbStatementOperation::Read => {
+ let requested_limit = statement.limit.unwrap_or(max_rows.max(1).min(MAX_PAGE_SIZE as usize) as i64);
+ let effective_limit = requested_limit.min(max_rows.max(1).min(i64::MAX as usize) as i64);
+ let filter =
+ statement.filter.as_ref().map(serde_json::to_string).transpose().map_err(|error| error.to_string())?;
+ let sort =
+ statement.sort.as_ref().map(serde_json::to_string).transpose().map_err(|error| error.to_string())?;
+ let result = find_items(
+ client,
+ &statement.table,
+ effective_limit,
+ filter.as_deref(),
+ sort.as_deref(),
+ statement.cursor.as_deref(),
+ )
+ .await?;
+ Ok(document_query_result(result, started))
+ }
+ DynamoDbStatementOperation::Insert => {
+ let key = serde_json::to_string(statement.key.as_ref().expect("validated DynamoDB insert key"))
+ .map_err(|error| error.to_string())?;
+ let item = serde_json::to_string(statement.item.as_ref().expect("validated DynamoDB insert item"))
+ .map_err(|error| error.to_string())?;
+ insert_item_with_expected_identity(client, &statement.table, &item, Some(&key)).await?;
+ Ok(affected_query_result(1, started))
+ }
+ DynamoDbStatementOperation::Put => {
+ let key = serde_json::to_string(statement.key.as_ref().expect("validated DynamoDB put key"))
+ .map_err(|error| error.to_string())?;
+ let item = serde_json::to_string(statement.item.as_ref().expect("validated DynamoDB put item"))
+ .map_err(|error| error.to_string())?;
+ let affected = update_item(client, &statement.table, &key, &item).await?;
+ Ok(affected_query_result(affected, started))
+ }
+ DynamoDbStatementOperation::Delete => {
+ let key = serde_json::to_string(statement.key.as_ref().expect("validated DynamoDB delete key"))
+ .map_err(|error| error.to_string())?;
+ let affected = delete_item(client, &statement.table, &key).await?;
+ Ok(affected_query_result(affected, started))
+ }
+ }
+}
+
+fn parse_dynamodb_statement(source: &str) -> Result {
+ let mut lines = source.lines();
+ let header = lines
+ .find(|line| !line.trim().is_empty())
+ .map(str::trim)
+ .ok_or_else(|| "DynamoDB statement is empty".to_string())?;
+ let operation = match header.to_ascii_uppercase().as_str() {
+ "DBX DYNAMODB SCAN" | "DBX DYNAMODB QUERY / SCAN" => DynamoDbStatementOperation::Read,
+ "DBX DYNAMODB INSERT ITEM" => DynamoDbStatementOperation::Insert,
+ "DBX DYNAMODB PUT ITEM" => DynamoDbStatementOperation::Put,
+ "DBX DYNAMODB DELETE ITEM" => DynamoDbStatementOperation::Delete,
+ _ => {
+ return Err(
+ "Unsupported DynamoDB statement. Use a DBX DYNAMODB SCAN, QUERY / SCAN, INSERT ITEM, PUT ITEM, or DELETE ITEM statement."
+ .to_string(),
+ )
+ }
+ };
+
+ let mut fields = HashMap::new();
+ let mut current_name: Option = None;
+ let mut current_value = String::new();
+ for line in lines {
+ if let Some((name, value)) = dynamodb_statement_field(line) {
+ finish_dynamodb_statement_field(&mut fields, current_name.take(), &mut current_value)?;
+ current_name = Some(name.to_string());
+ current_value.push_str(value.trim_start());
+ } else if current_name.is_some() {
+ if !current_value.is_empty() {
+ current_value.push('\n');
+ }
+ current_value.push_str(line);
+ } else if !line.trim().is_empty() {
+ return Err(format!("Invalid DynamoDB statement line: {line}"));
+ }
+ }
+ finish_dynamodb_statement_field(&mut fields, current_name.take(), &mut current_value)?;
+
+ let table = take_dynamodb_string_field(&mut fields, "table", true)?.expect("required table field");
+ let limit = take_dynamodb_integer_field(&mut fields, "limit")?;
+ if limit.is_some_and(|value| value <= 0) {
+ return Err("DynamoDB statement limit must be greater than zero".to_string());
+ }
+ let filter = take_dynamodb_object_field(&mut fields, "filter")?;
+ let sort = take_dynamodb_object_field(&mut fields, "sort")?;
+ let cursor = take_dynamodb_string_field(&mut fields, "cursor", false)?;
+ let key = take_dynamodb_object_field(&mut fields, "key")?;
+ let item = take_dynamodb_object_field(&mut fields, "item")?;
+ if !fields.is_empty() {
+ return Err(format!("Unsupported DynamoDB statement field: {}", fields.keys().next().unwrap()));
+ }
+
+ match operation {
+ DynamoDbStatementOperation::Read => {
+ if key.is_some() || item.is_some() {
+ return Err("DynamoDB read statements do not accept key or item fields".to_string());
+ }
+ }
+ DynamoDbStatementOperation::Insert | DynamoDbStatementOperation::Put => {
+ if key.is_none() || item.is_none() {
+ return Err("DynamoDB INSERT ITEM and PUT ITEM statements require key and item fields".to_string());
+ }
+ if limit.is_some() || filter.is_some() || sort.is_some() || cursor.is_some() {
+ return Err("DynamoDB item write statements accept only table, key, and item fields".to_string());
+ }
+ }
+ DynamoDbStatementOperation::Delete => {
+ if key.is_none() {
+ return Err("DynamoDB DELETE ITEM statements require a key field".to_string());
+ }
+ if item.is_some() || limit.is_some() || filter.is_some() || sort.is_some() || cursor.is_some() {
+ return Err("DynamoDB DELETE ITEM statements accept only table and key fields".to_string());
+ }
+ }
+ }
+
+ Ok(DynamoDbStatement { operation, table, limit, filter, sort, cursor, key, item })
+}
+
+fn dynamodb_statement_field(line: &str) -> Option<(&str, &str)> {
+ if line.chars().next().is_some_and(char::is_whitespace) {
+ return None;
+ }
+ let (name, value) = line.split_once(':')?;
+ let name = name.trim();
+ matches!(name, "table" | "limit" | "filter" | "sort" | "cursor" | "key" | "item").then_some((name, value))
+}
+
+fn finish_dynamodb_statement_field(
+ fields: &mut HashMap,
+ name: Option,
+ value: &mut String,
+) -> Result<(), String> {
+ let Some(name) = name else {
+ return Ok(());
+ };
+ let value = std::mem::take(value).trim().to_string();
+ if value.is_empty() {
+ return Err(format!("DynamoDB statement field {name} cannot be empty"));
+ }
+ if fields.insert(name.clone(), value).is_some() {
+ return Err(format!("Duplicate DynamoDB statement field: {name}"));
+ }
+ Ok(())
+}
+
+fn take_dynamodb_string_field(
+ fields: &mut HashMap,
+ name: &str,
+ required: bool,
+) -> Result