diff --git a/packages/errors/package.json b/packages/errors/package.json index 46bc3d735..bb358bcf2 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -42,6 +42,7 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { + "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^9.3.0" }, "devDependencies": { @@ -50,6 +51,7 @@ "@metamask/eslint-config": "^14.0.0", "@metamask/eslint-config-nodejs": "^14.0.0", "@metamask/eslint-config-typescript": "^14.0.0", + "@ocap/test-utils": "workspace:^", "@ts-bridge/cli": "^0.5.1", "@ts-bridge/shims": "^0.1.1", "@typescript-eslint/eslint-plugin": "^8.8.1", diff --git a/packages/errors/src/BaseError.test.ts b/packages/errors/src/BaseError.test.ts index 63538b7d1..d4a8719f3 100644 --- a/packages/errors/src/BaseError.test.ts +++ b/packages/errors/src/BaseError.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { BaseError } from './BaseError.js'; import { ErrorCode } from './constants.js'; +import type { MarshaledOcapError } from './types.js'; describe('BaseError', () => { const mockCode = ErrorCode.VatNotFound; @@ -9,7 +10,7 @@ describe('BaseError', () => { const mockData = { key: 'value' }; const mockCause = new Error('Root cause error'); - it('should create a BaseError with required properties', () => { + it('creates a BaseError with required properties', () => { const error = new BaseError(mockCode, mockMessage); expect(error).toBeInstanceOf(BaseError); expect(error).toBeInstanceOf(Error); @@ -20,7 +21,7 @@ describe('BaseError', () => { expect(error.cause).toBeUndefined(); }); - it('should create a BaseError with all properties', () => { + it('creates a BaseError with all properties', () => { const error = new BaseError(mockCode, mockMessage, mockData, mockCause); expect(error.name).toBe('BaseError'); expect(error.message).toBe(mockMessage); @@ -29,21 +30,27 @@ describe('BaseError', () => { expect(error.cause).toBe(mockCause); }); - it('should inherit from the Error class and have the correct name', () => { + it('inherits from the Error class and have the correct name', () => { const error = new BaseError(mockCode, mockMessage); expect(error).toBeInstanceOf(Error); expect(error.name).toBe('BaseError'); }); - it('should correctly handle a missing data parameter', () => { + it('handles a missing data parameter', () => { const error = new BaseError(mockCode, mockMessage, undefined, mockCause); expect(error.data).toBeUndefined(); expect(error.cause).toBe(mockCause); }); - it('should correctly handle a missing cause parameter', () => { + it('handles a missing cause parameter', () => { const error = new BaseError(mockCode, mockMessage, mockData); expect(error.data).toStrictEqual(mockData); expect(error.cause).toBeUndefined(); }); + + it('throws an error when unmarshal is called', () => { + expect(() => BaseError.unmarshal({} as MarshaledOcapError)).toThrow( + 'Unmarshal method not implemented', + ); + }); }); diff --git a/packages/errors/src/BaseError.ts b/packages/errors/src/BaseError.ts index c62815162..3817def11 100644 --- a/packages/errors/src/BaseError.ts +++ b/packages/errors/src/BaseError.ts @@ -1,13 +1,12 @@ import type { Json } from '@metamask/utils'; import type { ErrorCode } from './constants.js'; +import type { MarshaledOcapError, OcapError } from './types.js'; -export class BaseError extends Error { +export class BaseError extends Error implements OcapError { public readonly code: ErrorCode; - public data: Json | undefined; - - public cause: unknown; + public readonly data: Json | undefined; constructor(code: ErrorCode, message: string, data?: Json, cause?: unknown) { super(message, { cause }); @@ -16,5 +15,16 @@ export class BaseError extends Error { this.code = code; this.data = data; this.cause = cause; + harden(this); + } + + /** + * A placeholder for unmarshal functionality. Should be implemented in subclasses. + * + * @param _marshaledError - The marshaled error to unmarshal. + */ + public static unmarshal(_marshaledError: MarshaledOcapError): BaseError { + throw new Error('Unmarshal method not implemented'); } } +harden(BaseError); diff --git a/packages/errors/src/constants.ts b/packages/errors/src/constants.ts index ec0d435a2..0dc6f4e6c 100644 --- a/packages/errors/src/constants.ts +++ b/packages/errors/src/constants.ts @@ -1,3 +1,13 @@ +import type { Struct } from '@metamask/superstruct'; +import { lazy, literal, optional, string, union } from '@metamask/superstruct'; +import { JsonStruct, object } from '@metamask/utils'; +import type { NonEmptyArray } from '@metamask/utils'; + +import type { MarshaledError, MarshaledOcapError } from './types.js'; + +/** + * Enum defining all error codes for Ocap errors. + */ export enum ErrorCode { StreamReadError = 'STREAM_READ_ERROR', VatAlreadyExists = 'VAT_ALREADY_EXISTS', @@ -6,3 +16,40 @@ export enum ErrorCode { VatDeleted = 'VAT_DELETED', VatNotFound = 'VAT_NOT_FOUND', } + +/** + * A sentinel value used to identify marshaled errors. + */ +export const ErrorSentinel = '@@MARSHALED_ERROR'; + +const ErrorCodeStruct = union( + Object.values(ErrorCode).map((code) => literal(code)) as NonEmptyArray< + Struct + >, +); + +export const marshaledErrorSchema = { + [ErrorSentinel]: literal(true), + message: string(), + code: optional(ErrorCodeStruct), + data: optional(JsonStruct), + stack: optional(string()), +}; + +/** + * Struct to validate marshaled errors. + */ +export const MarshaledErrorStruct = object({ + ...marshaledErrorSchema, + cause: optional(union([string(), lazy(() => MarshaledErrorStruct)])), +}) as Struct; + +/** + * Struct to validate marshaled ocap errors. + */ +export const MarshaledOcapErrorStruct = object({ + ...marshaledErrorSchema, + code: ErrorCodeStruct, + data: JsonStruct, + cause: optional(union([string(), lazy(() => MarshaledErrorStruct)])), +}) as Struct; diff --git a/packages/errors/src/errors.test.ts b/packages/errors/src/errors.test.ts deleted file mode 100644 index 558bec27f..000000000 --- a/packages/errors/src/errors.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { ErrorCode } from './constants.js'; -import { - VatAlreadyExistsError, - VatNotFoundError, - StreamReadError, - VatCapTpConnectionExistsError, - VatCapTpConnectionNotFoundError, - VatDeletedError, -} from './errors.js'; - -describe('Errors classes', () => { - const mockVatId = 'mockVatId'; - const mockSupervisorId = 'mockSupervisorId'; - const mockOriginalError = new Error('Original error'); - - describe('VatAlreadyExistsError', () => { - it('should create a VatAlreadyExistsError with the correct properties', () => { - const error = new VatAlreadyExistsError(mockVatId); - expect(error).toBeInstanceOf(VatAlreadyExistsError); - expect(error.code).toBe(ErrorCode.VatAlreadyExists); - expect(error.message).toBe('Vat already exists.'); - expect(error.data).toStrictEqual({ vatId: mockVatId }); - expect(error.cause).toBeUndefined(); - }); - }); - - describe('VatNotFoundError', () => { - it('should create a VatNotFoundError with the correct properties', () => { - const error = new VatNotFoundError(mockVatId); - expect(error).toBeInstanceOf(VatNotFoundError); - expect(error.code).toBe(ErrorCode.VatNotFound); - expect(error.message).toBe('Vat does not exist.'); - expect(error.data).toStrictEqual({ vatId: mockVatId }); - expect(error.cause).toBeUndefined(); - }); - }); - - describe('StreamReadError', () => { - it('should create a StreamReadError for Supervisor with the correct properties', () => { - const error = new StreamReadError( - { supervisorId: mockSupervisorId }, - mockOriginalError, - ); - expect(error).toBeInstanceOf(StreamReadError); - expect(error.code).toBe(ErrorCode.StreamReadError); - expect(error.message).toBe('Unexpected stream read error.'); - expect(error.data).toStrictEqual({ supervisorId: mockSupervisorId }); - expect(error.cause).toBe(mockOriginalError); - }); - - it('should create a StreamReadError for Vat with the correct properties', () => { - const error = new StreamReadError( - { vatId: mockVatId }, - mockOriginalError, - ); - expect(error).toBeInstanceOf(StreamReadError); - expect(error.code).toBe(ErrorCode.StreamReadError); - expect(error.message).toBe('Unexpected stream read error.'); - expect(error.data).toStrictEqual({ vatId: mockVatId }); - expect(error.cause).toBe(mockOriginalError); - }); - }); - - describe('VatCapTpConnectionExistsError', () => { - it('should create a VatCapTpConnectionExistsError with the correct properties', () => { - const error = new VatCapTpConnectionExistsError(mockVatId); - expect(error).toBeInstanceOf(VatCapTpConnectionExistsError); - expect(error.code).toBe(ErrorCode.VatCapTpConnectionExists); - expect(error.message).toBe('Vat already has a CapTP connection.'); - expect(error.data).toStrictEqual({ vatId: mockVatId }); - expect(error.cause).toBeUndefined(); - }); - }); - - describe('VatCapTpConnectionNotFoundError', () => { - it('should create a VatCapTpConnectionNotFoundError with the correct properties', () => { - const error = new VatCapTpConnectionNotFoundError(mockVatId); - expect(error).toBeInstanceOf(VatCapTpConnectionNotFoundError); - expect(error.code).toBe(ErrorCode.VatCapTpConnectionNotFound); - expect(error.message).toBe('Vat does not have a CapTP connection.'); - expect(error.data).toStrictEqual({ vatId: mockVatId }); - expect(error.cause).toBeUndefined(); - }); - }); - - describe('VatDeletedError', () => { - it('should create a VatDeletedError with the correct properties', () => { - const error = new VatDeletedError(mockVatId); - expect(error).toBeInstanceOf(VatDeletedError); - expect(error.code).toBe(ErrorCode.VatDeleted); - expect(error.message).toBe('Vat was deleted.'); - expect(error.data).toStrictEqual({ vatId: mockVatId }); - expect(error.cause).toBeUndefined(); - }); - }); -}); diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts deleted file mode 100644 index 5fbddea50..000000000 --- a/packages/errors/src/errors.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { BaseError } from './BaseError.js'; -import { ErrorCode } from './constants.js'; - -export class VatAlreadyExistsError extends BaseError { - constructor(vatId: string) { - super(ErrorCode.VatAlreadyExists, 'Vat already exists.', { - vatId, - }); - } -} - -export class VatNotFoundError extends BaseError { - constructor(vatId: string) { - super(ErrorCode.VatNotFound, 'Vat does not exist.', { vatId }); - } -} - -export class StreamReadError extends BaseError { - constructor( - data: { vatId: string } | { supervisorId: string }, - originalError: Error, - ) { - super( - ErrorCode.StreamReadError, - 'Unexpected stream read error.', - data, - originalError, - ); - } -} - -export class VatCapTpConnectionExistsError extends BaseError { - constructor(vatId: string) { - super( - ErrorCode.VatCapTpConnectionExists, - 'Vat already has a CapTP connection.', - { - vatId, - }, - ); - } -} - -export class VatCapTpConnectionNotFoundError extends BaseError { - constructor(vatId: string) { - super( - ErrorCode.VatCapTpConnectionNotFound, - 'Vat does not have a CapTP connection.', - { vatId }, - ); - } -} - -export class VatDeletedError extends BaseError { - constructor(vatId: string) { - super(ErrorCode.VatDeleted, 'Vat was deleted.', { vatId }); - } -} diff --git a/packages/errors/src/errors/StreamReadError.test.ts b/packages/errors/src/errors/StreamReadError.test.ts new file mode 100644 index 000000000..e78c062d5 --- /dev/null +++ b/packages/errors/src/errors/StreamReadError.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; + +import { StreamReadError } from './StreamReadError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +describe('StreamReadError', () => { + const mockVatId = 'mockVatId'; + const mockSupervisorId = 'mockSupervisorId'; + const mockOriginalError = new Error('Original error'); + + it('creates a StreamReadError for Supervisor with the correct properties', () => { + const error = new StreamReadError( + { supervisorId: mockSupervisorId }, + mockOriginalError, + ); + expect(error).toBeInstanceOf(StreamReadError); + expect(error.code).toBe(ErrorCode.StreamReadError); + expect(error.message).toBe('Unexpected stream read error.'); + expect(error.data).toStrictEqual({ supervisorId: mockSupervisorId }); + expect(error.cause).toBe(mockOriginalError); + }); + + it('creates a StreamReadError for Vat with the correct properties', () => { + const error = new StreamReadError({ vatId: mockVatId }, mockOriginalError); + expect(error).toBeInstanceOf(StreamReadError); + expect(error.code).toBe(ErrorCode.StreamReadError); + expect(error.message).toBe('Unexpected stream read error.'); + expect(error.data).toStrictEqual({ vatId: mockVatId }); + expect(error.cause).toBe(mockOriginalError); + }); + + it('unmarshals a valid marshaled StreamReadError for Vat', () => { + const data = { vatId: mockVatId }; + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Unexpected stream read error.', + stack: 'customStack', + code: ErrorCode.StreamReadError, + data, + cause: { + [ErrorSentinel]: true, + message: 'Original error', + stack: 'bar', + }, + }; + + const unmarshaledError = StreamReadError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(StreamReadError); + expect(unmarshaledError.code).toBe(ErrorCode.StreamReadError); + expect(unmarshaledError.message).toBe('Unexpected stream read error.'); + expect(unmarshaledError.data).toStrictEqual({ + vatId: mockVatId, + }); + expect(unmarshaledError.cause).toBeInstanceOf(Error); + expect((unmarshaledError.cause as Error).message).toBe('Original error'); + }); + + it('unmarshals a valid marshaled StreamReadError for Supervisor', () => { + const data = { supervisorId: mockSupervisorId }; + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Unexpected stream read error.', + stack: 'customStack', + code: ErrorCode.StreamReadError, + data, + cause: { + [ErrorSentinel]: true, + message: 'Original error', + stack: 'bar', + }, + }; + + const unmarshaledError = StreamReadError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(StreamReadError); + expect(unmarshaledError.code).toBe(ErrorCode.StreamReadError); + expect(unmarshaledError.message).toBe('Unexpected stream read error.'); + expect(unmarshaledError.data).toStrictEqual({ + supervisorId: mockSupervisorId, + }); + expect(unmarshaledError.cause).toBeInstanceOf(Error); + expect((unmarshaledError.cause as Error).message).toBe('Original error'); + }); + + it('throws an error when an invalid message is unmarshaled', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Unexpected stream read error.', + code: ErrorCode.StreamReadError, + data: 'invalid data', + stack: 'stack trace', + }; + + expect(() => StreamReadError.unmarshal(marshaledError)).toThrow( + 'At path: data -- Expected the value to satisfy a union of `object | object`, but received: "invalid data"', + ); + }); + + it('throws when both vatId and supervisorId are present in data', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Unexpected stream read error.', + stack: 'customStack', + code: ErrorCode.StreamReadError, + data: { supervisorId: mockSupervisorId, vatId: mockVatId }, + cause: { + [ErrorSentinel]: true, + message: 'Original error', + stack: 'bar', + }, + }; + + expect(() => StreamReadError.unmarshal(marshaledError)).toThrow( + 'At path: data -- Expected the value to satisfy a union of `object | object`, but received: [object Object]', + ); + }); +}); diff --git a/packages/errors/src/errors/StreamReadError.ts b/packages/errors/src/errors/StreamReadError.ts new file mode 100644 index 000000000..73abe67f0 --- /dev/null +++ b/packages/errors/src/errors/StreamReadError.ts @@ -0,0 +1,60 @@ +import { + assert, + literal, + never, + object, + optional, + string, + union, +} from '@metamask/superstruct'; + +import { BaseError } from '../BaseError.js'; +import { + marshaledErrorSchema, + ErrorCode, + MarshaledErrorStruct, +} from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +type StreamReadErrorData = { vatId: string } | { supervisorId: string }; + +export class StreamReadError extends BaseError { + constructor(data: StreamReadErrorData, originalError: Error) { + super( + ErrorCode.StreamReadError, + 'Unexpected stream read error.', + data, + originalError, + ); + harden(this); + } + + /** + * A superstruct struct for validating marshaled {@link StreamReadError} instances. + */ + public static struct = object({ + ...marshaledErrorSchema, + code: literal(ErrorCode.StreamReadError), + data: union([ + object({ vatId: string(), supervisorId: optional(never()) }), + object({ supervisorId: string(), vatId: optional(never()) }), + ]), + cause: MarshaledErrorStruct, + }); + + /** + * Unmarshals a {@link MarshaledError} into a {@link StreamReadError}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ + public static unmarshal(marshaledError: MarshaledOcapError): StreamReadError { + assert(marshaledError, this.struct); + return new StreamReadError( + marshaledError.data as StreamReadErrorData, + // The cause will be properly unmarshaled during the parent call. + new Error(marshaledError.cause?.message), + ); + } +} +harden(StreamReadError); diff --git a/packages/errors/src/errors/VatAlreadyExistsError.test.ts b/packages/errors/src/errors/VatAlreadyExistsError.test.ts new file mode 100644 index 000000000..649c6e4a0 --- /dev/null +++ b/packages/errors/src/errors/VatAlreadyExistsError.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import { VatAlreadyExistsError } from './VatAlreadyExistsError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +describe('VatAlreadyExistsError', () => { + const mockVatId = 'mockVatId'; + + it('creates a VatAlreadyExistsError with the correct properties', () => { + const error = new VatAlreadyExistsError(mockVatId); + expect(error).toBeInstanceOf(VatAlreadyExistsError); + expect(error.code).toBe(ErrorCode.VatAlreadyExists); + expect(error.message).toBe('Vat already exists.'); + expect(error.data).toStrictEqual({ vatId: mockVatId }); + expect(error.cause).toBeUndefined(); + }); + + it('unmarshals a valid marshaled error', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat already exists.', + code: ErrorCode.VatAlreadyExists, + data: { vatId: mockVatId }, + stack: 'stack trace', + }; + + const unmarshaledError = VatAlreadyExistsError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(VatAlreadyExistsError); + expect(unmarshaledError.code).toBe(ErrorCode.VatAlreadyExists); + expect(unmarshaledError.message).toBe('Vat already exists.'); + expect(unmarshaledError.data).toStrictEqual({ + vatId: mockVatId, + }); + }); + + it('throws an error when an invalid message is unmarshaled', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat already exists.', + code: ErrorCode.VatAlreadyExists, + data: '{ vatId: mockVatId }', + stack: 'stack trace', + }; + + expect(() => VatAlreadyExistsError.unmarshal(marshaledError)).toThrow( + 'At path: data -- Expected an object, but received: "{ vatId: mockVatId }"', + ); + }); +}); diff --git a/packages/errors/src/errors/VatAlreadyExistsError.ts b/packages/errors/src/errors/VatAlreadyExistsError.ts new file mode 100644 index 000000000..c8a9e9291 --- /dev/null +++ b/packages/errors/src/errors/VatAlreadyExistsError.ts @@ -0,0 +1,39 @@ +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { BaseError } from '../BaseError.js'; +import { marshaledErrorSchema, ErrorCode } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +export class VatAlreadyExistsError extends BaseError { + constructor(vatId: string) { + super(ErrorCode.VatAlreadyExists, 'Vat already exists.', { + vatId, + }); + harden(this); + } + + /** + * A superstruct struct for validating marshaled {@link VatAlreadyExistsError} instances. + */ + public static struct = object({ + ...marshaledErrorSchema, + code: literal(ErrorCode.VatAlreadyExists), + data: object({ + vatId: string(), + }), + }); + + /** + * Unmarshals a {@link MarshaledError} into a {@link VatAlreadyExistsError}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ + public static unmarshal( + marshaledError: MarshaledOcapError, + ): VatAlreadyExistsError { + assert(marshaledError, this.struct); + return new VatAlreadyExistsError(marshaledError.data.vatId); + } +} +harden(VatAlreadyExistsError); diff --git a/packages/errors/src/errors/VatCapTpConnectionExistsError.test.ts b/packages/errors/src/errors/VatCapTpConnectionExistsError.test.ts new file mode 100644 index 000000000..efd6d8f2b --- /dev/null +++ b/packages/errors/src/errors/VatCapTpConnectionExistsError.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +import { VatCapTpConnectionExistsError } from './VatCapTpConnectionExistsError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +describe('VatCapTpConnectionExistsError', () => { + const mockVatId = 'mockVatId'; + + it('creates a VatCapTpConnectionExistsError with the correct properties', () => { + const error = new VatCapTpConnectionExistsError(mockVatId); + expect(error).toBeInstanceOf(VatCapTpConnectionExistsError); + expect(error.code).toBe(ErrorCode.VatCapTpConnectionExists); + expect(error.message).toBe('Vat already has a CapTP connection.'); + expect(error.data).toStrictEqual({ vatId: mockVatId }); + expect(error.cause).toBeUndefined(); + }); + + it('unmarshals a valid marshaled error', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat already has a CapTP connection.', + code: ErrorCode.VatCapTpConnectionExists, + data: { vatId: mockVatId }, + stack: 'stack trace', + }; + + const unmarshaledError = + VatCapTpConnectionExistsError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(VatCapTpConnectionExistsError); + expect(unmarshaledError.code).toBe(ErrorCode.VatCapTpConnectionExists); + expect(unmarshaledError.message).toBe( + 'Vat already has a CapTP connection.', + ); + expect(unmarshaledError.data).toStrictEqual({ + vatId: mockVatId, + }); + }); + + it('throws an error when an invalid message is unmarshaled', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat already has a CapTP connection.', + code: ErrorCode.VatCapTpConnectionExists, + data: '{ vatId: mockVatId }', + stack: 'stack trace', + }; + + expect(() => + VatCapTpConnectionExistsError.unmarshal(marshaledError), + ).toThrow( + 'At path: data -- Expected an object, but received: "{ vatId: mockVatId }"', + ); + }); +}); diff --git a/packages/errors/src/errors/VatCapTpConnectionExistsError.ts b/packages/errors/src/errors/VatCapTpConnectionExistsError.ts new file mode 100644 index 000000000..275bc35e7 --- /dev/null +++ b/packages/errors/src/errors/VatCapTpConnectionExistsError.ts @@ -0,0 +1,43 @@ +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { BaseError } from '../BaseError.js'; +import { marshaledErrorSchema, ErrorCode } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +export class VatCapTpConnectionExistsError extends BaseError { + constructor(vatId: string) { + super( + ErrorCode.VatCapTpConnectionExists, + 'Vat already has a CapTP connection.', + { + vatId, + }, + ); + harden(this); + } + + /** + * A superstruct struct for validating marshaled {@link VatCapTpConnectionExistsError} instances. + */ + public static struct = object({ + ...marshaledErrorSchema, + code: literal(ErrorCode.VatCapTpConnectionExists), + data: object({ + vatId: string(), + }), + }); + + /** + * Unmarshals a {@link MarshaledError} into a {@link VatCapTpConnectionExistsError}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ + public static unmarshal( + marshaledError: MarshaledOcapError, + ): VatCapTpConnectionExistsError { + assert(marshaledError, this.struct); + return new VatCapTpConnectionExistsError(marshaledError.data.vatId); + } +} +harden(VatCapTpConnectionExistsError); diff --git a/packages/errors/src/errors/VatCapTpConnectionNotFoundError.test.ts b/packages/errors/src/errors/VatCapTpConnectionNotFoundError.test.ts new file mode 100644 index 000000000..a76dcf74d --- /dev/null +++ b/packages/errors/src/errors/VatCapTpConnectionNotFoundError.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +import { VatCapTpConnectionNotFoundError } from './VatCapTpConnectionNotFoundError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +describe('VatCapTpConnectionNotFoundError', () => { + const mockVatId = 'mockVatId'; + + it('creates a VatCapTpConnectionNotFoundError with the correct properties', () => { + const error = new VatCapTpConnectionNotFoundError(mockVatId); + expect(error).toBeInstanceOf(VatCapTpConnectionNotFoundError); + expect(error.code).toBe(ErrorCode.VatCapTpConnectionNotFound); + expect(error.message).toBe('Vat does not have a CapTP connection.'); + expect(error.data).toStrictEqual({ vatId: mockVatId }); + expect(error.cause).toBeUndefined(); + }); + + it('unmarshals a valid marshaled error', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat does not have a CapTP connection.', + code: ErrorCode.VatCapTpConnectionNotFound, + data: { vatId: mockVatId }, + stack: 'stack trace', + }; + + const unmarshaledError = + VatCapTpConnectionNotFoundError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(VatCapTpConnectionNotFoundError); + expect(unmarshaledError.code).toBe(ErrorCode.VatCapTpConnectionNotFound); + expect(unmarshaledError.message).toBe( + 'Vat does not have a CapTP connection.', + ); + expect(unmarshaledError.data).toStrictEqual({ + vatId: mockVatId, + }); + }); + + it('throws an error when an invalid message is unmarshaled', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat does not have a CapTP connection.', + code: ErrorCode.VatCapTpConnectionNotFound, + data: '{ vatId: mockVatId }', + stack: 'stack trace', + }; + + expect(() => + VatCapTpConnectionNotFoundError.unmarshal(marshaledError), + ).toThrow( + 'At path: data -- Expected an object, but received: "{ vatId: mockVatId }"', + ); + }); +}); diff --git a/packages/errors/src/errors/VatCapTpConnectionNotFoundError.ts b/packages/errors/src/errors/VatCapTpConnectionNotFoundError.ts new file mode 100644 index 000000000..6500332f4 --- /dev/null +++ b/packages/errors/src/errors/VatCapTpConnectionNotFoundError.ts @@ -0,0 +1,41 @@ +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { BaseError } from '../BaseError.js'; +import { marshaledErrorSchema, ErrorCode } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +export class VatCapTpConnectionNotFoundError extends BaseError { + constructor(vatId: string) { + super( + ErrorCode.VatCapTpConnectionNotFound, + 'Vat does not have a CapTP connection.', + { vatId }, + ); + harden(this); + } + + /** + * A superstruct struct for validating marshaled {@link VatCapTpConnectionNotFoundError} instances. + */ + public static struct = object({ + ...marshaledErrorSchema, + code: literal(ErrorCode.VatCapTpConnectionNotFound), + data: object({ + vatId: string(), + }), + }); + + /** + * Unmarshals a {@link MarshaledError} into a {@link VatCapTpConnectionNotFoundError}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ + public static unmarshal( + marshaledError: MarshaledOcapError, + ): VatCapTpConnectionNotFoundError { + assert(marshaledError, this.struct); + return new VatCapTpConnectionNotFoundError(marshaledError.data.vatId); + } +} +harden(VatCapTpConnectionNotFoundError); diff --git a/packages/errors/src/errors/VatDeletedError.test.ts b/packages/errors/src/errors/VatDeletedError.test.ts new file mode 100644 index 000000000..28e478a57 --- /dev/null +++ b/packages/errors/src/errors/VatDeletedError.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import { VatDeletedError } from './VatDeletedError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +describe('VatDeletedError', () => { + const mockVatId = 'mockVatId'; + + it('creates a VatDeletedError with the correct properties', () => { + const error = new VatDeletedError(mockVatId); + expect(error).toBeInstanceOf(VatDeletedError); + expect(error.code).toBe(ErrorCode.VatDeleted); + expect(error.message).toBe('Vat was deleted.'); + expect(error.data).toStrictEqual({ vatId: mockVatId }); + expect(error.cause).toBeUndefined(); + }); + + it('unmarshals a valid marshaled error', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat was deleted.', + code: ErrorCode.VatDeleted, + data: { vatId: mockVatId }, + stack: 'stack trace', + }; + + const unmarshaledError = VatDeletedError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(VatDeletedError); + expect(unmarshaledError.code).toBe(ErrorCode.VatDeleted); + expect(unmarshaledError.message).toBe('Vat was deleted.'); + expect(unmarshaledError.data).toStrictEqual({ + vatId: mockVatId, + }); + }); + + it('throws an error when an invalid message is unmarshaled', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat was deleted.', + code: ErrorCode.VatDeleted, + data: '{ vatId: mockVatId }', + stack: 'stack trace', + }; + + expect(() => VatDeletedError.unmarshal(marshaledError)).toThrow( + 'At path: data -- Expected an object, but received: "{ vatId: mockVatId }"', + ); + }); +}); diff --git a/packages/errors/src/errors/VatDeletedError.ts b/packages/errors/src/errors/VatDeletedError.ts new file mode 100644 index 000000000..b6fff0cf2 --- /dev/null +++ b/packages/errors/src/errors/VatDeletedError.ts @@ -0,0 +1,35 @@ +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { BaseError } from '../BaseError.js'; +import { marshaledErrorSchema, ErrorCode } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +export class VatDeletedError extends BaseError { + constructor(vatId: string) { + super(ErrorCode.VatDeleted, 'Vat was deleted.', { vatId }); + harden(this); + } + + /** + * A superstruct struct for validating marshaled {@link VatDeletedError} instances. + */ + public static struct = object({ + ...marshaledErrorSchema, + code: literal(ErrorCode.VatDeleted), + data: object({ + vatId: string(), + }), + }); + + /** + * Unmarshals a {@link MarshaledError} into a {@link VatDeletedError}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ + public static unmarshal(marshaledError: MarshaledOcapError): VatDeletedError { + assert(marshaledError, this.struct); + return new VatDeletedError(marshaledError.data.vatId); + } +} +harden(VatDeletedError); diff --git a/packages/errors/src/errors/VatNotFoundError.test.ts b/packages/errors/src/errors/VatNotFoundError.test.ts new file mode 100644 index 000000000..d61f74b89 --- /dev/null +++ b/packages/errors/src/errors/VatNotFoundError.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import { VatNotFoundError } from './VatNotFoundError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +describe('VatNotFoundError', () => { + const mockVatId = 'mockVatId'; + + it('creates a VatNotFoundError with the correct properties', () => { + const error = new VatNotFoundError(mockVatId); + expect(error).toBeInstanceOf(VatNotFoundError); + expect(error.code).toBe(ErrorCode.VatNotFound); + expect(error.message).toBe('Vat does not exist.'); + expect(error.data).toStrictEqual({ vatId: mockVatId }); + expect(error.cause).toBeUndefined(); + }); + + it('unmarshals a valid marshaled error', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat does not exist.', + code: ErrorCode.VatNotFound, + data: { vatId: mockVatId }, + stack: 'stack trace', + }; + + const unmarshaledError = VatNotFoundError.unmarshal(marshaledError); + expect(unmarshaledError).toBeInstanceOf(VatNotFoundError); + expect(unmarshaledError.code).toBe(ErrorCode.VatNotFound); + expect(unmarshaledError.message).toBe('Vat does not exist.'); + expect(unmarshaledError.data).toStrictEqual({ + vatId: mockVatId, + }); + }); + + it('throws an error when an invalid message is unmarshaled', () => { + const marshaledError: MarshaledOcapError = { + [ErrorSentinel]: true, + message: 'Vat does not exist.', + code: ErrorCode.VatNotFound, + data: '{ vatId: mockVatId }', + stack: 'stack trace', + }; + + expect(() => VatNotFoundError.unmarshal(marshaledError)).toThrow( + 'At path: data -- Expected an object, but received: "{ vatId: mockVatId }"', + ); + }); +}); diff --git a/packages/errors/src/errors/VatNotFoundError.ts b/packages/errors/src/errors/VatNotFoundError.ts new file mode 100644 index 000000000..10d699b48 --- /dev/null +++ b/packages/errors/src/errors/VatNotFoundError.ts @@ -0,0 +1,37 @@ +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { BaseError } from '../BaseError.js'; +import { marshaledErrorSchema, ErrorCode } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +export class VatNotFoundError extends BaseError { + constructor(vatId: string) { + super(ErrorCode.VatNotFound, 'Vat does not exist.', { vatId }); + harden(this); + } + + /** + * A superstruct struct for validating marshaled {@link VatNotFoundError} instances. + */ + public static struct = object({ + ...marshaledErrorSchema, + code: literal(ErrorCode.VatNotFound), + data: object({ + vatId: string(), + }), + }); + + /** + * Unmarshals a {@link MarshaledError} into a {@link VatNotFoundError}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ + public static unmarshal( + marshaledError: MarshaledOcapError, + ): VatNotFoundError { + assert(marshaledError, this.struct); + return new VatNotFoundError(marshaledError.data.vatId); + } +} +harden(VatNotFoundError); diff --git a/packages/errors/src/errors/index.test.ts b/packages/errors/src/errors/index.test.ts new file mode 100644 index 000000000..64e6fa444 --- /dev/null +++ b/packages/errors/src/errors/index.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; + +import { errorClasses } from './index.js'; +import { ErrorCode } from '../constants.js'; + +describe('errorClasses', () => { + it('should contain all keys from ErrorCode', () => { + const errorCodes = Object.values(ErrorCode); + const errorClassKeys = Object.keys(errorClasses); + expect(errorClassKeys).toStrictEqual(errorCodes); + }); +}); diff --git a/packages/errors/src/errors/index.ts b/packages/errors/src/errors/index.ts new file mode 100644 index 000000000..c70b39a9f --- /dev/null +++ b/packages/errors/src/errors/index.ts @@ -0,0 +1,16 @@ +import { StreamReadError } from './StreamReadError.js'; +import { VatAlreadyExistsError } from './VatAlreadyExistsError.js'; +import { VatCapTpConnectionExistsError } from './VatCapTpConnectionExistsError.js'; +import { VatCapTpConnectionNotFoundError } from './VatCapTpConnectionNotFoundError.js'; +import { VatDeletedError } from './VatDeletedError.js'; +import { VatNotFoundError } from './VatNotFoundError.js'; +import { ErrorCode } from '../constants.js'; + +export const errorClasses = { + [ErrorCode.StreamReadError]: StreamReadError, + [ErrorCode.VatAlreadyExists]: VatAlreadyExistsError, + [ErrorCode.VatCapTpConnectionExists]: VatCapTpConnectionExistsError, + [ErrorCode.VatCapTpConnectionNotFound]: VatCapTpConnectionNotFoundError, + [ErrorCode.VatDeleted]: VatDeletedError, + [ErrorCode.VatNotFound]: VatNotFoundError, +} as const; diff --git a/packages/errors/src/index.test.ts b/packages/errors/src/index.test.ts index bb1757b67..e7d1e62e0 100644 --- a/packages/errors/src/index.test.ts +++ b/packages/errors/src/index.test.ts @@ -6,13 +6,19 @@ describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'ErrorCode', + 'ErrorSentinel', 'StreamReadError', 'VatAlreadyExistsError', 'VatCapTpConnectionExistsError', 'VatCapTpConnectionNotFoundError', 'VatDeletedError', 'VatNotFoundError', + 'isMarshaledError', + 'isMarshaledOcapError', + 'isOcapError', + 'marshalError', 'toError', + 'unmarshalError', ]); }); }); diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 2b653611d..c2aaf7186 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -1,10 +1,14 @@ -export { ErrorCode } from './constants.js'; -export { - VatCapTpConnectionExistsError, - VatCapTpConnectionNotFoundError, - VatAlreadyExistsError, - VatDeletedError, - VatNotFoundError, - StreamReadError, -} from './errors.js'; +export type { OcapError, MarshaledError } from './types.js'; +export { VatCapTpConnectionExistsError } from './errors/VatCapTpConnectionExistsError.js'; +export { VatCapTpConnectionNotFoundError } from './errors/VatCapTpConnectionNotFoundError.js'; +export { VatAlreadyExistsError } from './errors/VatAlreadyExistsError.js'; +export { VatDeletedError } from './errors/VatDeletedError.js'; +export { VatNotFoundError } from './errors/VatNotFoundError.js'; +export { StreamReadError } from './errors/StreamReadError.js'; +export { ErrorCode, ErrorSentinel } from './constants.js'; export { toError } from './utils/toError.js'; +export { isOcapError } from './utils/isOcapError.js'; +export { marshalError } from './marshal/marshalError.js'; +export { unmarshalError } from './marshal/unmarshalError.js'; +export { isMarshaledError } from './marshal/isMarshaledError.js'; +export { isMarshaledOcapError } from './marshal/isMarshaledOcapError.js'; diff --git a/packages/errors/src/marshal/isMarshaledError.test.ts b/packages/errors/src/marshal/isMarshaledError.test.ts new file mode 100644 index 000000000..2fe9c2acc --- /dev/null +++ b/packages/errors/src/marshal/isMarshaledError.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; + +import { isMarshaledError } from './isMarshaledError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; + +describe('isMarshaledError', () => { + it.each([ + [ + 'valid marshaled error with required fields only', + { + [ErrorSentinel]: true, + message: 'An error occurred', + }, + true, + ], + [ + 'valid marshaled error with optional fields', + { + [ErrorSentinel]: true, + message: 'An error occurred', + code: ErrorCode.VatAlreadyExists, + data: { key: 'value' }, + stack: 'Error stack trace', + cause: 'Another error', + }, + true, + ], + [ + 'valid marshaled error with nested cause', + { + [ErrorSentinel]: true, + message: 'An error occurred', + cause: { + [ErrorSentinel]: true, + message: 'Nested error occurred', + }, + }, + true, + ], + [ + 'object missing the sentinel value', + { + message: 'An error occurred', + }, + false, + ], + [ + 'object missing the message value', + { + [ErrorSentinel]: true, + }, + false, + ], + [ + 'object with incorrect sentinel value', + { + [ErrorSentinel]: false, + message: 'An error occurred', + }, + false, + ], + ['null value', null, false], + ['undefined value', undefined, false], + ['string value', 'string', false], + ['number value', 123, false], + ['array value', [], false], + ])('should return %s', (_, value, expected) => { + expect(isMarshaledError(value)).toBe(expected); + }); +}); diff --git a/packages/errors/src/marshal/isMarshaledError.ts b/packages/errors/src/marshal/isMarshaledError.ts new file mode 100644 index 000000000..cd74916c2 --- /dev/null +++ b/packages/errors/src/marshal/isMarshaledError.ts @@ -0,0 +1,14 @@ +import { is } from '@metamask/superstruct'; + +import { MarshaledErrorStruct } from '../constants.js'; +import type { MarshaledError } from '../types.js'; + +/** + * Checks if a value is a {@link MarshaledError}. + * + * @param value - The value to check. + * @returns Whether the value is a {@link MarshaledError}. + */ +export function isMarshaledError(value: unknown): value is MarshaledError { + return is(value, MarshaledErrorStruct); +} diff --git a/packages/errors/src/marshal/isMarshaledOcapError.test.ts b/packages/errors/src/marshal/isMarshaledOcapError.test.ts new file mode 100644 index 000000000..fe7c99313 --- /dev/null +++ b/packages/errors/src/marshal/isMarshaledOcapError.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; + +import { isMarshaledOcapError } from './isMarshaledOcapError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; + +describe('isMarshaledOcapError', () => { + it.each([ + [ + 'valid marshaled error with required fields only', + { + [ErrorSentinel]: true, + message: 'An error occurred', + code: ErrorCode.VatNotFound, + data: { key: 'value' }, + }, + true, + ], + [ + 'valid marshaled error with optional fields', + { + [ErrorSentinel]: true, + message: 'An error occurred', + code: ErrorCode.VatNotFound, + data: { key: 'value' }, + stack: 'Error stack trace', + cause: 'Another error', + }, + true, + ], + [ + 'valid marshaled error with nested cause', + { + [ErrorSentinel]: true, + message: 'An error occurred', + code: ErrorCode.VatNotFound, + data: { key: 'value' }, + cause: { + [ErrorSentinel]: true, + message: 'Nested error occurred', + }, + }, + true, + ], + [ + 'object with invalid code value', + { + [ErrorSentinel]: true, + message: 'An error occurred', + code: 'ERROR_CODE', + data: { key: 'value' }, + }, + false, + ], + [ + 'object missing the sentinel value', + { + message: 'An error occurred', + code: ErrorCode.VatNotFound, + data: { key: 'value' }, + }, + false, + ], + [ + 'object missing the message value', + { + [ErrorSentinel]: true, + code: ErrorCode.VatNotFound, + data: { key: 'value' }, + }, + false, + ], + [ + 'object missing the code value', + { + [ErrorSentinel]: true, + message: 'An error occurred', + data: { key: 'value' }, + }, + false, + ], + [ + 'object missing the data value', + { + [ErrorSentinel]: true, + message: 'An error occurred', + code: ErrorCode.VatNotFound, + }, + false, + ], + [ + 'object with incorrect sentinel value', + { + [ErrorSentinel]: false, + message: 'An error occurred', + code: ErrorCode.VatNotFound, + data: { key: 'value' }, + }, + false, + ], + ['null value', null, false], + ['undefined value', undefined, false], + ['string value', 'string', false], + ['number value', 123, false], + ['array value', [], false], + ])('should return %s', (_, value, expected) => { + expect(isMarshaledOcapError(value)).toBe(expected); + }); +}); diff --git a/packages/errors/src/marshal/isMarshaledOcapError.ts b/packages/errors/src/marshal/isMarshaledOcapError.ts new file mode 100644 index 000000000..cc15a0840 --- /dev/null +++ b/packages/errors/src/marshal/isMarshaledOcapError.ts @@ -0,0 +1,16 @@ +import { is } from '@metamask/superstruct'; + +import { MarshaledOcapErrorStruct } from '../constants.js'; +import type { MarshaledOcapError } from '../types.js'; + +/** + * Checks if a value is a {@link MarshaledOcapError}. + * + * @param value - The value to check. + * @returns Whether the value is a {@link MarshaledOcapError}. + */ +export function isMarshaledOcapError( + value: unknown, +): value is MarshaledOcapError { + return is(value, MarshaledOcapErrorStruct); +} diff --git a/packages/errors/src/marshal/marshalError.test.ts b/packages/errors/src/marshal/marshalError.test.ts new file mode 100644 index 000000000..be7aa4765 --- /dev/null +++ b/packages/errors/src/marshal/marshalError.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; + +import { marshalError } from './marshalError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import { VatNotFoundError } from '../errors/VatNotFoundError.js'; + +describe('marshalError', () => { + it('should marshal an error', () => { + const error = new Error('foo'); + const marshaledError = marshalError(error); + expect(marshaledError).toStrictEqual( + expect.objectContaining({ + [ErrorSentinel]: true, + message: 'foo', + stack: expect.any(String), + }), + ); + }); + + it('should marshal an error with a cause', () => { + const cause = new Error('baz'); + const error = new Error('foo', { cause }); + const marshaledError = marshalError(error); + expect(marshaledError).toStrictEqual( + expect.objectContaining({ + [ErrorSentinel]: true, + message: 'foo', + stack: expect.any(String), + cause: { + [ErrorSentinel]: true, + message: 'baz', + stack: expect.any(String), + }, + }), + ); + }); + + it('should marshal an error with a non-error cause', () => { + const cause = { bar: 'baz' }; + const error = new Error('foo', { cause }); + const marshaledError = marshalError(error); + expect(marshaledError).toStrictEqual( + expect.objectContaining({ + [ErrorSentinel]: true, + message: 'foo', + stack: expect.any(String), + cause: JSON.stringify(cause), + }), + ); + }); + + it('should marshal an ocap error', () => { + const error = new VatNotFoundError('v1'); + const marshaledError = marshalError(error); + expect(marshaledError).toStrictEqual( + expect.objectContaining({ + [ErrorSentinel]: true, + message: 'Vat does not exist.', + stack: expect.any(String), + code: ErrorCode.VatNotFound, + data: { vatId: 'v1' }, + }), + ); + }); +}); diff --git a/packages/errors/src/marshal/marshalError.ts b/packages/errors/src/marshal/marshalError.ts new file mode 100644 index 000000000..5b6a81fb3 --- /dev/null +++ b/packages/errors/src/marshal/marshalError.ts @@ -0,0 +1,38 @@ +import { getSafeJson } from '@metamask/utils'; + +import { ErrorSentinel } from '../constants.js'; +import type { MarshaledError } from '../types.js'; +import { isOcapError } from '../utils/isOcapError.js'; + +/** + * Marshals an error into a {@link MarshaledError}. + * + * @param error - The error to marshal. + * @returns The marshaled error. + */ +export function marshalError(error: Error): MarshaledError { + const output: MarshaledError = { + [ErrorSentinel]: true, + message: error.message, + }; + + if (error.cause) { + output.cause = + error.cause instanceof Error + ? marshalError(error.cause) + : JSON.stringify(error.cause); + } + + if (error.stack) { + output.stack = error.stack; + } + + if (isOcapError(error)) { + output.code = error.code; + if (error.data) { + output.data = getSafeJson(error.data); + } + } + + return harden(output); +} diff --git a/packages/errors/src/marshal/unmarshalError.test.ts b/packages/errors/src/marshal/unmarshalError.test.ts new file mode 100644 index 000000000..d510b2d95 --- /dev/null +++ b/packages/errors/src/marshal/unmarshalError.test.ts @@ -0,0 +1,116 @@ +import { makeErrorMatcherFactory } from '@ocap/test-utils'; +import { describe, it, expect } from 'vitest'; + +import { unmarshalError } from './unmarshalError.js'; +import { ErrorCode, ErrorSentinel } from '../constants.js'; +import { StreamReadError } from '../errors/StreamReadError.js'; +import { VatAlreadyExistsError } from '../errors/VatAlreadyExistsError.js'; +import type { OcapError } from '../types.js'; +import { isOcapError } from '../utils/isOcapError.js'; + +const makeErrorMatcher = makeErrorMatcherFactory(expect); + +describe('unmarshalError', () => { + it('should unmarshal a marshaled error', () => { + const marshaledError = { + [ErrorSentinel]: true, + message: 'foo', + stack: 'bar', + } as const; + expect(unmarshalError(marshaledError)).toStrictEqual( + makeErrorMatcher('foo'), + ); + }); + + it('should unmarshal a marshaled error with a cause', () => { + const marshaledError = { + [ErrorSentinel]: true, + message: 'foo', + stack: 'bar', + cause: { + [ErrorSentinel]: true, + message: 'baz', + stack: 'qux', + }, + } as const; + expect(unmarshalError(marshaledError)).toStrictEqual( + makeErrorMatcher(new Error('foo', { cause: new Error('baz') })), + ); + }); + + it('should unmarshal a marshaled error with a string cause', () => { + const marshaledError = { + [ErrorSentinel]: true, + message: 'foo', + stack: 'bar', + cause: 'baz', + } as const; + expect(unmarshalError(marshaledError)).toStrictEqual( + makeErrorMatcher(new Error('foo', { cause: 'baz' })), + ); + }); + + it('should unmarshal an ocap error class', () => { + const data = { vatId: 'v123' }; + const marshaledError = { + [ErrorSentinel]: true, + message: 'Vat already exists.', + stack: 'customStack', + code: ErrorCode.VatAlreadyExists, + data, + } as const; + + const expectedError = new VatAlreadyExistsError(data.vatId); + expectedError.stack = 'customStack'; + + const unmarshaledError = unmarshalError(marshaledError) as OcapError; + + expect(unmarshaledError).toStrictEqual(makeErrorMatcher(expectedError)); + expect(isOcapError(unmarshaledError)).toBe(true); + expect(unmarshaledError.code).toBe(ErrorCode.VatAlreadyExists); + expect(unmarshaledError.data).toStrictEqual(data); + }); + + it('should unmarshal an ocap error class with a cause', () => { + const data = { vatId: 'v123' }; + const marshaledError = { + [ErrorSentinel]: true, + message: 'Unexpected stream read error.', + stack: 'customStack', + code: ErrorCode.StreamReadError, + data, + cause: { + [ErrorSentinel]: true, + message: 'foo', + stack: 'bar', + }, + } as const; + + const expectedCauseError = new Error('foo'); + expectedCauseError.stack = 'bar'; + + const expectedError = new StreamReadError(data, expectedCauseError); + expectedError.stack = 'customStack'; + + const unmarshaledError = unmarshalError(marshaledError) as OcapError; + + expect(unmarshaledError).toStrictEqual(makeErrorMatcher(expectedError)); + expect(isOcapError(unmarshaledError)).toBe(true); + expect(unmarshaledError.code).toBe(ErrorCode.StreamReadError); + expect(unmarshaledError.data).toStrictEqual(data); + }); + + it('should throw if the ocap error class is malformed', () => { + const invalidMarshaledError = { + [ErrorSentinel]: true, + message: 'Vat already exists.', + stack: 'customStack', + code: ErrorCode.VatAlreadyExists, + data: 'invalid data', + } as const; + + expect(() => unmarshalError(invalidMarshaledError)).toThrow( + 'At path: data -- Expected an object, but received: "invalid data"', + ); + }); +}); diff --git a/packages/errors/src/marshal/unmarshalError.ts b/packages/errors/src/marshal/unmarshalError.ts new file mode 100644 index 000000000..f2f6851b9 --- /dev/null +++ b/packages/errors/src/marshal/unmarshalError.ts @@ -0,0 +1,34 @@ +import { isMarshaledOcapError } from './isMarshaledOcapError.js'; +import { errorClasses } from '../errors/index.js'; +import type { MarshaledError, OcapError } from '../types.js'; + +/** + * Unmarshals a {@link MarshaledError} into an {@link Error}. + * + * @param marshaledError - The marshaled error to unmarshal. + * @returns The unmarshaled error. + */ +export function unmarshalError( + marshaledError: MarshaledError, +): Error | OcapError { + let error: Error | OcapError; + + if (isMarshaledOcapError(marshaledError)) { + error = errorClasses[marshaledError.code].unmarshal(marshaledError); + } else { + error = new Error(marshaledError.message); + } + + if (marshaledError.cause) { + error.cause = + typeof marshaledError.cause === 'string' + ? marshaledError.cause + : unmarshalError(marshaledError.cause); + } + + if (marshaledError.stack) { + error.stack = marshaledError.stack; + } + + return error; +} diff --git a/packages/errors/src/types.ts b/packages/errors/src/types.ts new file mode 100644 index 000000000..13c106b33 --- /dev/null +++ b/packages/errors/src/types.ts @@ -0,0 +1,22 @@ +import type { Json } from '@metamask/utils'; + +import type { ErrorCode, ErrorSentinel } from './constants.js'; + +export type OcapError = { + code: ErrorCode; + data: Json | undefined; +} & Error; + +export type MarshaledError = { + [ErrorSentinel]: true; + message: string; + code?: ErrorCode; + data?: Json; + stack?: string; + cause?: MarshaledError | string; +}; + +export type MarshaledOcapError = Omit & { + code: ErrorCode; + data: Json; +}; diff --git a/packages/errors/src/utils/isOcapError.test.ts b/packages/errors/src/utils/isOcapError.test.ts new file mode 100644 index 000000000..95f6b20b9 --- /dev/null +++ b/packages/errors/src/utils/isOcapError.test.ts @@ -0,0 +1,54 @@ +import type { Json } from '@metamask/utils'; +import { describe, it, expect } from 'vitest'; + +import { isOcapError } from './isOcapError.js'; +import { BaseError } from '../BaseError.js'; +import { ErrorCode } from '../constants.js'; +import { VatAlreadyExistsError } from '../errors/VatAlreadyExistsError.js'; + +class MockCodedError extends Error { + code: string; + + data: Json | undefined; + + constructor(message: string, code: string, data?: Json) { + super(message); + this.code = code; + this.data = data; + } +} + +describe('isOcapError', () => { + it.each([ + [ + new BaseError(ErrorCode.VatNotFound, 'Base Error'), + true, + 'Base class error', + ], + [new VatAlreadyExistsError('v1'), true, 'VatAlreadyExistsError error'], + [ + new MockCodedError('An error occurred', 'ERROR_CODE'), + false, + 'coded error', + ], + [ + new MockCodedError('An error with data occurred', 'ERROR_CODE_DATA', { + test: 1, + }), + false, + 'coded error', + ], + [new Error('A regular error'), false, 'regular error'], + [ + { + message: 'Not an error', + code: 'SOME_CODE', + data: { test: 1 }, + } as unknown as Error, + false, + 'non-error object', + ], + ])('should return %s for %s', (inputError, expectedResult) => { + expect(isOcapError(inputError)).toBe(expectedResult); + }); +}); diff --git a/packages/errors/src/utils/isOcapError.ts b/packages/errors/src/utils/isOcapError.ts new file mode 100644 index 000000000..1f2c11969 --- /dev/null +++ b/packages/errors/src/utils/isOcapError.ts @@ -0,0 +1,11 @@ +import { BaseError } from '../BaseError.js'; + +/** + * Type guard to check if an error is a custom Ocap error (BaseError). + * + * @param error - The error to check. + * @returns `true` if the error is an instance of BaseError. + */ +export function isOcapError(error: Error): error is BaseError { + return error instanceof BaseError; +} diff --git a/packages/errors/tsconfig.json b/packages/errors/tsconfig.json index 743899d8b..e35ee6316 100644 --- a/packages/errors/tsconfig.json +++ b/packages/errors/tsconfig.json @@ -5,5 +5,6 @@ "lib": ["DOM", "ES2022"], "types": ["ses", "vitest", "vitest/jsdom"] }, + "references": [{ "path": "../test-utils" }], "include": ["./src", "./vite.config.ts", "./vitest.config.ts"] } diff --git a/packages/errors/vitest.config.ts b/packages/errors/vitest.config.ts index d909288c9..a158b2c0e 100644 --- a/packages/errors/vitest.config.ts +++ b/packages/errors/vitest.config.ts @@ -12,6 +12,7 @@ const config = mergeConfig( defineConfig({ test: { pool: 'vmThreads', + setupFiles: '../test-utils/src/env/mock-endo.ts', }, }), ); diff --git a/packages/streams/package.json b/packages/streams/package.json index 3bec7129e..cc5000eca 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -50,6 +50,7 @@ "@endo/stream": "^1.2.6", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^9.3.0", + "@ocap/errors": "workspace:^", "@ocap/utils": "workspace:^" }, "devDependencies": { diff --git a/packages/streams/src/BaseStream.test.ts b/packages/streams/src/BaseStream.test.ts index 38df8d69a..ebf608ad6 100644 --- a/packages/streams/src/BaseStream.test.ts +++ b/packages/streams/src/BaseStream.test.ts @@ -1,8 +1,9 @@ +import { marshalError } from '@ocap/errors'; import { makeErrorMatcherFactory, makePromiseKitMock } from '@ocap/test-utils'; import { describe, expect, it, vi } from 'vitest'; import { BaseReader, BaseWriter } from './BaseStream.js'; -import { makeDoneResult, makePendingResult, marshalError } from './utils.js'; +import { makeDoneResult, makePendingResult } from './utils.js'; import { TestReader, TestWriter } from '../test/stream-mocks.js'; vi.mock('@endo/promise-kit', () => makePromiseKitMock()); diff --git a/packages/streams/src/utils.test.ts b/packages/streams/src/utils.test.ts index 0ac97a4f8..c98ee28fc 100644 --- a/packages/streams/src/utils.test.ts +++ b/packages/streams/src/utils.test.ts @@ -1,19 +1,16 @@ import type { Json } from '@metamask/utils'; +import { ErrorSentinel, marshalError } from '@ocap/errors'; import { makeErrorMatcherFactory } from '@ocap/test-utils'; -import { stringify } from '@ocap/utils'; import { describe, expect, it } from 'vitest'; import type { Dispatchable } from './utils.js'; import { assertIsWritable, - ErrorSentinel, isDispatchable, makeDoneResult, makePendingResult, marshal, - marshalError, unmarshal, - unmarshalError, } from './utils.js'; const makeErrorMatcher = makeErrorMatcherFactory(expect); @@ -89,93 +86,6 @@ describe('makePendingResult', () => { }); }); -describe('marshalError', () => { - it('should marshal an error', () => { - const error = new Error('foo'); - const marshaledError = marshalError(error); - expect(marshaledError).toStrictEqual( - expect.objectContaining({ - [ErrorSentinel]: true, - message: 'foo', - stack: expect.any(String), - }), - ); - }); - - it('should marshal an error with a cause', () => { - const cause = new Error('baz'); - const error = new Error('foo', { cause }); - const marshaledError = marshalError(error); - expect(marshaledError).toStrictEqual( - expect.objectContaining({ - [ErrorSentinel]: true, - message: 'foo', - stack: expect.any(String), - cause: { - [ErrorSentinel]: true, - message: 'baz', - stack: expect.any(String), - }, - }), - ); - }); - - it('should marshal an error with a non-error cause', () => { - const cause = { bar: 'baz' }; - const error = new Error('foo', { cause }); - const marshaledError = marshalError(error); - expect(marshaledError).toStrictEqual( - expect.objectContaining({ - [ErrorSentinel]: true, - message: 'foo', - stack: expect.any(String), - cause: stringify(cause), - }), - ); - }); -}); - -describe('unmarshalError', () => { - it('should unmarshal a marshaled error', () => { - const marshaledError = { - [ErrorSentinel]: true, - message: 'foo', - stack: 'bar', - } as const; - expect(unmarshalError(marshaledError)).toStrictEqual( - makeErrorMatcher('foo'), - ); - }); - - it('should unmarshal a marshaled error with a cause', () => { - const marshaledError = { - [ErrorSentinel]: true, - message: 'foo', - stack: 'bar', - cause: { - [ErrorSentinel]: true, - message: 'baz', - stack: 'qux', - }, - } as const; - expect(unmarshalError(marshaledError)).toStrictEqual( - makeErrorMatcher(new Error('foo', { cause: new Error('baz') })), - ); - }); - - it('should unmarshal a marshaled error with a string cause', () => { - const marshaledError = { - [ErrorSentinel]: true, - message: 'foo', - stack: 'bar', - cause: 'baz', - } as const; - expect(unmarshalError(marshaledError)).toStrictEqual( - makeErrorMatcher(new Error('foo', { cause: 'baz' })), - ); - }); -}); - describe('marshal', () => { it.each([ ['pending result with string', makePendingResult('foo')], diff --git a/packages/streams/src/utils.ts b/packages/streams/src/utils.ts index 91b999653..ba6808466 100644 --- a/packages/streams/src/utils.ts +++ b/packages/streams/src/utils.ts @@ -1,16 +1,9 @@ import type { Reader, Writer } from '@endo/stream'; import type { Struct } from '@metamask/superstruct'; -import { - boolean, - is, - lazy, - literal, - optional, - string, - union, -} from '@metamask/superstruct'; +import { boolean, is, optional } from '@metamask/superstruct'; import { type Json, UnsafeJsonStruct, object } from '@metamask/utils'; -import { stringify } from '@ocap/utils'; +import type { MarshaledError } from '@ocap/errors'; +import { isMarshaledError, marshalError, unmarshalError } from '@ocap/errors'; export type { Reader, Writer }; @@ -86,86 +79,6 @@ export const makePendingResult = ( value, }); -/** - * A sentinel value to detect marshaled errors. - */ -export const ErrorSentinel = '@@MARSHALED_ERROR'; - -/** - * A marshaled error. - */ -type MarshaledError = { - [ErrorSentinel]: true; - message: string; - stack?: string; - cause?: MarshaledError | string; -}; - -const MarshaledErrorStruct: Struct = object({ - [ErrorSentinel]: literal(true), - message: string(), - stack: optional(string()), - cause: optional(union([string(), lazy(() => MarshaledErrorStruct)])), -}) as Struct; - -/** - * Checks if a value is a {@link MarshaledError}. - * - * @param value - The value to check. - * @returns Whether the value is a {@link MarshaledError}. - */ -function isMarshaledError(value: unknown): value is MarshaledError { - return is(value, MarshaledErrorStruct); -} - -/** - * Marshals an error into a {@link MarshaledError}. - * - * @param error - The error to marshal. - * @returns The marshaled error. - */ -export function marshalError(error: Error): MarshaledError { - const output: MarshaledError = { - [ErrorSentinel]: true, - message: error.message, - }; - if (error.cause) { - output.cause = - error.cause instanceof Error - ? marshalError(error.cause) - : stringify(error.cause); - } - if (error.stack) { - output.stack = error.stack; - } - return output; -} - -/** - * Unmarshals a {@link MarshaledError} into an {@link Error}. - * - * @param marshaledError - The marshaled error to unmarshal. - * @returns The unmarshaled error. - */ -export function unmarshalError(marshaledError: MarshaledError): Error { - let output: Error; - if (marshaledError.cause) { - output = new Error(marshaledError.message, { - cause: - typeof marshaledError.cause === 'string' - ? marshaledError.cause - : unmarshalError(marshaledError.cause), - }); - } else { - output = new Error(marshaledError.message); - } - - if (marshaledError.stack) { - output.stack = marshaledError.stack; - } - return output; -} - /** * A value that can be dispatched to the internal transport mechanism of a stream. * diff --git a/packages/streams/tsconfig.build.json b/packages/streams/tsconfig.build.json index 0f6059b8b..7f786ed3f 100644 --- a/packages/streams/tsconfig.build.json +++ b/packages/streams/tsconfig.build.json @@ -7,6 +7,9 @@ "rootDir": "./src", "types": ["ses"] }, - "references": [{ "path": "../utils/tsconfig.build.json" }], + "references": [ + { "path": "../utils/tsconfig.build.json" }, + { "path": "../errors/tsconfig.build.json" } + ], "include": ["./src"] } diff --git a/packages/streams/tsconfig.json b/packages/streams/tsconfig.json index 1c4926288..e431a82af 100644 --- a/packages/streams/tsconfig.json +++ b/packages/streams/tsconfig.json @@ -5,7 +5,7 @@ "lib": ["DOM", "ES2022"], "types": ["ses", "vitest", "vitest/jsdom"] }, - "references": [{ "path": "../test-utils" }], + "references": [{ "path": "../test-utils" }, { "path": "../errors" }], "include": [ "./src", "./src/chrome.d.ts", diff --git a/packages/utils/package.json b/packages/utils/package.json index ee0eab4e2..fb3a34a70 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -43,7 +43,8 @@ }, "dependencies": { "@endo/captp": "^4.4.0", - "@metamask/utils": "^9.3.0" + "@metamask/utils": "^9.3.0", + "@ocap/errors": "workspace:^" }, "devDependencies": { "@arethetypeswrong/cli": "^0.16.4", diff --git a/packages/utils/src/stringify.test.ts b/packages/utils/src/stringify.test.ts index 3d3fe7c08..dee9a139b 100644 --- a/packages/utils/src/stringify.test.ts +++ b/packages/utils/src/stringify.test.ts @@ -1,39 +1,35 @@ +import { VatNotFoundError } from '@ocap/errors'; import { describe, it, expect } from 'vitest'; import { stringify } from './stringify.js'; describe('stringify', () => { - it('stringifies a simple object with default indent', () => { - const input = { key: 'value' }; - const result = stringify(input); - expect(result).toBe(`{\n "key": "value"\n}`); - }); - - it('stringifies a simple object with custom indent', () => { - const input = { key: 'value' }; - const result = stringify(input, 4); - expect(result).toBe(`{\n "key": "value"\n}`); - }); - - it('stringifies an array', () => { - const input = [1, 2, 3]; - const result = stringify(input); - expect(result).toBe(`[\n 1,\n 2,\n 3\n]`); - }); - - it('returns a string for a simple primitive', () => { - expect(stringify(42)).toBe('42'); - expect(stringify('hello')).toBe('"hello"'); - expect(stringify(true)).toBe('true'); - }); - - it('handles null', () => { - expect(stringify(null)).toBe('null'); - }); - - it('handles undefined', () => { - expect(stringify(undefined)).toBe('undefined'); - }); + it.each([ + [ + { key: 'value' }, + 2, + `{\n "key": "value"\n}`, + 'stringifies a simple object with default indent', + ], + [ + { key: 'value' }, + 4, + `{\n "key": "value"\n}`, + 'stringifies a simple object with custom indent', + ], + [[1, 2, 3], 2, `[\n 1,\n 2,\n 3\n]`, 'stringifies an array'], + [42, 2, '42', 'returns a string for a number'], + ['hello', 2, '"hello"', 'returns a string for a string primitive'], + [true, 2, 'true', 'returns a string for a boolean primitive'], + [null, 2, 'null', 'handles null'], + [undefined, 2, 'undefined', 'handles undefined'], + ])( + 'should stringify %s with indent %i', + (input, indent, expected, _description) => { + const result = stringify(input, indent); + expect(result).toBe(expected); + }, + ); it('handles circular references gracefully', () => { const obj: Record = {}; @@ -50,31 +46,53 @@ describe('stringify', () => { ).toBe('function example() {\n return "hello";\n }'); }); - it('handles error objects with default indent', () => { - const error = new Error('An error occurred'); - const result = stringify(error); + it.each([ + [ + new Error('An error occurred'), + 2, + `An error occurred`, + 'handles error objects with default indent', + ], + [ + new Error('Another error occurred'), + 4, + `Another error occurred`, + 'handles error objects with custom indent', + ], + ])('should stringify simple error objects: %s', (error, indent, message) => { + const stackNewlines = error.stack?.replace(/\n/gu, '\\n'); + const result = stringify(error, indent); + expect(result).toContain(`"name": "Error"`); + expect(result).toContain(`"message": "${message}"`); + expect(result).toContain(`"stack": "${stackNewlines}"`); + }); + + it('handles error objects with an error cause that is an error', () => { + const rootCause = new Error('Root cause error'); + const error = new Error('Caused error', { cause: rootCause }); + const result = stringify(error, 2); const stackNewlines = error.stack?.replace(/\n/gu, '\\n'); + const rootStackNewlines = rootCause.stack?.replace(/\n/gu, '\\n'); expect(result).toBe( - `{\n "name": "Error",\n "message": "An error occurred",\n "stack": "${stackNewlines}"\n}`, + `{\n "name": "Error",\n "message": "Caused error",\n "stack": "${stackNewlines}",\n "cause": {\n "name": "Error",\n "message": "Root cause error",\n "stack": "${rootStackNewlines}"\n }\n}`, ); }); - it('handles error objects with custom indent', () => { - const error = new Error('Another error occurred'); - const result = stringify(error, 4); + it('handles error objects with an error cause that is not an error', () => { + const error = new Error('Caused error', { cause: 'root cause' }); + const result = stringify(error, 2); const stackNewlines = error.stack?.replace(/\n/gu, '\\n'); expect(result).toBe( - `{\n "name": "Error",\n "message": "Another error occurred",\n "stack": "${stackNewlines}"\n}`, + `{\n "name": "Error",\n "message": "Caused error",\n "stack": "${stackNewlines}",\n "cause": "\\"root cause\\""\n}`, ); }); - it('handles error objects with a cause', () => { - const rootCause = new Error('Root cause error'); - const error = new Error('Caused error', { cause: rootCause }); + it('handles ocap errors', () => { + const error = new VatNotFoundError('v1'); const result = stringify(error, 2); const stackNewlines = error.stack?.replace(/\n/gu, '\\n'); expect(result).toBe( - `{\n "name": "Error",\n "message": "Caused error",\n "stack": "${stackNewlines}",\n "cause": {\n "name": "Error",\n "message": "Root cause error"\n }\n}`, + `{\n "name": "VatNotFoundError",\n "message": "Vat does not exist.",\n "stack": "${stackNewlines}",\n "code": "VAT_NOT_FOUND",\n "data": "{\\n \\"vatId\\": \\"v1\\"\\n}"\n}`, ); }); }); diff --git a/packages/utils/src/stringify.ts b/packages/utils/src/stringify.ts index 870383dae..ba9d8723d 100644 --- a/packages/utils/src/stringify.ts +++ b/packages/utils/src/stringify.ts @@ -1,3 +1,6 @@ +import type { OcapError } from '@ocap/errors'; +import { isOcapError } from '@ocap/errors'; + /** * Stringify an evaluation result. * @@ -8,28 +11,64 @@ export const stringify = (value: unknown, indent: number = 2): string => { try { if (value instanceof Error) { - const errorObject: Record = { - name: value.name, - message: value.message, - stack: value.stack, - }; - - if (value.cause instanceof Error) { - errorObject.cause = { - name: value.cause.name, - message: value.cause.message, - }; - } - + const errorObject = stringifyError(value); return JSON.stringify(errorObject, null, indent); } const result = JSON.stringify(value, null, indent); - if (result === undefined) { - return String(value); - } - return result; + return result ?? String(value); } catch { return String(value); } }; + +/** + * Helper function to process an error. + * + * @param error - The error to process. + * @returns The processed object. + */ +function stringifyError(error: Error): Record { + return isOcapError(error) + ? createOcapErrorObject(error) + : createErrorObject(error); +} + +/** + * Helper function to create a simplified error object. + * + * @param error - The error to create an object from. + * @returns The error object. + */ +function createErrorObject(error: Error): Record { + const errorObject: Record = { + name: error.name, + message: error.message, + stack: error.stack, + }; + + if (error.cause) { + errorObject.cause = + error.cause instanceof Error + ? stringifyError(error.cause) + : stringify(error.cause); + } + + return errorObject; +} + +/** + * Helper function to create an Ocap error object. + * + * @param error - The Ocap error to create an object from. + * @returns The Ocap error object. + */ +function createOcapErrorObject(error: OcapError): Record { + const errorObject = { + ...createErrorObject(error), + code: error.code, + data: stringify(error.data), + }; + + return errorObject; +} diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json index 9651b671e..219aff97e 100644 --- a/packages/utils/tsconfig.build.json +++ b/packages/utils/tsconfig.build.json @@ -7,7 +7,7 @@ "rootDir": "./src", "types": ["ses"] }, - "references": [], + "references": [{ "path": "../errors/tsconfig.build.json" }], "files": [], "include": ["./src"] } diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index e35ee6316..3978278cb 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -5,6 +5,6 @@ "lib": ["DOM", "ES2022"], "types": ["ses", "vitest", "vitest/jsdom"] }, - "references": [{ "path": "../test-utils" }], + "references": [{ "path": "../test-utils" }, { "path": "../errors" }], "include": ["./src", "./vite.config.ts", "./vitest.config.ts"] } diff --git a/yarn.lock b/yarn.lock index 4072cb441..8312a704f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1140,12 +1140,12 @@ __metadata: linkType: hard "@metamask/object-multiplex@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/object-multiplex@npm:2.0.0" + version: 2.1.0 + resolution: "@metamask/object-multiplex@npm:2.1.0" dependencies: once: "npm:^1.4.0" readable-stream: "npm:^3.6.2" - checksum: 10/54baea752a3ac7c2742c376512e00d4902d383e9da8787574d3b21eb0081523309e24e3915a98f3ae0341d65712b6832d2eb7eeb862f4ef0da1ead52dcde5387 + checksum: 10/e119f695e89eb20c3174f8ac6d74587498d85cff92c37e83e167cb758b3d3147d5b5e1a997d6198d430ebcf2cede6265bf5d4513fe96dbb2d82bbc6167752caa languageName: node linkType: hard @@ -1235,22 +1235,22 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^6.8.0": - version: 6.8.0 - resolution: "@metamask/snaps-sdk@npm:6.8.0" +"@metamask/snaps-sdk@npm:^6.9.0": + version: 6.9.0 + resolution: "@metamask/snaps-sdk@npm:6.9.0" dependencies: "@metamask/key-tree": "npm:^9.1.2" "@metamask/providers": "npm:^17.1.2" "@metamask/rpc-errors": "npm:^6.3.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^9.2.1" - checksum: 10/9c2fa7517f8062f5162a8d5ad115e8e51e811aa6cc852834ec05914fe871267c6d347cdbfe67a44960898cdbe14f1e0395e9b71157fb23cf1e5620998c6f15dd + checksum: 10/ea2c34c4451f671acc6c3c0ad0d46e770e8b7d0741c1d78a30bc36b883f09a10e9a428b8b564ecd0171da95fdf78bb8ac0de261423a1b35de5d22852300a24ee languageName: node linkType: hard "@metamask/snaps-utils@npm:^8.3.0": - version: 8.4.0 - resolution: "@metamask/snaps-utils@npm:8.4.0" + version: 8.4.1 + resolution: "@metamask/snaps-utils@npm:8.4.1" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" @@ -1260,7 +1260,7 @@ __metadata: "@metamask/rpc-errors": "npm:^6.3.1" "@metamask/slip44": "npm:^4.0.0" "@metamask/snaps-registry": "npm:^3.2.1" - "@metamask/snaps-sdk": "npm:^6.8.0" + "@metamask/snaps-sdk": "npm:^6.9.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^9.2.1" "@noble/hashes": "npm:^1.3.1" @@ -1275,7 +1275,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.1.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/9948021d33f5c25e8196728dca01d75d285362393eace61f6d31a327de0ab1f0b5b4e8abf33484b94319d06bf2ccfdc101377adad1de65740c077d327d655a6b + checksum: 10/c68a2fe69dc835c2b996d621fd4698435475d419a85aa557aa000aae0ab7ebb68d2a52f0b28bbab94fff895ece9a94077e3910a21b16d904cff3b9419ca575b6 languageName: node linkType: hard @@ -1469,7 +1469,9 @@ __metadata: "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^9.3.0" + "@ocap/test-utils": "workspace:^" "@ts-bridge/cli": "npm:^0.5.1" "@ts-bridge/shims": "npm:^0.1.1" "@typescript-eslint/eslint-plugin": "npm:^8.8.1" @@ -1683,6 +1685,7 @@ __metadata: "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^9.3.0" + "@ocap/errors": "workspace:^" "@ocap/test-utils": "workspace:^" "@ocap/utils": "workspace:^" "@ts-bridge/cli": "npm:^0.5.1" @@ -1753,6 +1756,7 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/utils": "npm:^9.3.0" + "@ocap/errors": "workspace:^" "@ocap/test-utils": "workspace:^" "@ts-bridge/cli": "npm:^0.5.1" "@ts-bridge/shims": "npm:^0.1.1" @@ -2299,15 +2303,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.8.1, @typescript-eslint/eslint-plugin@npm:^8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/eslint-plugin@npm:8.8.1" +"@typescript-eslint/eslint-plugin@npm:8.9.0, @typescript-eslint/eslint-plugin@npm:^8.8.1": + version: 8.9.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.9.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.8.1" - "@typescript-eslint/type-utils": "npm:8.8.1" - "@typescript-eslint/utils": "npm:8.8.1" - "@typescript-eslint/visitor-keys": "npm:8.8.1" + "@typescript-eslint/scope-manager": "npm:8.9.0" + "@typescript-eslint/type-utils": "npm:8.9.0" + "@typescript-eslint/utils": "npm:8.9.0" + "@typescript-eslint/visitor-keys": "npm:8.9.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -2318,66 +2322,66 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/6d45d7c3b2993f9d4130794596b029e72646f69581741ff2032b33f5c5d6b46c241b854556d04f769c2ef491e117c7d73013a07d74de3a0e0b557e648bc82a9c + checksum: 10/c1858656d7ab3d37674759c838422d8a1b7540e54a25c67c7508c38ee76594a98e8f1f269749f08700f93a7a425e0dca6eb6d031b36539c537e10a32edb4975c languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.8.1, @typescript-eslint/parser@npm:^8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/parser@npm:8.8.1" +"@typescript-eslint/parser@npm:8.9.0, @typescript-eslint/parser@npm:^8.8.1": + version: 8.9.0 + resolution: "@typescript-eslint/parser@npm:8.9.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.8.1" - "@typescript-eslint/types": "npm:8.8.1" - "@typescript-eslint/typescript-estree": "npm:8.8.1" - "@typescript-eslint/visitor-keys": "npm:8.8.1" + "@typescript-eslint/scope-manager": "npm:8.9.0" + "@typescript-eslint/types": "npm:8.9.0" + "@typescript-eslint/typescript-estree": "npm:8.9.0" + "@typescript-eslint/visitor-keys": "npm:8.9.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/f19e9be6e8d3e4b574d5f2b1d7e23e3594ea8d5f0b2bd2e59d2fd237bd0a379597f4b7ba466b7e290c5f3c7bce044107a73b20159c17dc54a4cc6b2ca9470b4b + checksum: 10/6f73af7782856b292b37e43dde83c5babbbdae28da1a4fed764474a9ccbbfcce25903cedde82d6847ad53e0c1a7c2ed53f087b281e6f1408a064498169c0e2d1 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/scope-manager@npm:8.8.1" +"@typescript-eslint/scope-manager@npm:8.9.0": + version: 8.9.0 + resolution: "@typescript-eslint/scope-manager@npm:8.9.0" dependencies: - "@typescript-eslint/types": "npm:8.8.1" - "@typescript-eslint/visitor-keys": "npm:8.8.1" - checksum: 10/ab86b533d0cadaa3f325404ae8cda2c1c8e0b820d7b2265ad376a233bb073aa89783a8d20c2effa77552426f38405edaa71e4aa6a2676613ae8dec0e1f1ba061 + "@typescript-eslint/types": "npm:8.9.0" + "@typescript-eslint/visitor-keys": "npm:8.9.0" + checksum: 10/44dfb640113e8be2f5d25034f5657a9609ee06082b817dc24116c5e1d7a708ca31e8eedcc47f7d309def2ce63be662d1d0a37a1c7bdc7345968a31d04c0a2377 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/type-utils@npm:8.8.1" +"@typescript-eslint/type-utils@npm:8.9.0": + version: 8.9.0 + resolution: "@typescript-eslint/type-utils@npm:8.9.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.8.1" - "@typescript-eslint/utils": "npm:8.8.1" + "@typescript-eslint/typescript-estree": "npm:8.9.0" + "@typescript-eslint/utils": "npm:8.9.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependenciesMeta: typescript: optional: true - checksum: 10/3aed62459e68a49f468004d966c914457db2288979234a9452043bff6d5ac7f2d46490fe13f4bb06fd91af085a50e6ac63b69eb66f9a27ee477f958af4738587 + checksum: 10/aaeb465ed57d140bc0d9a8b81a474eff5d1c63d99479828b4eb83a1a626dcb2b1377052a971be5b4d094d6adcf1cf8e33c41ee13369bd71aed0f9cd9f3528c8a languageName: node linkType: hard -"@typescript-eslint/types@npm:8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/types@npm:8.8.1" - checksum: 10/5ac571810f24a266e1d46a8ce2a6665498fddf757a70eeeec959c993991f72d06a2bee7b848a6b27db958f7771034d8169a77117fd6ca7ed2c3166da9d27396b +"@typescript-eslint/types@npm:8.9.0": + version: 8.9.0 + resolution: "@typescript-eslint/types@npm:8.9.0" + checksum: 10/4d087153605ec23c980f9bc807b122edefff828e0c3b52ef531f4b8e1d30078c39f95e84019370a395bf97eed0d7886cc50b8cd545c287f8a2a21b301272377a languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.8.1" +"@typescript-eslint/typescript-estree@npm:8.9.0": + version: 8.9.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.9.0" dependencies: - "@typescript-eslint/types": "npm:8.8.1" - "@typescript-eslint/visitor-keys": "npm:8.8.1" + "@typescript-eslint/types": "npm:8.9.0" + "@typescript-eslint/visitor-keys": "npm:8.9.0" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" @@ -2387,31 +2391,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/b569cd362c5f68cf0e1ca53a85bf78c989f10fe4b680423d47c6089bef7cb60b3ed10927232f57dd666e457e43259cec9415da54f2c7b2425062d7acd2e7c98e + checksum: 10/855b433f24fad5d6791c16510d035ded31ccfd17235b45f4dcb7fa89ed57268e4bf4bf79311c5323037e6243da506b2edcb113aa51339291efb344b6d8035b1a languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.8.1, @typescript-eslint/utils@npm:^8.1.0, @typescript-eslint/utils@npm:^8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/utils@npm:8.8.1" +"@typescript-eslint/utils@npm:8.9.0, @typescript-eslint/utils@npm:^8.1.0, @typescript-eslint/utils@npm:^8.8.1": + version: 8.9.0 + resolution: "@typescript-eslint/utils@npm:8.9.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.8.1" - "@typescript-eslint/types": "npm:8.8.1" - "@typescript-eslint/typescript-estree": "npm:8.8.1" + "@typescript-eslint/scope-manager": "npm:8.9.0" + "@typescript-eslint/types": "npm:8.9.0" + "@typescript-eslint/typescript-estree": "npm:8.9.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - checksum: 10/8ecd827af49d3c69ebe65283e5a4e6b44b48f24392319ed9336b8eec47e84fcbcc3e1b5f855ed6b782996cfc0cd289a0a14e40dd69234fd60eeee0a29047bde5 + checksum: 10/84efd10d6aa212103615cf52211a79f1ca02dc4fbf2dbb3a8d2aa49cd19f582b04c219ee98ed1ab77a503f967d82ce56521b1663359ff3e7faaa1f8798c19697 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.8.1": - version: 8.8.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.8.1" +"@typescript-eslint/visitor-keys@npm:8.9.0": + version: 8.9.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.9.0" dependencies: - "@typescript-eslint/types": "npm:8.8.1" + "@typescript-eslint/types": "npm:8.9.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/b5bfb4c9a98d3320639abcfd5aae52dd9c8af477743c5e324ceee1a9ea5f101e0ff7da3de08d3ef66e57854a86e155359bafff13f184493db9e0dffaf9e363c7 + checksum: 10/809097884b8c706f549d99bafa3e0958bd893b3deb190297110f2f1f9360e12064335c8f2df68f39be7d744d2032b5eb57b710c9671eb38f793877ab9364c731 languageName: node linkType: hard @@ -3607,9 +3611,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.5.28": - version: 1.5.36 - resolution: "electron-to-chromium@npm:1.5.36" - checksum: 10/659f637b7384714d5a732de0e5baca007fa1ae741faa4a0f9eb576d65a6a6d30c553caae27df5df7307c65484c0fbcd2ac453df27848d04f7dd27b81dea072a2 + version: 1.5.38 + resolution: "electron-to-chromium@npm:1.5.38" + checksum: 10/862f57480d42e4218a7be7ce71847fb5cf00423bc0b8b9168a978edadfd77fdcef2eb09e9c37821a4dd73b034a72d7cd1dbf2f096297abcd7e8664cc571602d4 languageName: node linkType: hard @@ -3919,8 +3923,8 @@ __metadata: linkType: hard "eslint-plugin-jsdoc@npm:^50.3.1": - version: 50.4.0 - resolution: "eslint-plugin-jsdoc@npm:50.4.0" + version: 50.4.1 + resolution: "eslint-plugin-jsdoc@npm:50.4.1" dependencies: "@es-joy/jsdoccomment": "npm:~0.49.0" are-docs-informative: "npm:^0.0.2" @@ -3935,7 +3939,7 @@ __metadata: synckit: "npm:^0.9.1" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10/929a7da3bce3ea7a027d841fb04f590e1f95fa7d644af0721b176d588b64324d19059cc65d4ba07be2ac891f1c5af23ec8aefa69e1c999aaba39945b50249d90 + checksum: 10/0368c68b30bfa15525b3934e6223e593e24dd1903ae9f1cc898207fca039052dbce3955da365ab2b8cdeb7ca25fef2b1cf26f5669244bd28290ac79c70af5188 languageName: node linkType: hard @@ -7454,16 +7458,16 @@ __metadata: linkType: hard "typescript-eslint@npm:^8.8.1": - version: 8.8.1 - resolution: "typescript-eslint@npm:8.8.1" + version: 8.9.0 + resolution: "typescript-eslint@npm:8.9.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.8.1" - "@typescript-eslint/parser": "npm:8.8.1" - "@typescript-eslint/utils": "npm:8.8.1" + "@typescript-eslint/eslint-plugin": "npm:8.9.0" + "@typescript-eslint/parser": "npm:8.9.0" + "@typescript-eslint/utils": "npm:8.9.0" peerDependenciesMeta: typescript: optional: true - checksum: 10/f44f60069a88b98b7be269546990a9e75c8af8bde06ef9e095d6c8d8d416746a821047a580201b24a41eec6ea9457fdb9fcae6a9c32096aca2569133c981407f + checksum: 10/34ec65b9f3b9b2a8a17e02bf85c3eab1b9c8a1ccd51883038a2e69f8064177de68916b92976d3b1fe96e52c95898e0de204f43269b76e2230fbefad41cf9b061 languageName: node linkType: hard