diff --git a/packages/driver/src/baseClient.ts b/packages/driver/src/baseClient.ts index cabde36a5..12a272474 100644 --- a/packages/driver/src/baseClient.ts +++ b/packages/driver/src/baseClient.ts @@ -37,6 +37,7 @@ import type { SimpleRetryOptions, SimpleTransactionOptions, TransactionOptions, + WarningHandler, } from "./options"; import { Options } from "./options"; import Event from "./primitives/event"; @@ -178,17 +179,20 @@ export class ClientConnectionHolder { outputFormat: OutputFormat, expectedCardinality: Cardinality, ): Promise { - let result: any; for (let iteration = 0; ; ++iteration) { const conn = await this._getConnection(); try { - result = await conn.fetch( + const { result, warnings } = await conn.fetch( query, args, outputFormat, expectedCardinality, this.options.session, ); + if (warnings.length) { + this.options.warningHandler(warnings); + } + return result; } catch (err) { if ( err instanceof errors.EdgeDBError && @@ -210,12 +214,11 @@ export class ClientConnectionHolder { } throw err; } - return result; } } async execute(query: string, args?: QueryArgs): Promise { - return this.retryingFetch( + await this.retryingFetch( query, args, OutputFormat.NONE, @@ -576,6 +579,10 @@ export class Client implements Executor { ); } + withWarningHandler(handler: WarningHandler): Client { + return new Client(this.pool, this.options.withWarningHandler(handler)); + } + async ensureConnected(): Promise { await this.pool.ensureConnected(); return this; diff --git a/packages/driver/src/baseConn.ts b/packages/driver/src/baseConn.ts index d101f96bd..5147937c5 100644 --- a/packages/driver/src/baseConn.ts +++ b/packages/driver/src/baseConn.ts @@ -24,7 +24,7 @@ import type { CodecsRegistry } from "./codecs/registry"; import { EmptyTupleCodec, EMPTY_TUPLE_CODEC, TupleCodec } from "./codecs/tuple"; import { versionGreaterThanOrEqual } from "./utils"; import * as errors from "./errors"; -import { resolveErrorCode } from "./errors/resolve"; +import { resolveErrorCode, errorFromJSON } from "./errors/resolve"; import type { QueryOptions, ProtocolVersion, @@ -102,6 +102,7 @@ export type ParseResult = [ capabilities: number, inCodecBuffer: Uint8Array | null, outCodecBuffer: Uint8Array | null, + warnings: errors.EdgeDBError[], ]; export type connConstructor = new ( @@ -193,6 +194,17 @@ export class BaseRawConnection { } } + protected _readHeaders(): Record { + const numFields = this.buffer.readInt16(); + const headers: Record = {}; + for (let i = 0; i < numFields; i++) { + const key = this.buffer.readString(); + const value = this.buffer.readString(); + headers[key] = value; + } + return headers; + } + protected _abortWaiters(err: Error): void { if (!this.connWaiter.done) { this.connWaiter.setError(err); @@ -213,15 +225,19 @@ export class BaseRawConnection { return ret; } - private _parseDescribeTypeMessage(): [ + private _parseDescribeTypeMessage( + query: string, + ): [ Cardinality, ICodec, ICodec, number, Uint8Array, Uint8Array, + errors.EdgeDBError[], ] { let capabilities = -1; + let warnings: errors.EdgeDBError[] = []; if (this.isLegacyProtocol) { const headers = this._parseHeaders(); if (headers.has(LegacyHeaderCodes.capabilities)) { @@ -233,7 +249,14 @@ export class BaseRawConnection { ); } } else { - this._ignoreHeaders(); + const headers = this._readHeaders(); + if (headers["warnings"] != null) { + warnings = JSON.parse(headers.warnings).map((warning: any) => { + const err = errorFromJSON(warning); + (err as any)._query = query; + return err; + }); + } capabilities = Number(this.buffer.readBigInt64()); } @@ -270,6 +293,7 @@ export class BaseRawConnection { capabilities, inTypeData, outTypeData, + warnings, ]; } @@ -591,7 +615,7 @@ export class BaseRawConnection { capabilities, inCodecData, outCodecData, - ] = this._parseDescribeTypeMessage(); + ] = this._parseDescribeTypeMessage(query); } catch (e: any) { error = e; } @@ -815,7 +839,7 @@ export class BaseRawConnection { case chars.$T: { try { [newCard, inCodec, outCodec, capabilities] = - this._parseDescribeTypeMessage(); + this._parseDescribeTypeMessage(query); const key = this._getQueryCacheKey( query, outputFormat, @@ -944,6 +968,7 @@ export class BaseRawConnection { let outCodec: ICodec | null = null; let inCodecBuf: Uint8Array | null = null; let outCodecBuf: Uint8Array | null = null; + let warnings: errors.EdgeDBError[] = []; while (parsing) { if (!this.buffer.takeMessage()) { @@ -962,7 +987,8 @@ export class BaseRawConnection { capabilities, inCodecBuf, outCodecBuf, - ] = this._parseDescribeTypeMessage(); + warnings, + ] = this._parseDescribeTypeMessage(query); const key = this._getQueryCacheKey( query, outputFormat, @@ -1023,6 +1049,7 @@ export class BaseRawConnection { capabilities, inCodecBuf, outCodecBuf, + warnings, ]; } @@ -1037,7 +1064,7 @@ export class BaseRawConnection { result: any[] | WriteBuffer, capabilitiesFlags: number = RESTRICTED_CAPABILITIES, options?: QueryOptions, - ): Promise { + ): Promise { const wb = new WriteMessageBuffer(); wb.beginMessage(chars.$O); wb.writeUInt16(0); // no headers @@ -1068,6 +1095,7 @@ export class BaseRawConnection { let error: Error | null = null; let parsing = true; + let warnings: errors.EdgeDBError[] = []; while (parsing) { if (!this.buffer.takeMessage()) { @@ -1104,8 +1132,15 @@ export class BaseRawConnection { case chars.$T: { try { - const [newCard, newInCodec, newOutCodec, capabilities] = - this._parseDescribeTypeMessage(); + const [ + newCard, + newInCodec, + newOutCodec, + capabilities, + _, + __, + _warnings, + ] = this._parseDescribeTypeMessage(query); const key = this._getQueryCacheKey( query, outputFormat, @@ -1118,6 +1153,7 @@ export class BaseRawConnection { capabilities, ]); outCodec = newOutCodec; + warnings = _warnings; } catch (e: any) { error = e; } @@ -1157,6 +1193,8 @@ export class BaseRawConnection { } throw error; } + + return warnings; } private _getQueryCacheKey( @@ -1194,7 +1232,7 @@ export class BaseRawConnection { expectedCardinality: Cardinality, state: Session, privilegedMode = false, - ): Promise { + ): Promise<{ result: any; warnings: errors.EdgeDBError[] }> { if (this.isLegacyProtocol && outputFormat === OutputFormat.NONE) { if (args != null) { throw new errors.InterfaceError( @@ -1202,7 +1240,8 @@ export class BaseRawConnection { `EdgeDB. Upgrade to EdgeDB 2.0 or newer.`, ); } - return this.legacyExecute(query, privilegedMode); + await this.legacyExecute(query, privilegedMode); + return { result: null, warnings: [] }; } this._checkState(); @@ -1218,6 +1257,9 @@ export class BaseRawConnection { expectedCardinality, ); const ret: any[] = []; + // @ts-ignore + let _; + let warnings: errors.EdgeDBError[] = []; if (!this.isLegacyProtocol) { let [card, inCodec, outCodec] = this.queryCodecCache.get(key) ?? []; @@ -1230,7 +1272,7 @@ export class BaseRawConnection { (!inCodec && args !== null) || (this.stateCodec === INVALID_CODEC && state !== Session.defaults()) ) { - [card, inCodec, outCodec] = await this._parse( + [card, inCodec, outCodec, _, _, _, warnings] = await this._parse( query, outputFormat, expectedCardinality, @@ -1241,7 +1283,7 @@ export class BaseRawConnection { } try { - await this._executeFlow( + warnings = await this._executeFlow( query, args, outputFormat, @@ -1255,7 +1297,7 @@ export class BaseRawConnection { } catch (e) { if (e instanceof errors.ParameterTypeMismatchError) { [card, inCodec, outCodec] = this.queryCodecCache.get(key)!; - await this._executeFlow( + warnings = await this._executeFlow( query, args, outputFormat, @@ -1304,26 +1346,26 @@ export class BaseRawConnection { } if (outputFormat === OutputFormat.NONE) { - return; + return { result: null, warnings }; } if (expectOne) { if (requiredOne && !ret.length) { throw new errors.NoDataError("query returned no data"); } else { - return ret[0] ?? (asJson ? "null" : null); + return { result: ret[0] ?? (asJson ? "null" : null), warnings }; } } else { if (ret && ret.length) { if (asJson) { - return ret[0]; + return { result: ret[0], warnings }; } else { - return ret; + return { result: ret, warnings }; } } else { if (asJson) { - return "[]"; + return { result: "[]", warnings }; } else { - return ret; + return { result: ret, warnings }; } } } diff --git a/packages/driver/src/errors/base.ts b/packages/driver/src/errors/base.ts index 365db32af..b73cf0133 100644 --- a/packages/driver/src/errors/base.ts +++ b/packages/driver/src/errors/base.ts @@ -1,10 +1,11 @@ import { utf8Decoder } from "../primitives/buffer"; +import { tags } from "./tags"; export class EdgeDBError extends Error { - protected static tags: object = {}; + protected static tags: { [tag in tags]?: boolean } = {}; private _message: string; private _query?: string; - private _attrs?: Map; + private _attrs?: Map; constructor( message?: string, @@ -34,17 +35,15 @@ export class EdgeDBError extends Error { return this.constructor.name; } - hasTag(tag: symbol): boolean { - // Can't index by symbol, except when using : - // https://github.com/microsoft/TypeScript/issues/1863 - const error_type = this.constructor as typeof EdgeDBError as any; - return Boolean(error_type.tags?.[tag]); + hasTag(tag: tags): boolean { + const error_type = this.constructor as typeof EdgeDBError; + return error_type.tags[tag] ?? false; } } export type ErrorType = new (msg: string) => EdgeDBError; -enum ErrorAttr { +export enum ErrorAttr { hint = 1, details = 2, serverTraceback = 257, @@ -60,17 +59,24 @@ enum ErrorAttr { characterEnd = -6, } -function tryParseInt(val: any) { - if (!(val instanceof Uint8Array)) return null; +function tryParseInt(val: Uint8Array | string | undefined) { + if (val == null) return null; try { - return parseInt(utf8Decoder.decode(val), 10); + return parseInt( + val instanceof Uint8Array ? utf8Decoder.decode(val) : val, + 10, + ); } catch { return null; } } +function readAttrStr(val: Uint8Array | string | undefined) { + return val instanceof Uint8Array ? utf8Decoder.decode(val) : val ?? ""; +} + export function prettyPrintError( - attrs: Map, + attrs: Map, query: string, ) { let errMessage = "\n"; @@ -104,12 +110,10 @@ export function prettyPrintError( } if (attrs.has(ErrorAttr.details)) { - errMessage += `Details: ${utf8Decoder.decode( - attrs.get(ErrorAttr.details), - )}\n`; + errMessage += `Details: ${readAttrStr(attrs.get(ErrorAttr.details))}\n`; } if (attrs.has(ErrorAttr.hint)) { - errMessage += `Hint: ${utf8Decoder.decode(attrs.get(ErrorAttr.hint))}\n`; + errMessage += `Hint: ${readAttrStr(attrs.get(ErrorAttr.hint))}\n`; } return errMessage; diff --git a/packages/driver/src/errors/resolve.ts b/packages/driver/src/errors/resolve.ts index 27309cc44..39a784db5 100644 --- a/packages/driver/src/errors/resolve.ts +++ b/packages/driver/src/errors/resolve.ts @@ -17,7 +17,7 @@ */ import * as errors from "./index"; -import type { ErrorType } from "./base"; +import { ErrorAttr, type ErrorType } from "./base"; import { errorMapping } from "./map"; export function resolveErrorCode(code: number): ErrorType { @@ -48,3 +48,27 @@ export function resolveErrorCode(code: number): ErrorType { return errors.EdgeDBError; } + +const _JSON_FIELDS = { + hint: ErrorAttr.hint, + details: ErrorAttr.details, + start: ErrorAttr.characterStart, + end: ErrorAttr.characterEnd, + line: ErrorAttr.lineStart, + col: ErrorAttr.columnStart, +}; + +export function errorFromJSON(data: any) { + const errType = resolveErrorCode(data.code); + const err = new errType(data.message); + + const attrs = new Map(); + for (const [name, field] of Object.entries(_JSON_FIELDS)) { + if (data["name"] != null) { + attrs.set(field, data[name]); + } + } + (err as any)._attrs = attrs; + + return err; +} diff --git a/packages/driver/src/errors/tags.ts b/packages/driver/src/errors/tags.ts index ea11e8e4d..77fdc2361 100644 --- a/packages/driver/src/errors/tags.ts +++ b/packages/driver/src/errors/tags.ts @@ -1,2 +1,4 @@ export const SHOULD_RECONNECT = Symbol("SHOULD_RECONNECT"); export const SHOULD_RETRY = Symbol("SHOULD_RETRY"); + +export type tags = typeof SHOULD_RECONNECT | typeof SHOULD_RETRY; diff --git a/packages/driver/src/index.node.ts b/packages/driver/src/index.node.ts index 56a950b47..cdbc30398 100644 --- a/packages/driver/src/index.node.ts +++ b/packages/driver/src/index.node.ts @@ -33,7 +33,7 @@ export { RetryOptions, Session, } from "./options"; -export { defaultBackoff } from "./options"; +export { defaultBackoff, logWarnings, throwWarnings } from "./options"; export type { BackoffFunction } from "./options"; export * from "./index.shared"; diff --git a/packages/driver/src/options.ts b/packages/driver/src/options.ts index ffca54891..08b0a3201 100644 --- a/packages/driver/src/options.ts +++ b/packages/driver/src/options.ts @@ -35,6 +35,23 @@ export interface SimpleRetryOptions { backoff?: BackoffFunction; } +export type WarningHandler = (warnings: errors.EdgeDBError[]) => void; + +export function throwWarnings(warnings: errors.EdgeDBError[]) { + throw new Error( + `warnings occurred while running query: ${warnings.map((warn) => warn.message)}`, + { cause: warnings }, + ); +} + +export function logWarnings(warnings: errors.EdgeDBError[]) { + for (const warning of warnings) { + console.warn( + new Error(`EdgeDB warning: ${warning.message}`, { cause: warning }), + ); + } +} + export class RetryOptions { readonly default: RetryRule; private overrides: Map; @@ -197,19 +214,23 @@ export class Options { readonly retryOptions: RetryOptions; readonly transactionOptions: TransactionOptions; readonly session: Session; + readonly warningHandler: WarningHandler; constructor({ retryOptions = RetryOptions.defaults(), transactionOptions = TransactionOptions.defaults(), session = Session.defaults(), + warningHandler = logWarnings, }: { retryOptions?: RetryOptions; transactionOptions?: TransactionOptions; session?: Session; + warningHandler?: WarningHandler; } = {}) { this.retryOptions = retryOptions; this.transactionOptions = transactionOptions; this.session = session; + this.warningHandler = warningHandler; } withTransactionOptions( @@ -239,6 +260,10 @@ export class Options { }); } + withWarningHandler(handler: WarningHandler): Options { + return new Options({ ...this, warningHandler: handler }); + } + static defaults(): Options { return new Options(); } diff --git a/packages/driver/src/transaction.ts b/packages/driver/src/transaction.ts index a7d7a39d0..e83591e13 100644 --- a/packages/driver/src/transaction.ts +++ b/packages/driver/src/transaction.ts @@ -119,6 +119,19 @@ export class Transaction implements Executor { } } + private async _runFetchOp( + opName: string, + ...args: Parameters + ) { + const { result, warnings } = await this._runOp(opName, () => + this._rawConn.fetch(...args), + ); + if (warnings.length) { + this._holder.options.warningHandler(warnings); + } + return result; + } + /** @internal */ async _commit(): Promise { await this._runOp( @@ -158,38 +171,35 @@ export class Transaction implements Executor { } async execute(query: string, args?: QueryArgs): Promise { - return this._runOp("execute", () => - this._rawConn.fetch( - query, - args, - OutputFormat.NONE, - Cardinality.NO_RESULT, - this._holder.options.session, - ), + await this._runFetchOp( + "execute", + query, + args, + OutputFormat.NONE, + Cardinality.NO_RESULT, + this._holder.options.session, ); } async query(query: string, args?: QueryArgs): Promise { - return this._runOp("query", () => - this._rawConn.fetch( - query, - args, - OutputFormat.BINARY, - Cardinality.MANY, - this._holder.options.session, - ), + return this._runFetchOp( + "query", + query, + args, + OutputFormat.BINARY, + Cardinality.MANY, + this._holder.options.session, ); } async queryJSON(query: string, args?: QueryArgs): Promise { - return this._runOp("queryJSON", () => - this._rawConn.fetch( - query, - args, - OutputFormat.JSON, - Cardinality.MANY, - this._holder.options.session, - ), + return this._runFetchOp( + "queryJSON", + query, + args, + OutputFormat.JSON, + Cardinality.MANY, + this._holder.options.session, ); } @@ -197,26 +207,24 @@ export class Transaction implements Executor { query: string, args?: QueryArgs, ): Promise { - return this._runOp("querySingle", () => - this._rawConn.fetch( - query, - args, - OutputFormat.BINARY, - Cardinality.AT_MOST_ONE, - this._holder.options.session, - ), + return this._runFetchOp( + "querySingle", + query, + args, + OutputFormat.BINARY, + Cardinality.AT_MOST_ONE, + this._holder.options.session, ); } async querySingleJSON(query: string, args?: QueryArgs): Promise { - return this._runOp("querySingleJSON", () => - this._rawConn.fetch( - query, - args, - OutputFormat.JSON, - Cardinality.AT_MOST_ONE, - this._holder.options.session, - ), + return this._runFetchOp( + "querySingleJSON", + query, + args, + OutputFormat.JSON, + Cardinality.AT_MOST_ONE, + this._holder.options.session, ); } @@ -224,26 +232,24 @@ export class Transaction implements Executor { query: string, args?: QueryArgs, ): Promise<[T, ...T[]]> { - return this._runOp("queryRequired", () => - this._rawConn.fetch( - query, - args, - OutputFormat.BINARY, - Cardinality.AT_LEAST_ONE, - this._holder.options.session, - ), + return this._runFetchOp( + "queryRequired", + query, + args, + OutputFormat.BINARY, + Cardinality.AT_LEAST_ONE, + this._holder.options.session, ); } async queryRequiredJSON(query: string, args?: QueryArgs): Promise { - return this._runOp("queryRequiredJSON", () => - this._rawConn.fetch( - query, - args, - OutputFormat.JSON, - Cardinality.AT_LEAST_ONE, - this._holder.options.session, - ), + return this._runFetchOp( + "queryRequiredJSON", + query, + args, + OutputFormat.JSON, + Cardinality.AT_LEAST_ONE, + this._holder.options.session, ); } @@ -251,14 +257,13 @@ export class Transaction implements Executor { query: string, args?: QueryArgs, ): Promise { - return this._runOp("queryRequiredSingle", () => - this._rawConn.fetch( - query, - args, - OutputFormat.BINARY, - Cardinality.ONE, - this._holder.options.session, - ), + return this._runFetchOp( + "queryRequiredSingle", + query, + args, + OutputFormat.BINARY, + Cardinality.ONE, + this._holder.options.session, ); } @@ -266,14 +271,13 @@ export class Transaction implements Executor { query: string, args?: QueryArgs, ): Promise { - return this._runOp("queryRequiredSingleJSON", () => - this._rawConn.fetch( - query, - args, - OutputFormat.JSON, - Cardinality.ONE, - this._holder.options.session, - ), + return this._runFetchOp( + "queryRequiredSingleJSON", + query, + args, + OutputFormat.JSON, + Cardinality.ONE, + this._holder.options.session, ); } } diff --git a/packages/driver/test/client.test.ts b/packages/driver/test/client.test.ts index 84519a53f..4d2f46f2a 100644 --- a/packages/driver/test/client.test.ts +++ b/packages/driver/test/client.test.ts @@ -37,6 +37,7 @@ import { Session, AuthenticationError, InvalidReferenceError, + throwWarnings, } from "../src/index.node"; import { AdminUIFetchConnection } from "../src/fetchConn"; @@ -450,8 +451,10 @@ if (!isDeno) { const hasPgVectorExtention = await con.queryRequiredSingle( hasPgVectorExtentionQuery, ); - if (!hasPgVectorExtention) return; - await con.execute("drop extension pgvector;"); + if (hasPgVectorExtention) { + await con.execute("drop extension pgvector;"); + } + await con.close(); }); it("valid: Float32Array", async () => { @@ -2080,6 +2083,43 @@ test("pretty error message", async () => { ); }); +test("warnings handler", async () => { + if (getEdgeDBVersion().major < 6) return; + + let client = getClient(); + + try { + let warnings: EdgeDBError[] | null = null; + client = client.withWarningHandler((_warnings) => (warnings = _warnings)); + + await expect(client.query("select _warn_on_call();")).resolves.toEqual([0]); + + expect(Array.isArray(warnings)).toBe(true); + expect(warnings!.length).toBe(1); + expect(warnings![0]).toBeInstanceOf(EdgeDBError); + expect(warnings![0].message.trim()).toBe("Test warning please ignore"); + + warnings = null; + + await expect( + client.transaction((txn) => txn.query("select _warn_on_call();")), + ).resolves.toEqual([0]); + + expect(Array.isArray(warnings)).toBe(true); + expect(warnings!.length).toBe(1); + expect(warnings![0]).toBeInstanceOf(EdgeDBError); + expect(warnings![0].message.trim()).toBe("Test warning please ignore"); + + client = client.withWarningHandler(throwWarnings); + + await expect(client.query("select _warn_on_call();")).rejects.toThrow( + /warnings occurred while running query: Test warning please ignore/, + ); + } finally { + await client.close(); + } +}); + function _decodeResultBuffer(outCodec: _ICodec, resultData: Uint8Array) { const result = new Array(); const buf = new _ReadBuffer(resultData);