Files
dbx/apps/desktop/src/lib/app/documentJsonValues.ts
T

412 lines
15 KiB
TypeScript

import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, stringifyJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
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;
const trimmed = raw.trim();
if (!trimmed) return "";
try {
return parseJsonPreservingLargeNumbers(trimmed);
} catch {
return trimmed;
}
}
export function stringifyDocumentStoreValue(value: unknown, kind: DocumentStoreKind, indent?: number): string {
return kind === "mongodb" ? JSON.stringify(value, null, indent ?? undefined) : stringifyJsonPreservingLargeNumbers(value, indent);
}
export function documentStoreValueForGrid(value: unknown, kind: DocumentStoreKind): MongoInputValue {
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
return stringifyDocumentStoreValue(value, kind);
}
export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind): string {
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.
* 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";
}
}