diff --git a/src/mcp/mcpController.ts b/src/mcp/mcpController.ts index eac71861e..15f1e272b 100644 --- a/src/mcp/mcpController.ts +++ b/src/mcp/mcpController.ts @@ -22,7 +22,7 @@ import { createMCPConnectionErrorHandler } from './mcpConnectionErrorHandler'; import { getMCPConfigFromVSCodeSettings } from './mcpConfig'; import { DEFAULT_TELEMETRY_APP_NAME } from '../connectionController'; import formatError from '../utils/formatError'; -import { TelemetryService } from '../telemetry'; +import type { TelemetryService } from '../telemetry'; export type MCPServerStartupConfig = | 'prompt' diff --git a/src/test/suite/views/data-browsing-app/gracefulEjsonDeserialize.test.tsx b/src/test/suite/views/data-browsing-app/gracefulEjsonDeserialize.test.tsx new file mode 100644 index 000000000..a6b45193c --- /dev/null +++ b/src/test/suite/views/data-browsing-app/gracefulEjsonDeserialize.test.tsx @@ -0,0 +1,241 @@ +import { expect } from 'chai'; + +import { + gracefullyDeserializeEjson, + toDisplayString, + InvalidBSONValue, +} from '../../../../views/data-browsing-app/gracefulEjsonDeserialize'; + +describe('gracefulEjsonDeserialize', function () { + describe('gracefullyDeserializeEjson', function () { + it('should deserialize a valid EJSON document', function () { + const ejson = { + _id: { $oid: '507f1f77bcf86cd799439011' }, + count: { $numberLong: '42' }, + name: 'hello', + }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result._id).to.have.property('_bsontype', 'ObjectId'); + expect(result.count).to.have.property('_bsontype', 'Long'); + expect(result.name).to.equal('hello'); + }); + + it('should substitute InvalidBSONValue for an invalid $numberLong', function () { + const ejson = { + good: { $numberLong: '42' }, + bad: { $numberLong: 'pineapple' }, + }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result.good).to.have.property('_bsontype', 'Long'); + expect(result.bad).to.be.instanceOf(InvalidBSONValue); + expect((result.bad as InvalidBSONValue).typeName).to.equal('NumberLong'); + }); + + it('should substitute InvalidBSONValue for an invalid $date', function () { + const ejson = { + good: { $date: '2024-01-01T00:00:00Z' }, + bad: { $date: 'pineapple' }, + }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result.good).to.be.instanceOf(Date); + expect(result.bad).to.be.instanceOf(InvalidBSONValue); + expect((result.bad as InvalidBSONValue).typeName).to.equal('Date'); + }); + + it('should substitute InvalidBSONValue for an invalid $oid', function () { + const ejson = { bad: { $oid: 'not-an-objectid' } }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result.bad).to.be.instanceOf(InvalidBSONValue); + expect((result.bad as InvalidBSONValue).typeName).to.equal('Oid'); + }); + + it('should substitute InvalidBSONValue for an invalid $numberDecimal', function () { + const ejson = { + good: { $numberDecimal: '3.14' }, + bad: { $numberDecimal: 'not-a-number' }, + }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result.good).to.have.property('_bsontype', 'Decimal128'); + expect(result.bad).to.be.instanceOf(InvalidBSONValue); + expect((result.bad as InvalidBSONValue).typeName).to.equal( + 'NumberDecimal', + ); + }); + + it('should pass through regular objects with $-prefixed keys', function () { + const ejson = { + query: { $set: { x: 1 } }, + }; + const result = gracefullyDeserializeEjson(ejson); + + // $set is not an EJSON type wrapper, so it should be passed through + // as a regular object. + expect(result.query).to.not.be.instanceOf(InvalidBSONValue); + expect(result.query).to.have.property('$set'); + }); + + it('should not treat a subdocument with mixed $-prefixed and plain keys as an EJSON wrapper', function () { + const ejson = { + item: { $date: '2024-01-01T00:00:00Z', extra: 'field' }, + }; + const result = gracefullyDeserializeEjson(ejson); + + // Has a non-$-prefixed key alongside $date, so it's a plain object, + // not an EJSON type wrapper. + expect(result.item).to.not.be.instanceOf(InvalidBSONValue); + const item = result.item as Record; + expect(item.extra).to.equal('field'); + // The $date field inside should be deserialized individually. + expect(item.$date).to.equal('2024-01-01T00:00:00Z'); + }); + + it('should handle nested documents with mixed valid/invalid values', function () { + const ejson = { + _id: { $oid: '507f1f77bcf86cd799439011' }, + nested: { + ok: { $numberLong: '42' }, + bad: { $numberLong: 'NaN' }, + deep: { + name: 'test', + also_bad: { $date: 'garbage' }, + }, + }, + }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result._id).to.have.property('_bsontype', 'ObjectId'); + const nested = result.nested as Record; + expect(nested.ok).to.have.property('_bsontype', 'Long'); + expect(nested.bad).to.be.instanceOf(InvalidBSONValue); + const deep = nested.deep as Record; + expect(deep.name).to.equal('test'); + expect(deep.also_bad).to.be.instanceOf(InvalidBSONValue); + }); + + it('should handle arrays with mixed valid/invalid values', function () { + const ejson = { + items: [{ $numberLong: '1' }, { $numberLong: 'bad' }, 'plain string'], + }; + const result = gracefullyDeserializeEjson(ejson); + + const items = result.items as unknown[]; + expect(items[0]).to.have.property('_bsontype', 'Long'); + expect(items[1]).to.be.instanceOf(InvalidBSONValue); + expect(items[2]).to.equal('plain string'); + }); + + it('should handle primitives and nulls', function () { + const ejson = { + str: 'hello', + num: 42, + bool: true, + nil: null, + }; + const result = gracefullyDeserializeEjson(ejson); + + expect(result.str).to.equal('hello'); + expect(result.num).to.equal(42); + expect(result.bool).to.equal(true); + expect(result.nil).to.equal(null); + }); + }); + + describe('toDisplayString', function () { + it('should render InvalidBSONValue as unquoted text', function () { + const doc = gracefullyDeserializeEjson({ + bad: { $numberLong: 'pineapple' }, + }); + const output = toDisplayString(doc); + + // Should contain "Invalid NumberLong" without quotes around it + expect(output).to.include('Invalid NumberLong'); + // Should NOT be quoted like a string + expect(output).to.not.include("'Invalid NumberLong'"); + expect(output).to.not.include('"Invalid NumberLong"'); + }); + + it('should render valid BSON types in shell syntax', function () { + const doc = gracefullyDeserializeEjson({ + _id: { $oid: '507f1f77bcf86cd799439011' }, + count: { $numberLong: '42' }, + }); + const output = toDisplayString(doc); + + expect(output).to.include("ObjectId('507f1f77bcf86cd799439011')"); + expect(output).to.include("NumberLong('42')"); + }); + + it('should render a mix of valid, invalid, and primitive values', function () { + const doc = gracefullyDeserializeEjson({ + _id: { $oid: '507f1f77bcf86cd799439011' }, + bad: { $numberLong: 'NaN' }, + name: 'hello', + }); + const output = toDisplayString(doc); + + expect(output).to.include("ObjectId('507f1f77bcf86cd799439011')"); + expect(output).to.include('Invalid NumberLong'); + expect(output).to.include("'hello'"); + }); + + it('should render nested objects with proper indentation', function () { + const doc = gracefullyDeserializeEjson({ + nested: { + ok: { $numberLong: '42' }, + bad: { $date: 'garbage' }, + }, + }); + const output = toDisplayString(doc); + + expect(output).to.include('nested: {'); + expect(output).to.include("NumberLong('42')"); + expect(output).to.include('Invalid Date'); + }); + + it('should render arrays', function () { + const doc = gracefullyDeserializeEjson({ + items: [{ $numberLong: '1' }, { $numberLong: 'bad' }, 'plain'], + }); + const output = toDisplayString(doc); + + expect(output).to.include("NumberLong('1')"); + expect(output).to.include('Invalid NumberLong'); + expect(output).to.include("'plain'"); + }); + + it('should quote keys that are not valid JS identifiers', function () { + const doc = gracefullyDeserializeEjson({ + valid_key: 'a', + 'with space': 'b', + '123start': 'c', + 'with-hyphen': 'd', + "it's": 'e', + }); + const output = toDisplayString(doc); + + expect(output).to.include("valid_key: 'a'"); + expect(output).to.include("'with space': 'b'"); + expect(output).to.include("'123start': 'c'"); + expect(output).to.include("'with-hyphen': 'd'"); + expect(output).to.include("'it\\'s': 'e'"); + }); + + it('should render empty objects and arrays', function () { + const doc = gracefullyDeserializeEjson({ + emptyObj: {}, + emptyArr: [], + }); + const output = toDisplayString(doc); + + // The formatter might represent these differently, but they should + // not cause errors. + expect(output).to.be.a('string'); + expect(output.length).to.be.greaterThan(0); + }); + }); +}); diff --git a/src/views/data-browsing-app/gracefulEjsonDeserialize.ts b/src/views/data-browsing-app/gracefulEjsonDeserialize.ts new file mode 100644 index 000000000..44d12ff8d --- /dev/null +++ b/src/views/data-browsing-app/gracefulEjsonDeserialize.ts @@ -0,0 +1,229 @@ +import { EJSON, type Document } from 'bson'; +import { toJSString } from 'mongodb-query-parser'; + +/** + * Sentinel class used to represent a BSON value that could not be + * deserialized from EJSON. The custom stringifier (`toDisplayString`) + * recognises instances of this class and emits an unquoted + * `Invalid ` literal in the output. + */ +export class InvalidBSONValue { + readonly typeName: string; + constructor(typeName: string) { + this.typeName = typeName; + } +} + +/** + * Returns a human-readable label derived from the first `$`-prefixed key in + * `obj`, or `null` if the object has no such key. Used only as a fallback + * label when EJSON.deserialize throws for this value. + * + * Strips the leading `$` and capitalizes the remainder: + * `$numberLong` → `"NumberLong"`, `$date` → `"Date"`. + */ +function labelFromDollarKey(obj: Record): string | null { + for (const key of Object.keys(obj)) { + if (key.startsWith('$')) { + const raw = key.slice(1); + return raw.charAt(0).toUpperCase() + raw.slice(1); + } + } + return null; +} + +/** + * Returns `true` if `obj` structurally matches an EJSON type wrapper: + * 1–2 keys, all `$`-prefixed. This rejects plain subdocuments that + * merely happen to contain a `$`-prefixed field alongside other keys. + */ +function looksLikeEjsonWrapper(obj: Record): boolean { + const keys = Object.keys(obj); + return ( + (keys.length === 1 || keys.length === 2) && + keys.every((k) => k.startsWith('$')) + ); +} + +/** + * Returns `true` if `val` looks like a successfully-deserialized BSON + * value (i.e. it is no longer a plain EJSON type wrapper). + */ +function isDeserializedBsonValue(val: unknown): boolean { + if (val === null || typeof val !== 'object') return false; + if ('_bsontype' in (val as Record)) return true; + if (val instanceof Date) return true; + if (val instanceof RegExp) return true; + return false; +} + +/** + * Attempts to deserialize a single EJSON value by wrapping it in a parent + * document and calling `EJSON.deserialize`. + * + * Returns the deserialized BSON value on success, an `InvalidBSONValue` + * sentinel when the value is a recognized EJSON type wrapper that cannot + * be deserialized, or `null` when the value is not an EJSON type wrapper + * (i.e. it is a regular sub-document that should be recursed into). + */ +function tryDeserializeValue(value: Record): unknown | null { + try { + const result = EJSON.deserialize({ _: value } as unknown as Document, { + relaxed: false, + }); + const deserialized = (result as Record)._; + + if (isDeserializedBsonValue(deserialized)) { + // Special-case: `{ $date: 'pineapple' }` produces an Invalid Date + // rather than throwing. + if (deserialized instanceof Date && isNaN(deserialized.getTime())) { + return new InvalidBSONValue(labelFromDollarKey(value) ?? 'Date'); + } + return deserialized; + } + + // EJSON didn't convert it into a BSON type → treat as a regular object. + return null; + } catch { + // EJSON recognised it as a type wrapper but the value is invalid. + return new InvalidBSONValue(labelFromDollarKey(value) ?? 'Value'); + } +} + +/** + * Recursively walks an EJSON-serialized value and deserialises it field by + * field. When a single EJSON type wrapper (e.g. `{ $numberLong: "NaN" }`) + * cannot be deserialized, an `InvalidBSONValue` instance is substituted so + * that the rest of the document is still visible. + */ +function walkAndDeserialize(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (typeof value !== 'object') return value; + + if (Array.isArray(value)) { + return value.map(walkAndDeserialize); + } + + const obj = value as Record; + + // Only attempt EJSON deserialization on objects that structurally match + // EJSON type wrappers (1–2 keys, all `$`-prefixed). Plain sub-documents + // are recursed into field-by-field so that a single invalid value doesn't + // poison the entire parent object. + if (looksLikeEjsonWrapper(obj)) { + const attempted = tryDeserializeValue(obj); + if (attempted !== null) { + return attempted; + } + } + + // Regular object – recurse into its properties. + const result: Record = Object.create(null); + for (const [key, val] of Object.entries(obj)) { + result[key] = walkAndDeserialize(val); + } + return result; +} + +/** + * Deserializes an EJSON-serialized document gracefully: fields whose EJSON + * values are invalid are replaced with `InvalidBSONValue` instances instead + * of throwing an error for the whole document. + * + * Note: a whole-document `EJSON.deserialize` fast path is intentionally + * avoided because it produces different semantics — plain numbers become + * Int32/Long with `relaxed: false`, invalid dates silently become + * `Invalid Date` objects instead of `InvalidBSONValue`, and extra keys + * on EJSON-like wrappers are silently dropped. + */ +export function gracefullyDeserializeEjson( + document: Record, +): Record { + return walkAndDeserialize(document) as Record; +} + +/** + * Stringifies a single value to its shell-syntax representation. + * - `InvalidBSONValue` → unquoted `Invalid ` + * - BSON types → delegates to `toJSString` + * - Primitives / Date / RegExp → `toJSString` + * - Plain objects and arrays → returns `null` so the caller recurses + */ +function stringifyLeaf(value: unknown): string | null { + if (value instanceof InvalidBSONValue) { + return `Invalid ${value.typeName}`; + } + + // Plain objects and arrays must be handled by the recursive formatter + // so that nested InvalidBSONValue instances are properly rendered. + if ( + Array.isArray(value) || + (value !== null && + typeof value === 'object' && + !isDeserializedBsonValue(value)) + ) { + return null; + } + + const s = toJSString(value); + if (s !== undefined) return s; + return null; +} + +/** Matches keys that are valid unquoted JS identifiers (ASCII subset). */ +const SAFE_KEY = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +/** Quotes a key if it isn't a safe unquoted identifier. */ +function formatKey(key: string): string { + return SAFE_KEY.test(key) + ? key + : `'${key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +/** + * Recursively formats a value into an indented shell-syntax string. + */ +function formatValue(value: unknown, indent: number, depth: number): string { + // Try leaf stringification first (primitives, BSON types, InvalidBSONValue). + const leaf = stringifyLeaf(value); + if (leaf !== null) return leaf; + + const pad = ' '.repeat(indent * (depth + 1)); + const closePad = ' '.repeat(indent * depth); + + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const items = value.map( + (v) => `${pad}${formatValue(v, indent, depth + 1)}`, + ); + return `[\n${items.join(',\n')}\n${closePad}]`; + } + + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) return '{}'; + const fields = entries.map( + ([key, val]) => + `${pad}${formatKey(key)}: ${formatValue(val, indent, depth + 1)}`, + ); + return `{\n${fields.join(',\n')}\n${closePad}}`; + } + + // Fallback – shouldn't normally be reached. + return String(value); +} + +/** + * Converts a deserialized document (which may contain `InvalidBSONValue` + * instances) into a shell-syntax display string. + * + * Walks the object tree, delegates to `toJSString` for BSON types and + * primitives, and emits unquoted `Invalid ` for `InvalidBSONValue` + * instances. + */ +export function toDisplayString( + doc: Record, + indent = 2, +): string { + return formatValue(doc, indent, 0); +} diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index 0b3424fa8..d7d4e8579 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -13,8 +13,10 @@ import type { TokenColors, MonacoBaseTheme, } from './extension-app-message-constants'; -import { toJSString } from 'mongodb-query-parser'; -import { EJSON } from 'bson'; +import { + gracefullyDeserializeEjson, + toDisplayString, +} from './gracefulEjsonDeserialize'; import { sendEditDocument, sendCloneDocument, @@ -285,12 +287,8 @@ const MonacoViewer: React.FC = ({ }, [monaco, colors, themeKind]); const documentString = useMemo(() => { - try { - const deserialized = EJSON.deserialize(document, { relaxed: false }); - return toJSString(deserialized) ?? ''; - } catch (e) { - return `Failed to deserialize and render document: ${(e as Error).message}`; - } + const deserialized = gracefullyDeserializeEjson(document); + return toDisplayString(deserialized); }, [document]); const calculateHeight = useCallback(() => {