feat(mongo): add whole-document JSON editing
This commit is contained in:
1 parent
68b1777aa4
commit
5bcad3adc6
24 files changed
+1277
-67
No files matched your search
@@ -35,7 +35,19 @@ import {
|
||||
type ElasticsearchBoolClause,
|
||||
type ElasticsearchQueryType,
|
||||
} from "@/lib/app/documentStoreProvider";
|
||||
import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
|
||||
import {
|
||||
isDocumentStoreIdentityField,
|
||||
normalizeDocumentStoreRouting,
|
||||
parseDocumentStoreInputValue,
|
||||
parseDocumentStoreJsonDocument,
|
||||
planDocumentStoreIdentityMigration,
|
||||
resolveDocumentStoreWriteRouting,
|
||||
serializeDocumentStoreId,
|
||||
stringifyDocumentStoreValue,
|
||||
documentStoreValueForGrid,
|
||||
} from "@/lib/app/documentJsonValues";
|
||||
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
@@ -75,6 +87,8 @@ const selectedIdx = ref<number | null>(null);
|
||||
const editJson = ref("");
|
||||
const isEditing = ref(false);
|
||||
const isNew = ref(false);
|
||||
const documentEditMode = ref<"fields" | "json">("json");
|
||||
const isSavingDocument = ref(false);
|
||||
const error = ref("");
|
||||
const editFields = ref<EditNode[]>([]);
|
||||
const showDeleteConfirm = ref(false);
|
||||
@@ -344,20 +358,21 @@ function documentIdFromGridValue(value: MongoInputValue | undefined): string | n
|
||||
return id.trim() ? id : null;
|
||||
}
|
||||
|
||||
function documentRoutingValue(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
const routing = typeof value === "string" ? value : String(value);
|
||||
const trimmed = routing.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function documentRoutingFromDocument(doc: JsonRecord | undefined): string | undefined {
|
||||
return documentRoutingValue(doc?._routing);
|
||||
return normalizeDocumentStoreRouting(doc?._routing);
|
||||
}
|
||||
|
||||
function documentRoutingFromGridRow(row: MongoInputValue[] | undefined, columns: string[]): string | undefined {
|
||||
const routingColIdx = columns.indexOf("_routing");
|
||||
return routingColIdx >= 0 ? documentRoutingValue(row?.[routingColIdx]) : undefined;
|
||||
return routingColIdx >= 0 ? normalizeDocumentStoreRouting(row?.[routingColIdx]) : undefined;
|
||||
}
|
||||
|
||||
function documentStoreWriteApis() {
|
||||
return {
|
||||
insert: (docJson: string, routing?: string) => api.documentInsertDocument(props.connectionId, props.database, props.collection, docJson, routing),
|
||||
update: (id: string, docJson: string, routing?: string) => api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, docJson, routing),
|
||||
delete: (id: string, routing?: string) => api.documentDeleteDocument(props.connectionId, props.database, props.collection, id, routing),
|
||||
};
|
||||
}
|
||||
|
||||
async function gridSave(changes: DocumentGridChanges) {
|
||||
@@ -409,10 +424,11 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
const doc = isEs ? buildElasticsearchInsertDocument(newRow, cols) : buildMongoInsertDocument(newRow, cols);
|
||||
if (isEs) {
|
||||
const id = documentIdFromGridValue(newRow[idColIdx]);
|
||||
const routing = documentRoutingFromGridRow(newRow, cols);
|
||||
if (id) {
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), documentRoutingFromGridRow(newRow, cols));
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
|
||||
} else {
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, "elasticsearch"));
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -643,38 +659,121 @@ function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previ
|
||||
}
|
||||
}
|
||||
|
||||
function emptyDocumentJson(): string {
|
||||
return stringifyDocumentStoreValue({}, documentStoreProvider.value.kind, 2);
|
||||
}
|
||||
|
||||
function documentEditErrorMessage(result: { error: "empty" | "invalid" | "not-object" | "unsupported-number" } | { error: "duplicate-key"; field: string }): string {
|
||||
if (result.error === "not-object") return t("mongo.documentMustBeObject");
|
||||
if (result.error === "unsupported-number") return t("mongo.unsupportedJsonNumber");
|
||||
if (result.error === "duplicate-key") return t("mongo.duplicateJsonKey", { field: result.field });
|
||||
return t("mongo.invalidJson");
|
||||
}
|
||||
|
||||
function buildEditFieldsFromDocument(doc: JsonRecord): EditNode[] {
|
||||
return Object.entries(doc).map(([name, value]) => {
|
||||
const isMetadata = isDocumentStoreIdentityField(documentStoreProvider.value.kind, name);
|
||||
// Metadata field names stay fixed; values are editable so _id / routing rekey is possible.
|
||||
return createEditNode(name, value, isMetadata, false);
|
||||
});
|
||||
}
|
||||
|
||||
function metadataFieldsFromDocument(doc: JsonRecord | undefined): JsonRecord {
|
||||
const metadata: JsonRecord = {};
|
||||
if (!doc) return metadata;
|
||||
if (Object.prototype.hasOwnProperty.call(doc, "_id")) metadata._id = doc._id;
|
||||
if (documentStoreProvider.value.kind === "elasticsearch" && Object.prototype.hasOwnProperty.call(doc, "_routing")) {
|
||||
metadata._routing = doc._routing;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function currentDocumentMetadata(): JsonRecord {
|
||||
if (selectedDoc.value) return metadataFieldsFromDocument(selectedDoc.value);
|
||||
// New documents keep metadata that already exists in either editor mode.
|
||||
if (documentEditMode.value === "json") {
|
||||
const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind);
|
||||
return parsed.ok ? metadataFieldsFromDocument(parsed.document) : {};
|
||||
}
|
||||
const metadata: JsonRecord = {};
|
||||
for (const field of editFields.value) {
|
||||
const name = field.keyName.trim();
|
||||
if (isDocumentStoreIdentityField(documentStoreProvider.value.kind, name)) {
|
||||
metadata[name] = buildValueFromNode(field, name);
|
||||
}
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function syncEditJsonFromFields() {
|
||||
const doc = buildDocumentFromFields();
|
||||
// Field mode omits root metadata keys; restore them for JSON round-trips (new + existing).
|
||||
Object.assign(doc, currentDocumentMetadata());
|
||||
editJson.value = stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind, 2);
|
||||
}
|
||||
|
||||
function setDocumentEditMode(mode: "fields" | "json") {
|
||||
if (!isEditing.value || documentEditMode.value === mode) return;
|
||||
error.value = "";
|
||||
if (mode === "json") {
|
||||
try {
|
||||
syncEditJsonFromFields();
|
||||
documentEditMode.value = "json";
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind);
|
||||
if (!parsed.ok) {
|
||||
error.value = documentEditErrorMessage(parsed);
|
||||
return;
|
||||
}
|
||||
editFields.value = buildEditFieldsFromDocument(parsed.document);
|
||||
documentEditMode.value = "fields";
|
||||
}
|
||||
|
||||
function selectDoc(idx: number) {
|
||||
selectedIdx.value = idx;
|
||||
editJson.value = stringifyDocumentStoreValue(documents.value[idx], documentStoreProvider.value.kind, 2);
|
||||
isEditing.value = false;
|
||||
isNew.value = false;
|
||||
documentEditMode.value = "fields";
|
||||
editFields.value = [];
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
selectedIdx.value = null;
|
||||
editJson.value = "";
|
||||
editJson.value = emptyDocumentJson();
|
||||
editFields.value = [createEditNode("", "", false, false)];
|
||||
documentEditMode.value = "json";
|
||||
isEditing.value = true;
|
||||
isNew.value = true;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
const doc = selectedDoc.value;
|
||||
if (!doc) return;
|
||||
editFields.value = Object.entries(doc).map(([name, value]) => {
|
||||
const readonlyMetadata = name === "_id" || (documentStoreProvider.value.kind === "elasticsearch" && name === "_routing");
|
||||
return createEditNode(name, value, readonlyMetadata, readonlyMetadata);
|
||||
});
|
||||
// Issue #2952: open whole-document JSON editing by default (DBeaver-style), not field tree.
|
||||
editJson.value = stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind, 2);
|
||||
editFields.value = buildEditFieldsFromDocument(doc);
|
||||
documentEditMode.value = "json";
|
||||
isEditing.value = true;
|
||||
isNew.value = false;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false;
|
||||
documentEditMode.value = "fields";
|
||||
if (isNew.value) {
|
||||
isNew.value = false;
|
||||
editFields.value = [];
|
||||
editJson.value = "";
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
if (selectedDoc.value) {
|
||||
@@ -765,7 +864,7 @@ function buildObjectFromNodes(nodes: EditNode[], path: string): JsonRecord {
|
||||
|
||||
for (const field of nodes) {
|
||||
const name = field.keyName.trim();
|
||||
if (!name || (!path && (name === "_id" || (documentStoreProvider.value.kind === "elasticsearch" && name === "_routing")))) continue;
|
||||
if (!name || (!path && isDocumentStoreIdentityField(documentStoreProvider.value.kind, name))) continue;
|
||||
if (seen.has(name)) throw new Error(t("mongo.duplicateField", { field: name }));
|
||||
seen.add(name);
|
||||
doc[name] = buildValueFromNode(field, path ? `${path}.${name}` : name);
|
||||
@@ -786,23 +885,102 @@ function buildDocumentFromFields(): JsonRecord {
|
||||
return buildObjectFromNodes(editFields.value, "");
|
||||
}
|
||||
|
||||
async function saveDoc() {
|
||||
error.value = "";
|
||||
function buildDocumentFromEditor(): JsonRecord | null {
|
||||
if (documentEditMode.value === "json") {
|
||||
const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind);
|
||||
if (!parsed.ok) {
|
||||
error.value = documentEditErrorMessage(parsed);
|
||||
return null;
|
||||
}
|
||||
return parsed.document;
|
||||
}
|
||||
|
||||
// Field mode skips root metadata in buildDocumentFromFields(); reattach identity field values.
|
||||
const doc = buildDocumentFromFields();
|
||||
for (const field of editFields.value) {
|
||||
const name = field.keyName.trim();
|
||||
if (isDocumentStoreIdentityField(documentStoreProvider.value.kind, name)) {
|
||||
doc[name] = buildValueFromNode(field, name);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
function resolveDocumentStorePathId(id: unknown): string | null {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
return documentIdFromGridValue(documentStoreValueForGrid(id, "elasticsearch"));
|
||||
}
|
||||
if (id === undefined || id === null || id === "") return null;
|
||||
try {
|
||||
const doc = buildDocumentFromFields();
|
||||
const serialized = serializeDocumentStoreId(id, documentStoreProvider.value.kind);
|
||||
return serialized.trim() ? serialized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, currentRouting: string | undefined): { writeId: string; writeRouting?: string } | null {
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
const hasPayloadId = Object.prototype.hasOwnProperty.call(doc, "_id");
|
||||
const writeId = hasPayloadId ? resolveDocumentStorePathId(doc._id) : resolveDocumentStorePathId(currentId);
|
||||
if (!writeId) return null;
|
||||
const writeRouting = kind === "elasticsearch" ? resolveDocumentStoreWriteRouting(doc, currentRouting) : undefined;
|
||||
return { writeId, writeRouting };
|
||||
}
|
||||
|
||||
async function saveDoc() {
|
||||
if (isSavingDocument.value) return;
|
||||
error.value = "";
|
||||
isSavingDocument.value = true;
|
||||
try {
|
||||
const doc = buildDocumentFromEditor();
|
||||
if (!doc) return;
|
||||
|
||||
const apis = documentStoreWriteApis();
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
|
||||
if (isNew.value) {
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind));
|
||||
const explicitId = kind === "elasticsearch" ? documentIdFromGridValue(documentStoreValueForGrid(doc._id, "elasticsearch")) : null;
|
||||
await insertDocumentStoreDocumentCore({
|
||||
kind,
|
||||
document: doc,
|
||||
explicitId,
|
||||
routing: normalizeDocumentStoreRouting(doc._routing),
|
||||
apis,
|
||||
});
|
||||
} else if (selectedIdx.value !== null) {
|
||||
const current = documents.value[selectedIdx.value];
|
||||
const id = current?._id;
|
||||
if (!id) {
|
||||
const currentId = current?._id;
|
||||
if (currentId === undefined || currentId === null) {
|
||||
error.value = "No _id field";
|
||||
return;
|
||||
}
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, serializeDocumentStoreId(id, documentStoreProvider.value.kind), stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind), documentRoutingFromDocument(current));
|
||||
|
||||
const deleteId = resolveDocumentStorePathId(currentId);
|
||||
if (!deleteId) {
|
||||
error.value = "No _id field";
|
||||
return;
|
||||
}
|
||||
const currentRouting = documentRoutingFromDocument(current);
|
||||
const write = resolveWriteIdentityFromEditor(doc, currentId, currentRouting);
|
||||
if (!write) {
|
||||
error.value = t("mongo.jsonIdRequired");
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = planDocumentStoreIdentityMigration({
|
||||
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 });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = false;
|
||||
isNew.value = false;
|
||||
documentEditMode.value = "fields";
|
||||
editFields.value = [];
|
||||
await load();
|
||||
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
|
||||
@@ -810,6 +988,8 @@ async function saveDoc() {
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
isSavingDocument.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,21 +1472,34 @@ function resetTableSearchSplitWidth() {
|
||||
<span class="flex-1" />
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
|
||||
<template v-if="isEditing">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
|
||||
<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>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'fields' }" :disabled="isSavingDocument" @click="setDocumentEditMode('fields')">{{ t("mongo.editModeFields") }}</Button>
|
||||
</div>
|
||||
<Button v-if="documentEditMode === 'fields'" variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="cancelEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
|
||||
<div class="json-edit min-w-fit p-5" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }">
|
||||
<div class="json-edit-brace">{</div>
|
||||
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
|
||||
{{ t("mongo.jsonReplaceHint") }}
|
||||
</div>
|
||||
|
||||
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!field.readonlyValue" @remove="requestRemoveField(idx)" />
|
||||
<div v-if="isEditing" class="flex-1 min-h-0 overflow-hidden bg-muted/10">
|
||||
<div v-if="documentEditMode === 'json'" class="h-full min-h-0 p-2">
|
||||
<RedisJsonEditor v-model="editJson" class="h-full rounded border bg-background" :save-disabled="isSavingDocument" :read-only="isSavingDocument" @save="saveDoc" />
|
||||
</div>
|
||||
<div v-else class="h-full overflow-auto">
|
||||
<div class="json-edit min-w-fit p-5" :class="{ 'pointer-events-none opacity-60': isSavingDocument }" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }" :aria-disabled="isSavingDocument ? 'true' : undefined">
|
||||
<div class="json-edit-brace">{</div>
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!isSavingDocument && !field.readonlyValue" @remove="requestRemoveField(idx)" />
|
||||
|
||||
<div class="json-edit-brace">}</div>
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" :disabled="isSavingDocument" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
|
||||
<div class="json-edit-brace">}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2487,8 +2487,16 @@ export default {
|
||||
selectDocument: "Select a document",
|
||||
readonlyId: "_id is read-only",
|
||||
invalidJsonValue: "This cell is not a valid JSON value",
|
||||
invalidJson: "Invalid JSON",
|
||||
documentMustBeObject: "Document JSON must be an object",
|
||||
unsupportedJsonNumber: "JSON contains a number outside MongoDB int64 range",
|
||||
duplicateJsonKey: "Duplicate JSON key: {field}",
|
||||
duplicateField: "Duplicate field name: {field}",
|
||||
edit: "Edit",
|
||||
editModeFields: "Fields",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "JSON save replaces the whole document. Changing _id creates a new document and deletes the old one.",
|
||||
jsonIdRequired: "A valid _id is required when changing document identity.",
|
||||
documentView: "Document View",
|
||||
tableView: "Table View",
|
||||
filterPlaceholder: "Filter...",
|
||||
|
||||
@@ -2336,8 +2336,16 @@ export default withEnglishFallback({
|
||||
selectDocument: "Selecciona un documento",
|
||||
readonlyId: "_id es de solo lectura",
|
||||
invalidJsonValue: "Esta celda no contiene un valor JSON válido",
|
||||
invalidJson: "JSON no válido",
|
||||
documentMustBeObject: "El JSON del documento debe ser un objeto",
|
||||
unsupportedJsonNumber: "El JSON contiene un número fuera del rango int64 de MongoDB",
|
||||
duplicateJsonKey: "Clave JSON duplicada: {field}",
|
||||
duplicateField: "Nombre de campo duplicado: {field}",
|
||||
edit: "Editar",
|
||||
editModeFields: "Campos",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "Guardar en JSON reemplaza todo el documento. Cambiar _id crea un documento nuevo y elimina el anterior.",
|
||||
jsonIdRequired: "Se requiere un _id válido al cambiar la identidad del documento.",
|
||||
documentView: "Vista de documento",
|
||||
tableView: "Vista de tabla",
|
||||
filterPlaceholder: "Filtro...",
|
||||
|
||||
@@ -2334,8 +2334,16 @@ export default withEnglishFallback({
|
||||
selectDocument: "Seleziona un documento",
|
||||
readonlyId: "_id è in sola lettura",
|
||||
invalidJsonValue: "Questa cella non contiene un valore JSON valido",
|
||||
invalidJson: "JSON non valido",
|
||||
documentMustBeObject: "Il JSON del documento deve essere un oggetto",
|
||||
unsupportedJsonNumber: "Il JSON contiene un numero fuori dall'intervallo int64 di MongoDB",
|
||||
duplicateJsonKey: "Chiave JSON duplicata: {field}",
|
||||
duplicateField: "Nome campo duplicato: {field}",
|
||||
edit: "Modifica",
|
||||
editModeFields: "Campi",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "Il salvataggio JSON sostituisce l'intero documento. Cambiare _id crea un nuovo documento ed elimina quello vecchio.",
|
||||
jsonIdRequired: "Serve un _id valido quando si modifica l'identità del documento.",
|
||||
documentView: "Visualizzazione Documento",
|
||||
tableView: "Visualizzazione Tabella",
|
||||
filterPlaceholder: "Filtra...",
|
||||
|
||||
@@ -2335,8 +2335,16 @@ export default withEnglishFallback({
|
||||
selectDocument: "ドキュメントを選択",
|
||||
readonlyId: "_idは読み取り専用です",
|
||||
invalidJsonValue: "このセルは有効なJSON値ではありません",
|
||||
invalidJson: "無効なJSON",
|
||||
documentMustBeObject: "ドキュメントJSONはオブジェクトである必要があります",
|
||||
unsupportedJsonNumber: "JSON に MongoDB の int64 範囲外の数値が含まれています",
|
||||
duplicateJsonKey: "JSON キーが重複しています: {field}",
|
||||
duplicateField: "重複するフィールド名: {field}",
|
||||
edit: "編集",
|
||||
editModeFields: "フィールド",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "JSON 保存はドキュメント全体を置換します。_id を変更すると新しいドキュメントを作成し、古いドキュメントを削除します。",
|
||||
jsonIdRequired: "ドキュメント識別子を変更する場合は有効な _id が必要です。",
|
||||
documentView: "ドキュメントビュー",
|
||||
tableView: "テーブルビュー",
|
||||
filterPlaceholder: "フィルター...",
|
||||
|
||||
@@ -2336,8 +2336,16 @@ export default withEnglishFallback({
|
||||
selectDocument: "Selecione um documento",
|
||||
readonlyId: "_id é somente leitura",
|
||||
invalidJsonValue: "Esta célula não é um valor JSON válido",
|
||||
invalidJson: "JSON inválido",
|
||||
documentMustBeObject: "O JSON do documento deve ser um objeto",
|
||||
unsupportedJsonNumber: "O JSON contém um número fora do intervalo int64 do MongoDB",
|
||||
duplicateJsonKey: "Chave JSON duplicada: {field}",
|
||||
duplicateField: "Nome de campo duplicado: {field}",
|
||||
edit: "Editar",
|
||||
editModeFields: "Campos",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "Salvar em JSON substitui o documento inteiro. Alterar _id cria um novo documento e remove o antigo.",
|
||||
jsonIdRequired: "É necessário um _id válido ao alterar a identidade do documento.",
|
||||
documentView: "Visão de documento",
|
||||
tableView: "Visão de tabela",
|
||||
filterPlaceholder: "Filtrar...",
|
||||
|
||||
@@ -2457,8 +2457,16 @@ export default withEnglishFallback({
|
||||
selectDocument: "选择一个文档",
|
||||
readonlyId: "_id 只读",
|
||||
invalidJsonValue: "当前单元格不是合法 JSON 值",
|
||||
invalidJson: "不是合法的 JSON",
|
||||
documentMustBeObject: "文档 JSON 必须是对象",
|
||||
unsupportedJsonNumber: "JSON 中包含超出 MongoDB int64 范围的数字",
|
||||
duplicateJsonKey: "JSON 字段重复:{field}",
|
||||
duplicateField: "字段名重复:{field}",
|
||||
edit: "编辑",
|
||||
editModeFields: "字段",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "JSON 保存会整体替换文档。修改 _id 会新建文档并删除旧文档。",
|
||||
jsonIdRequired: "修改文档标识时必须提供有效的 _id。",
|
||||
documentView: "文档视图",
|
||||
tableView: "表格视图",
|
||||
filterPlaceholder: "过滤条件...",
|
||||
|
||||
@@ -2192,8 +2192,16 @@ export default withEnglishFallback({
|
||||
selectDocument: "選擇一個文件",
|
||||
readonlyId: "_id 唯讀",
|
||||
invalidJsonValue: "目前儲存格不是合法 JSON 值",
|
||||
invalidJson: "不是合法的 JSON",
|
||||
documentMustBeObject: "文件 JSON 必須是物件",
|
||||
unsupportedJsonNumber: "JSON 包含超出 MongoDB int64 範圍的數字",
|
||||
duplicateJsonKey: "JSON 欄位重複:{field}",
|
||||
duplicateField: "欄位名重複:{field}",
|
||||
edit: "編輯",
|
||||
editModeFields: "欄位",
|
||||
editModeJson: "JSON",
|
||||
jsonReplaceHint: "JSON 儲存會整份取代文件。修改 _id 會建立新文件並刪除舊文件。",
|
||||
jsonIdRequired: "變更文件識別時必須提供有效的 _id。",
|
||||
documentView: "文件檢視",
|
||||
tableView: "表格檢視",
|
||||
filterPlaceholder: "過濾條件……",
|
||||
|
||||
@@ -2,6 +2,19 @@ import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, stringifyJsonPre
|
||||
import { parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import type { DocumentStoreKind } from "@/lib/app/documentStoreProvider";
|
||||
|
||||
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
const MIN_BSON_INT64 = -9223372036854775808n;
|
||||
const MAX_BSON_INT64 = 9223372036854775807n;
|
||||
|
||||
export type ParseDocumentStoreJsonDocumentError = "empty" | "invalid" | "not-object" | "unsupported-number" | "duplicate-key";
|
||||
|
||||
export type ParseDocumentStoreJsonDocumentResult = { ok: true; document: Record<string, unknown> } | { ok: false; error: Exclude<ParseDocumentStoreJsonDocumentError, "duplicate-key"> } | { ok: false; error: "duplicate-key"; field: string };
|
||||
|
||||
export type PrepareDocumentStoreWriteDocumentOptions = {
|
||||
kind: DocumentStoreKind;
|
||||
mode: "insert" | "update";
|
||||
};
|
||||
|
||||
export function parseDocumentStoreInputValue(raw: MongoInputValue, kind: DocumentStoreKind): unknown {
|
||||
if (kind === "mongodb") return parseMongoDocumentInputValue(raw);
|
||||
if (raw === null || typeof raw === "number" || typeof raw === "boolean") return raw;
|
||||
@@ -28,3 +41,361 @@ export function documentStoreValueForGrid(value: unknown, kind: DocumentStoreKin
|
||||
export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind): string {
|
||||
return kind === "elasticsearch" ? String(value) : serializeMongoDocumentId(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a whole document JSON payload for MongoDB / Elasticsearch editors.
|
||||
* Accepts standard JSON and Extended JSON objects; rejects non-object roots.
|
||||
* Duplicate object keys are rejected instead of being silently collapsed by JSON.parse.
|
||||
*/
|
||||
export function parseDocumentStoreJsonDocument(text: string, kind: DocumentStoreKind): ParseDocumentStoreJsonDocumentResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { ok: false, error: "empty" };
|
||||
|
||||
const duplicateKey = findDuplicateJsonObjectKey(trimmed);
|
||||
if (duplicateKey) return { ok: false, error: "duplicate-key", field: duplicateKey };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonPreservingLargeNumbers(trimmed);
|
||||
} catch {
|
||||
return { ok: false, error: "invalid" };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: "not-object" };
|
||||
}
|
||||
|
||||
try {
|
||||
const document = kind === "mongodb" ? (convertMongoJsonValue(parsed) as Record<string, unknown>) : (parsed as Record<string, unknown>);
|
||||
return { ok: true, document };
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedMongoJsonNumberError) return { ok: false, error: "unsupported-number" };
|
||||
return { ok: false, error: "invalid" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a parsed document for insert/update.
|
||||
* - Updates drop `_id` from the body (identity is applied via the write path).
|
||||
* - Elasticsearch always drops `_routing` from the body; routing is an API argument on insert/update/delete.
|
||||
* - Identity changes are planned by the caller (write under the new id/routing, then delete the old document).
|
||||
*/
|
||||
export function prepareDocumentStoreWriteDocument(document: Record<string, unknown>, options: PrepareDocumentStoreWriteDocumentOptions): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...document };
|
||||
|
||||
if (options.mode === "update" && Object.prototype.hasOwnProperty.call(next, "_id")) {
|
||||
delete next._id;
|
||||
}
|
||||
|
||||
if (options.kind === "elasticsearch" && Object.prototype.hasOwnProperty.call(next, "_routing")) {
|
||||
delete next._routing;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function documentStoreIdsEqual(left: unknown, right: unknown, kind: DocumentStoreKind): boolean {
|
||||
if (left === right) return true;
|
||||
if (left == null || right == null) return left == null && right == null;
|
||||
try {
|
||||
return serializeDocumentStoreId(left, kind) === serializeDocumentStoreId(right, kind);
|
||||
} catch {
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Root identity metadata field (`_id`, and Elasticsearch `_routing`). */
|
||||
export function isDocumentStoreIdentityField(kind: DocumentStoreKind, name: string): boolean {
|
||||
if (name === "_id") return true;
|
||||
return kind === "elasticsearch" && name === "_routing";
|
||||
}
|
||||
|
||||
/** Normalize Elasticsearch custom routing for API write/delete arguments. */
|
||||
export function normalizeDocumentStoreRouting(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
const routing = typeof value === "string" ? value : String(value);
|
||||
const trimmed = routing.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve write routing from an edited document payload.
|
||||
* - Present `_routing` key uses the normalized value (empty/null clears routing).
|
||||
* - Absent `_routing` key keeps the current document routing.
|
||||
*/
|
||||
export function resolveDocumentStoreWriteRouting(nextDocument: Record<string, unknown>, currentRouting: string | undefined): string | undefined {
|
||||
if (Object.prototype.hasOwnProperty.call(nextDocument, "_routing")) {
|
||||
return normalizeDocumentStoreRouting(nextDocument._routing);
|
||||
}
|
||||
return currentRouting;
|
||||
}
|
||||
|
||||
export type DocumentStoreIdentityCoords = {
|
||||
id: string;
|
||||
routing?: string;
|
||||
};
|
||||
|
||||
export type DocumentStoreIdentityPlan =
|
||||
| { action: "replace"; writeId: string; writeRouting?: string }
|
||||
| {
|
||||
action: "rekey";
|
||||
writeId: string;
|
||||
writeRouting?: string;
|
||||
deleteId: string;
|
||||
deleteRouting?: string;
|
||||
};
|
||||
|
||||
/** True when two document identities (path id + optional ES routing) are equal. */
|
||||
export function documentStoreIdentityEquals(left: DocumentStoreIdentityCoords, right: DocumentStoreIdentityCoords): boolean {
|
||||
return left.id === right.id && left.routing === right.routing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan how to save relative to the currently selected identity.
|
||||
* Callers must resolve path ids and routing first; this only compares string coordinates.
|
||||
*
|
||||
* Elasticsearch treats custom routing as part of identity: routing-only changes rekey
|
||||
* (write under the new routing, then delete under the old routing after a successful write).
|
||||
*/
|
||||
export function planDocumentStoreIdentityMigration(options: { write: DocumentStoreIdentityCoords; current: DocumentStoreIdentityCoords }): DocumentStoreIdentityPlan {
|
||||
if (documentStoreIdentityEquals(options.write, options.current)) {
|
||||
return { action: "replace", writeId: options.write.id, writeRouting: options.write.routing };
|
||||
}
|
||||
return {
|
||||
action: "rekey",
|
||||
writeId: options.write.id,
|
||||
writeRouting: options.write.routing,
|
||||
deleteId: options.current.id,
|
||||
deleteRouting: options.current.routing,
|
||||
};
|
||||
}
|
||||
|
||||
class UnsupportedMongoJsonNumberError extends Error {
|
||||
constructor(raw: string) {
|
||||
super(`Unsupported MongoDB numeric literal: ${raw}`);
|
||||
this.name = "UnsupportedMongoJsonNumberError";
|
||||
}
|
||||
}
|
||||
|
||||
function convertMongoJsonValue(value: unknown): unknown {
|
||||
if (isLosslessJsonNumber(value)) return convertMongoLosslessNumber(value.raw);
|
||||
|
||||
if (Array.isArray(value)) return value.map((item) => convertMongoJsonValue(item));
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, convertMongoJsonValue(item)]));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function convertMongoLosslessNumber(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
try {
|
||||
const integer = BigInt(trimmed);
|
||||
if (integer > MAX_SAFE_BIGINT || integer < -MAX_SAFE_BIGINT) {
|
||||
if (integer >= MIN_BSON_INT64 && integer <= MAX_BSON_INT64) return { $numberLong: trimmed };
|
||||
throw new UnsupportedMongoJsonNumberError(trimmed);
|
||||
}
|
||||
return Number(trimmed);
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedMongoJsonNumberError) throw error;
|
||||
throw new UnsupportedMongoJsonNumberError(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
return Number.isFinite(numeric) ? numeric : trimmed;
|
||||
}
|
||||
|
||||
function findDuplicateJsonObjectKey(text: string): string | null {
|
||||
try {
|
||||
const scanner = new LightweightJsonScanner(text);
|
||||
return scanJsonValueForDuplicateKeys(scanner);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function scanJsonValueForDuplicateKeys(scanner: LightweightJsonScanner): string | null {
|
||||
scanner.skipWhitespace();
|
||||
const ch = scanner.peek();
|
||||
if (ch === "{") return scanJsonObjectForDuplicateKeys(scanner);
|
||||
if (ch === "[") return scanJsonArrayForDuplicateKeys(scanner);
|
||||
scanner.readPrimitive();
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanJsonObjectForDuplicateKeys(scanner: LightweightJsonScanner): string | null {
|
||||
scanner.expect("{");
|
||||
scanner.skipWhitespace();
|
||||
if (scanner.peek() === "}") {
|
||||
scanner.position += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
while (true) {
|
||||
const key = scanner.readStringValue();
|
||||
if (seen.has(key)) return key;
|
||||
seen.add(key);
|
||||
scanner.expect(":");
|
||||
const nested = scanJsonValueForDuplicateKeys(scanner);
|
||||
if (nested) return nested;
|
||||
scanner.skipWhitespace();
|
||||
const next = scanner.peek();
|
||||
if (next === ",") {
|
||||
scanner.position += 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "}") {
|
||||
scanner.position += 1;
|
||||
return null;
|
||||
}
|
||||
throw new SyntaxError(`Expected ',' or '}' at position ${scanner.position}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scanJsonArrayForDuplicateKeys(scanner: LightweightJsonScanner): string | null {
|
||||
scanner.expect("[");
|
||||
scanner.skipWhitespace();
|
||||
if (scanner.peek() === "]") {
|
||||
scanner.position += 1;
|
||||
return null;
|
||||
}
|
||||
while (true) {
|
||||
const nested = scanJsonValueForDuplicateKeys(scanner);
|
||||
if (nested) return nested;
|
||||
scanner.skipWhitespace();
|
||||
const next = scanner.peek();
|
||||
if (next === ",") {
|
||||
scanner.position += 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "]") {
|
||||
scanner.position += 1;
|
||||
return null;
|
||||
}
|
||||
throw new SyntaxError(`Expected ',' or ']' at position ${scanner.position}`);
|
||||
}
|
||||
}
|
||||
|
||||
class LightweightJsonScanner {
|
||||
readonly text: string;
|
||||
position = 0;
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
peek(): string | undefined {
|
||||
return this.text[this.position];
|
||||
}
|
||||
|
||||
skipWhitespace() {
|
||||
while (this.position < this.text.length) {
|
||||
const character = this.text[this.position];
|
||||
if (character === " " || character === "\t" || character === "\n" || character === "\r") {
|
||||
this.position += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(character: string) {
|
||||
this.skipWhitespace();
|
||||
if (this.text[this.position] !== character) {
|
||||
throw new SyntaxError(`Expected '${character}' at position ${this.position}`);
|
||||
}
|
||||
this.position += 1;
|
||||
}
|
||||
|
||||
readStringValue(): string {
|
||||
this.skipWhitespace();
|
||||
if (this.text[this.position] !== '"') throw new SyntaxError(`Expected string at position ${this.position}`);
|
||||
this.position += 1;
|
||||
let result = "";
|
||||
let escaped = false;
|
||||
while (this.position < this.text.length) {
|
||||
const character = this.text[this.position];
|
||||
if (escaped) {
|
||||
if (character === "u") {
|
||||
const hex = this.text.slice(this.position + 1, this.position + 5);
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new SyntaxError(`Invalid unicode escape at position ${this.position}`);
|
||||
result += String.fromCharCode(parseInt(hex, 16));
|
||||
this.position += 5;
|
||||
} else {
|
||||
const map: Record<string, string> = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
||||
if (!(character in map)) throw new SyntaxError(`Invalid escape sequence at position ${this.position}`);
|
||||
result += map[character];
|
||||
this.position += 1;
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
this.position += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
this.position += 1;
|
||||
return result;
|
||||
}
|
||||
if (character.charCodeAt(0) < 0x20) throw new SyntaxError(`Invalid control character in string at position ${this.position}`);
|
||||
result += character;
|
||||
this.position += 1;
|
||||
}
|
||||
throw new SyntaxError(`Unterminated string at position ${this.position}`);
|
||||
}
|
||||
|
||||
readPrimitive() {
|
||||
this.skipWhitespace();
|
||||
const ch = this.peek();
|
||||
if (ch === '"') {
|
||||
this.readStringValue();
|
||||
return;
|
||||
}
|
||||
if (ch === "-" || (ch !== undefined && ch >= "0" && ch <= "9")) {
|
||||
this.readNumberToken();
|
||||
return;
|
||||
}
|
||||
for (const keyword of ["true", "false", "null"] as const) {
|
||||
if (this.text.startsWith(keyword, this.position)) {
|
||||
this.position += keyword.length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new SyntaxError(`Unexpected token at position ${this.position}`);
|
||||
}
|
||||
|
||||
readNumberToken() {
|
||||
const start = this.position;
|
||||
if (this.text[this.position] === "-") this.position += 1;
|
||||
if (this.text[this.position] === "0") this.position += 1;
|
||||
else if (this.isDigit(this.text[this.position])) {
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
} else throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
if (this.text[this.position] === ".") {
|
||||
this.position += 1;
|
||||
if (!this.isDigit(this.text[this.position])) throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
}
|
||||
if (this.text[this.position] === "e" || this.text[this.position] === "E") {
|
||||
this.position += 1;
|
||||
if (this.text[this.position] === "+" || this.text[this.position] === "-") this.position += 1;
|
||||
if (!this.isDigit(this.text[this.position])) throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private isDigit(character: string | undefined): boolean {
|
||||
return character !== undefined && character >= "0" && character <= "9";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DocumentStoreKind } from "@/lib/app/documentStoreProvider";
|
||||
import { prepareDocumentStoreWriteDocument, stringifyDocumentStoreValue, type DocumentStoreIdentityPlan } from "@/lib/app/documentJsonValues";
|
||||
|
||||
export type DocumentStoreWriteApis = {
|
||||
insert: (docJson: string, routing?: string) => Promise<string>;
|
||||
update: (id: string, docJson: string, routing?: string) => Promise<number>;
|
||||
delete: (id: string, routing?: string) => Promise<number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write a document body under a known identity.
|
||||
* - `put`: Elasticsearch index-by-id / Mongo update-by-id (identity via path, not body).
|
||||
* - `insert`: Mongo insert (and ES auto-id when no explicit id); routing is always an API arg.
|
||||
*/
|
||||
export async function writeDocumentStoreDocument(options: { kind: DocumentStoreKind; op: "put" | "insert"; id?: string; routing?: string; document: Record<string, unknown>; apis: Pick<DocumentStoreWriteApis, "insert" | "update"> }): Promise<void> {
|
||||
const prepared = prepareDocumentStoreWriteDocument(options.document, {
|
||||
kind: options.kind,
|
||||
mode: options.op === "put" ? "update" : "insert",
|
||||
});
|
||||
const body = stringifyDocumentStoreValue(prepared, options.kind);
|
||||
|
||||
if (options.op === "put") {
|
||||
if (!options.id) throw new Error("Document write requires an id");
|
||||
await options.apis.update(options.id, body, options.routing);
|
||||
return;
|
||||
}
|
||||
|
||||
await options.apis.insert(body, options.routing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an identity plan for an existing document save.
|
||||
* Rekey always writes first, then deletes the old identity — a failed write never deletes.
|
||||
* 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;
|
||||
|
||||
if (plan.action === "replace") {
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "put",
|
||||
id: plan.writeId,
|
||||
routing: plan.writeRouting,
|
||||
document,
|
||||
apis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Rekey write: ES uses put under the new id/routing; Mongo inserts a new document then deletes the old id.
|
||||
if (kind === "elasticsearch") {
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "put",
|
||||
id: plan.writeId,
|
||||
routing: plan.writeRouting,
|
||||
document,
|
||||
apis,
|
||||
});
|
||||
} else {
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "insert",
|
||||
document,
|
||||
apis,
|
||||
});
|
||||
}
|
||||
|
||||
// Only reached after a successful write — preserves the old document when write fails.
|
||||
await apis.delete(plan.deleteId, plan.deleteRouting);
|
||||
}
|
||||
|
||||
/** Insert a new document (optional explicit ES id 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 === "elasticsearch" && explicitId) {
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "put",
|
||||
id: explicitId,
|
||||
routing,
|
||||
document,
|
||||
apis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "insert",
|
||||
routing,
|
||||
document,
|
||||
apis,
|
||||
});
|
||||
}
|
||||
@@ -2143,12 +2143,12 @@ export async function mongoDropIndexes(connectionId: string, database: string, c
|
||||
return post("/api/mongo/drop-indexes", { connectionId, database, collection, indexesJson, single });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
|
||||
return documentInsertDocument(connectionId, database, collection, docJson);
|
||||
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
|
||||
return documentInsertDocument(connectionId, database, collection, docJson, routing);
|
||||
}
|
||||
|
||||
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
|
||||
return post("/api/document-store/insert-document", { connectionId, database, collection, docJson });
|
||||
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
|
||||
return post("/api/document-store/insert-document", { connectionId, database, collection, docJson, routing });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocuments(connectionId: string, database: string, collection: string, docsJson: string): Promise<{ affected_rows: number }> {
|
||||
|
||||
@@ -1919,12 +1919,12 @@ export async function mongoDropIndexes(connectionId: string, database: string, c
|
||||
return invoke("mongo_drop_indexes", { connectionId, database, collection, indexesJson, single });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
|
||||
return documentInsertDocument(connectionId, database, collection, docJson);
|
||||
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
|
||||
return documentInsertDocument(connectionId, database, collection, docJson, routing);
|
||||
}
|
||||
|
||||
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
|
||||
return invoke("document_insert_document", { connectionId, database, collection, docJson });
|
||||
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
|
||||
return invoke("document_insert_document", { connectionId, database, collection, docJson, routing });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocuments(connectionId: string, database: string, collection: string, docsJson: string): Promise<{ affected_rows: number }> {
|
||||
|
||||
Reference in new issue
Block a user