diff --git a/packages/collections/src/batch.ts b/packages/collections/src/batch.ts index 27cf4877..1e1b5cf6 100644 --- a/packages/collections/src/batch.ts +++ b/packages/collections/src/batch.ts @@ -1,4 +1,4 @@ -import { AbortOptions, CodedError, ErrorCode, operationError } from '@mithic/commons'; +import { AbortOptions, CodedError, OperationError } from '@mithic/commons'; import { MaybeAsyncAppendOnlySet, MaybeAsyncReadonlySet, MaybeAsyncReadonlySetBatch, MaybeAsyncSet, MaybeAsyncSetAddBatch, MaybeAsyncSetDeleteBatch, MaybeAsyncSetUpdateBatch @@ -48,8 +48,12 @@ export async function* deleteMany( try { await data.delete(key, options); yield; - } catch (error) { - yield operationError('Failed to delete key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to delete key', { + cause, + code: (cause as CodedError)?.code, + detail: key, + }); } } } @@ -67,8 +71,12 @@ export async function* addMany( try { await data.add(key, options); yield; - } catch (error) { - yield operationError('Failed to add key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to add key', { + cause, + code: (cause as CodedError)?.code, + detail: key, + }); } } } @@ -86,8 +94,12 @@ export async function* setMany( try { await data.set(key, value, options); yield; - } catch (error) { - yield operationError('Failed to set key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to set key', { + cause, + code: (cause as CodedError)?.code, + detail: key, + }); } } } @@ -107,8 +119,12 @@ export async function* updateSetMany( try { await (isDelete ? data.delete(key, options) : data.add(key, options)); yield; - } catch (error) { - yield operationError(`Failed to update key`, (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError(`failed to update key`, { + cause, + code: (cause as CodedError)?.code, + detail: key, + }); } } } @@ -127,8 +143,12 @@ export async function* updateMapMany( try { await ((value !== void 0) ? data.set(key, value, options) : data.delete(key, options)); yield; - } catch (error) { - yield operationError('Failed to update key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to update key', { + cause, + code: (cause as CodedError)?.code, + detail: key, + }); } } } @@ -145,11 +165,15 @@ export async function* putMany( options?.signal?.throwIfAborted(); try { yield [await data.put(value, options)]; - } catch (error) { + } catch (cause) { const key = await data.getKey(value, options); yield [ key, - operationError('Failed to put key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error) + new OperationError('failed to put key', { + cause, + code: (cause as CodedError)?.code, + detail: key, + }) ]; } } diff --git a/packages/collections/src/impl/__tests__/batchmap.spec.ts b/packages/collections/src/impl/__tests__/batchmap.spec.ts index 3e9e784c..b009aa94 100644 --- a/packages/collections/src/impl/__tests__/batchmap.spec.ts +++ b/packages/collections/src/impl/__tests__/batchmap.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { ErrorCode, operationError } from '@mithic/commons'; import { SyncMapBatchAdapter } from '../batchmap.js'; +import { OperationError } from '@mithic/commons'; const DATA = [[1, 'value1'], [2, 'value2'], [3, 'value3']] as const; @@ -43,7 +43,7 @@ describe(SyncMapBatchAdapter.name, () => { it('should handle errors while setting many values synchronously in the map', () => { const entries = [[1, 'x'], [2, 'y'], [3, 'z']] as [number, string][]; - const error = new Error('Failed'); + const error = new Error('failed'); adapter.set = jest.fn(key => { if (key === 2) { throw error; @@ -52,7 +52,7 @@ describe(SyncMapBatchAdapter.name, () => { const errors = [...adapter.setMany(entries)]; expect(errors).toEqual([ void 0, - operationError('Failed to set value', ErrorCode.OpFailed, 2, error), + new OperationError('failed to set value', { detail: 2, cause: error }), void 0 ]); }); @@ -68,7 +68,7 @@ describe(SyncMapBatchAdapter.name, () => { it('should handle errors while deleting many values synchronously from the map', () => { const keys = [1, 2, 3]; - const error = new Error('Failed'); + const error = new Error('failed'); adapter.delete = jest.fn(key => { if (key === 3) { throw error; @@ -77,7 +77,7 @@ describe(SyncMapBatchAdapter.name, () => { const errors = [...adapter.deleteMany(keys)]; expect(errors).toEqual([ void 0, void 0, - operationError('Failed to delete key', ErrorCode.OpFailed, 3, error), + new OperationError('failed to delete key', { detail: 3, cause: error }), ]); }); }); @@ -95,7 +95,7 @@ describe(SyncMapBatchAdapter.name, () => { it('should handle errors while setting many values synchronously in the map', () => { const entries = [[1, 'x'], [2], [3, 'z']] as [number, string | undefined][]; - const error = new Error('Failed'); + const error = new Error('failed'); adapter.set = jest.fn(key => { if (key === 3) { throw error; @@ -105,7 +105,7 @@ describe(SyncMapBatchAdapter.name, () => { expect(errors).toEqual([ void 0, void 0, - operationError('Failed to update key', ErrorCode.OpFailed, 3, error), + new OperationError('failed to update key', { detail: 3, cause: error }), ]); }); }); diff --git a/packages/collections/src/impl/__tests__/encodedmap.spec.ts b/packages/collections/src/impl/__tests__/encodedmap.spec.ts index 1443949b..8ffd5d60 100644 --- a/packages/collections/src/impl/__tests__/encodedmap.spec.ts +++ b/packages/collections/src/impl/__tests__/encodedmap.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { ErrorCode, operationError } from '@mithic/commons'; +import { OperationError } from '@mithic/commons'; import { MaybeAsyncMap } from '../../map.js'; import { EncodedMap } from '../encodedmap.js'; @@ -90,15 +90,16 @@ describe.each([ it('should return errors from underlying map', async () => { if ('setMany' in map.map) return; // skip test - jest.spyOn(map.map, 'set').mockImplementation(() => { throw new Error('error'); }); + const cause = new Error('error'); + jest.spyOn(map.map, 'set').mockImplementation(() => { throw cause; }); const results = []; for await (const error of map.setMany([[K1, 1], [K3, 3]])) { results.push(error); } expect(results).toEqual([ - operationError(`Failed to set key`, ErrorCode.OpFailed, K1, new Error('error')), - operationError(`Failed to set key`, ErrorCode.OpFailed, K3, new Error('error')), + new OperationError(`failed to set key`, { detail: K1, cause }), + new OperationError(`failed to set key`, { detail: K3, cause }), ]) }); }); @@ -115,15 +116,16 @@ describe.each([ it('should return errors from underlying map', async () => { if ('deleteMany' in map.map) return; // skip test - jest.spyOn(map.map, 'delete').mockImplementation(() => { throw new Error('error'); }); + const cause = new Error('error'); + jest.spyOn(map.map, 'delete').mockImplementation(() => { throw cause; }); const results = []; for await (const error of map.deleteMany([K1, K2])) { results.push(error); } expect(results).toEqual([ - operationError(`Failed to delete key`, ErrorCode.OpFailed, K1, new Error('error')), - operationError(`Failed to delete key`, ErrorCode.OpFailed, K2, new Error('error')), + new OperationError(`failed to delete key`, { detail: K1, cause }), + new OperationError(`failed to delete key`, { detail: K2, cause }), ]) }); }); @@ -143,16 +145,17 @@ describe.each([ it('should return errors from underlying map', async () => { if ('updateMany' in map.map) return; // skip test - jest.spyOn(map.map, 'set').mockImplementation(() => { throw new Error('error'); }); - jest.spyOn(map.map, 'delete').mockImplementation(() => { throw new Error('error'); }); + const cause = new Error('error'); + jest.spyOn(map.map, 'set').mockImplementation(() => { throw cause; }); + jest.spyOn(map.map, 'delete').mockImplementation(() => { throw cause; }); const results = []; for await (const error of map.updateMany([[K1, 123], [K2, void 0]])) { results.push(error); } expect(results).toEqual([ - operationError(`Failed to update key`, ErrorCode.OpFailed, K1, new Error('error')), - operationError(`Failed to update key`, ErrorCode.OpFailed, K2, new Error('error')), + new OperationError(`failed to update key`, { detail: K1, cause }), + new OperationError(`failed to update key`, { detail: K2, cause }), ]) }); }); diff --git a/packages/collections/src/impl/__tests__/encodedset.spec.ts b/packages/collections/src/impl/__tests__/encodedset.spec.ts index 9815ffdd..7b4fb5ab 100644 --- a/packages/collections/src/impl/__tests__/encodedset.spec.ts +++ b/packages/collections/src/impl/__tests__/encodedset.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { ErrorCode, operationError } from '@mithic/commons'; +import { OperationError } from '@mithic/commons'; import { MaybeAsyncSet } from '../../set.js'; import { EncodedSet } from '../encodedset.js'; @@ -79,15 +79,16 @@ describe.each([ it('should return errors from underlying set', async () => { if ('addMany' in set.set) return; // skip test - jest.spyOn(set.set, 'add').mockImplementation(() => { throw new Error('error'); }); + const cause = new Error('error') + jest.spyOn(set.set, 'add').mockImplementation(() => { throw cause; }); const results = []; for await (const error of set.addMany([K1, K2])) { results.push(error); } expect(results).toEqual([ - operationError(`Failed to add key`, ErrorCode.OpFailed, K1, new Error('error')), - operationError(`Failed to add key`, ErrorCode.OpFailed, K2, new Error('error')), + new OperationError(`failed to add key`, { detail: K1, cause }), + new OperationError(`failed to add key`, { detail: K2, cause }), ]) }); }); @@ -104,15 +105,16 @@ describe.each([ it('should return errors from underlying set', async () => { if ('deleteMany' in set.set) return; // skip test - jest.spyOn(set.set, 'delete').mockImplementation(() => { throw new Error('error'); }); + const cause = new Error('error') + jest.spyOn(set.set, 'delete').mockImplementation(() => { throw cause; }); const results = []; for await (const error of set.deleteMany([K1, K2])) { results.push(error); } expect(results).toEqual([ - operationError(`Failed to delete key`, ErrorCode.OpFailed, K1, new Error('error')), - operationError(`Failed to delete key`, ErrorCode.OpFailed, K2, new Error('error')), + new OperationError(`failed to delete key`, { detail: K1, cause }), + new OperationError(`failed to delete key`, { detail: K2, cause }), ]) }); }); @@ -129,16 +131,17 @@ describe.each([ it('should return errors from underlying set', async () => { if ('addMany' in set.set) return; // skip test - jest.spyOn(set.set, 'add').mockImplementation(() => { throw new Error('error'); }); - jest.spyOn(set.set, 'delete').mockImplementation(() => { throw new Error('error'); }); + const cause = new Error('error') + jest.spyOn(set.set, 'add').mockImplementation(() => { throw cause; }); + jest.spyOn(set.set, 'delete').mockImplementation(() => { throw cause; }); const results = []; for await (const error of set.updateMany([[K1, true], [K3]])) { results.push(error); } expect(results).toEqual([ - operationError(`Failed to update key`, ErrorCode.OpFailed, K1, new Error('error')), - operationError(`Failed to update key`, ErrorCode.OpFailed, K3, new Error('error')), + new OperationError(`failed to update key`, { detail: K1, cause }), + new OperationError(`failed to update key`, { detail: K3, cause }), ]) }); }); diff --git a/packages/collections/src/impl/batchmap.ts b/packages/collections/src/impl/batchmap.ts index 3afec0db..b84bd889 100644 --- a/packages/collections/src/impl/batchmap.ts +++ b/packages/collections/src/impl/batchmap.ts @@ -1,4 +1,4 @@ -import { AbortOptions, CodedError, ErrorCode, operationError } from '@mithic/commons'; +import { AbortOptions, CodedError, OperationError } from '@mithic/commons'; import { MaybeAsyncMap, MaybeAsyncMapBatch } from '../map.js'; /** Abstract base class to provide default synchronous batch API implementations for a {@link MaybeAsyncMap}. */ @@ -28,8 +28,12 @@ export abstract class SyncMapBatchAdapter implements MaybeAsyncMap, try { this.set(key, value, options); yield; - } catch (error) { - yield operationError('Failed to set value', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to set value', { + cause, + code: (cause as CodedError)?.code, + detail: key + }); } } } @@ -40,8 +44,12 @@ export abstract class SyncMapBatchAdapter implements MaybeAsyncMap, try { this.delete(key, options); yield; - } catch (error) { - yield operationError('Failed to delete key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to delete key', { + cause, + code: (cause as CodedError)?.code, + detail: key + }); } } } @@ -52,8 +60,12 @@ export abstract class SyncMapBatchAdapter implements MaybeAsyncMap, try { (value !== void 0) ? this.set(key, value, options) : this.delete(key, options); yield; - } catch (error) { - yield operationError('Failed to update key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to update key', { + cause, + code: (cause as CodedError)?.code, + detail: key + }); } } } diff --git a/packages/collections/src/impl/camap.ts b/packages/collections/src/impl/camap.ts index 19e3b6bb..eb37bda0 100644 --- a/packages/collections/src/impl/camap.ts +++ b/packages/collections/src/impl/camap.ts @@ -1,5 +1,5 @@ import { - AbortOptions, CodedError, ContentId, ErrorCode, MaybePromise, maybeAsync, operationError, sha256 + AbortOptions, CodedError, ContentId, MaybePromise, OperationError, maybeAsync, sha256 } from '@mithic/commons'; import { CID } from 'multiformats'; import * as raw from 'multiformats/codecs/raw'; @@ -71,12 +71,11 @@ export class ContentAddressedMapStore< const key = entries[i++][0]; yield [ key, - error && operationError( - 'Failed to put', - (error as CodedError)?.code ?? ErrorCode.OpFailed, - key, - error - ) + error && new OperationError('failed to put', { + code: (error as CodedError)?.code, + detail: key, + cause: error + }) ]; } } diff --git a/packages/collections/src/impl/indexeddbmap.ts b/packages/collections/src/impl/indexeddbmap.ts index d9509b99..8570a42a 100644 --- a/packages/collections/src/impl/indexeddbmap.ts +++ b/packages/collections/src/impl/indexeddbmap.ts @@ -1,4 +1,4 @@ -import { AbortOptions, CodedError, DisposableCloseable, ErrorCode, Startable, operationError } from '@mithic/commons'; +import { AbortOptions, CodedError, DisposableCloseable, OperationError, Startable } from '@mithic/commons'; import { MaybeAsyncMap, MaybeAsyncMapBatch } from '../map.js'; import { RangeQueryOptions, RangeQueryable } from '../query.js'; @@ -83,8 +83,12 @@ export class IndexedDBMap try { await asPromise(store.put(value, key)); yield; - } catch (error) { - yield operationError('Failed to set value', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to set value', { + cause, + code: (cause as CodedError)?.code, + detail: key + }); } } } @@ -97,8 +101,12 @@ export class IndexedDBMap try { await asPromise(store.delete(key)); yield; - } catch (error) { - yield operationError('Failed to delete key', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to delete key', { + cause, + code: (cause as CodedError)?.code, + detail: key + }); } } } @@ -115,8 +123,12 @@ export class IndexedDBMap await asPromise(store.put(value, key)); } yield; - } catch (error) { - yield operationError('Failed to update value', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error); + } catch (cause) { + yield new OperationError('failed to update value', { + cause, + code: (cause as CodedError)?.code, + detail: key + }); } } } diff --git a/packages/commons/src/error/__tests__/__snapshots__/errors.spec.ts.snap b/packages/commons/src/error/__tests__/__snapshots__/errors.spec.ts.snap index a54ca1a4..4ab32392 100644 --- a/packages/commons/src/error/__tests__/__snapshots__/errors.spec.ts.snap +++ b/packages/commons/src/error/__tests__/__snapshots__/errors.spec.ts.snap @@ -1,20 +1,22 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`abortError should initialize with given reason 1`] = `[AbortError: test reason]`; +exports[`AbortError should initialize with given reason 1`] = `[AbortError: test reason]`; -exports[`abortError should initialize with given reason 2`] = ` +exports[`AbortError should initialize with given reason 2`] = ` { "code": "ABORT_ERR", - "detail": undefined, + "detail": { + "detail": "issue", + }, "name": "AbortError", } `; -exports[`invalidStateError should initialize with given reason 1`] = `[InvalidStateError: test reason]`; +exports[`InvalidStateError should initialize with given reason 1`] = `[InvalidStateError: test reason]`; -exports[`invalidStateError should initialize with given reason 2`] = ` +exports[`InvalidStateError should initialize with given reason 2`] = ` { - "code": "ERR", + "code": "INVALID_STATE_ERR", "detail": { "detail": "issue", }, @@ -22,11 +24,11 @@ exports[`invalidStateError should initialize with given reason 2`] = ` } `; -exports[`notFoundError should initialize with given reason 1`] = `[NotFoundError: test reason]`; +exports[`NotFoundError should initialize with given reason 1`] = `[NotFoundError: test reason]`; -exports[`notFoundError should initialize with given reason 2`] = ` +exports[`NotFoundError should initialize with given reason 2`] = ` { - "code": "ERR_NOT_FOUND", + "code": "NOT_FOUND_ERR", "detail": { "detail": "issue", }, @@ -34,14 +36,50 @@ exports[`notFoundError should initialize with given reason 2`] = ` } `; -exports[`operationError should initialize with given parameters 1`] = `[OperationError: test reason]`; +exports[`NotSupportedError should initialize with given reason 1`] = `[NotSupportedError: test reason]`; + +exports[`NotSupportedError should initialize with given reason 2`] = ` +{ + "code": "NOT_SUPPORTED_ERR", + "detail": { + "detail": "issue", + }, + "name": "NotSupportedError", +} +`; + +exports[`OperationError should initialize with given reason 1`] = `[OperationError: test reason]`; + +exports[`OperationError should initialize with given reason 2`] = ` +{ + "code": "ERR_OPERATION_FAILED", + "detail": { + "detail": "issue", + }, + "name": "OperationError", +} +`; + +exports[`OperationError should initialize with given reason and code 1`] = `[OperationError: test reason]`; -exports[`operationError should initialize with given parameters 2`] = ` +exports[`OperationError should initialize with given reason and code 2`] = ` { - "code": "ERR_INVALID_ARG_VALUE", + "code": "TEST_ERR", "detail": { "detail": "issue", }, "name": "OperationError", } `; + +exports[`TimeoutError should initialize with given reason 1`] = `[TimeoutError: test reason]`; + +exports[`TimeoutError should initialize with given reason 2`] = ` +{ + "code": "TIMEOUT_ERR", + "detail": { + "detail": "issue", + }, + "name": "TimeoutError", +} +`; diff --git a/packages/commons/src/error/__tests__/error.spec.ts b/packages/commons/src/error/__tests__/error.spec.ts index 5e351d7f..4726524e 100644 --- a/packages/commons/src/error/__tests__/error.spec.ts +++ b/packages/commons/src/error/__tests__/error.spec.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from '@jest/globals'; -import { ErrorCode } from '../enums.js'; import { CodedError } from '../error.js'; describe(CodedError.name, () => { @@ -7,7 +6,7 @@ describe(CodedError.name, () => { const cause = new Error('Error cause'); const error = new CodedError('Error message', { name: 'TestError', - code: ErrorCode.Abort, + code: 'ABORT_ERR', detail: { 'this': 'is a testing' }, cause }); diff --git a/packages/commons/src/error/__tests__/errors.spec.ts b/packages/commons/src/error/__tests__/errors.spec.ts index c872fc7b..cc9c20ef 100644 --- a/packages/commons/src/error/__tests__/errors.spec.ts +++ b/packages/commons/src/error/__tests__/errors.spec.ts @@ -1,11 +1,35 @@ import { describe, expect, it } from '@jest/globals'; -import { ErrorCode } from '../enums.js'; -import { abortError, invalidStateError, notFoundError, operationError } from '../errors.js'; +import { AbortError, InvalidStateError, NotFoundError, NotSupportedError, OperationError, TimeoutError } from '../errors.js'; -describe(abortError.name, () => { +describe(AbortError.name, () => { it('should initialize with given reason', () => { + const detail = { 'detail': 'issue' }; + const cause = new Error('of cause'); + const error = new AbortError('test reason', { detail, cause }); + + expect(error).toMatchSnapshot(); + expect({ ...error }).toMatchSnapshot(); + expect(error.cause).toBe(cause); + }); +}); + +describe(TimeoutError.name, () => { + it('should initialize with given reason', () => { + const detail = { 'detail': 'issue' }; + const cause = new Error('of cause'); + const error = new TimeoutError('test reason', { detail, cause }); + + expect(error).toMatchSnapshot(); + expect({ ...error }).toMatchSnapshot(); + expect(error.cause).toBe(cause); + }); +}); + +describe(NotFoundError.name, () => { + it('should initialize with given reason', () => { + const detail = { 'detail': 'issue' }; const cause = new Error('of cause'); - const error = abortError('test reason', cause); + const error = new NotFoundError('test reason', { detail, cause }); expect(error).toMatchSnapshot(); expect({ ...error }).toMatchSnapshot(); @@ -13,11 +37,11 @@ describe(abortError.name, () => { }); }); -describe(notFoundError.name, () => { +describe(NotSupportedError.name, () => { it('should initialize with given reason', () => { const detail = { 'detail': 'issue' }; const cause = new Error('of cause'); - const error = notFoundError('test reason', detail, cause); + const error = new NotSupportedError('test reason', { detail, cause }); expect(error).toMatchSnapshot(); expect({ ...error }).toMatchSnapshot(); @@ -25,11 +49,11 @@ describe(notFoundError.name, () => { }); }); -describe(invalidStateError.name, () => { +describe(InvalidStateError.name, () => { it('should initialize with given reason', () => { const detail = { 'detail': 'issue' }; const cause = new Error('of cause'); - const error = invalidStateError('test reason', ErrorCode.Error, detail, cause); + const error = new InvalidStateError('test reason', { detail, cause }); expect(error).toMatchSnapshot(); expect({ ...error }).toMatchSnapshot(); @@ -37,11 +61,21 @@ describe(invalidStateError.name, () => { }); }); -describe(operationError.name, () => { - it('should initialize with given parameters', () => { +describe(OperationError.name, () => { + it('should initialize with given reason', () => { + const detail = { 'detail': 'issue' }; + const cause = new Error('of cause'); + const error = new OperationError('test reason', { detail, cause }); + + expect(error).toMatchSnapshot(); + expect({ ...error }).toMatchSnapshot(); + expect(error.cause).toBe(cause); + }); + + it('should initialize with given reason and code', () => { const detail = { 'detail': 'issue' }; const cause = new Error('of cause'); - const error = operationError('test reason', ErrorCode.InvalidArg, detail, cause); + const error = new OperationError('test reason', { detail, cause, code: 'TEST_ERR' }); expect(error).toMatchSnapshot(); expect({ ...error }).toMatchSnapshot(); diff --git a/packages/commons/src/error/code.ts b/packages/commons/src/error/code.ts new file mode 100644 index 00000000..73b4fc11 --- /dev/null +++ b/packages/commons/src/error/code.ts @@ -0,0 +1,31 @@ +/** + * Common error codes. + * @packageDocumentation + */ + +/** Generic error code. */ +export const ERR = 'ERR'; + +/** Abort error code. */ +export const ABORT_ERR = 'ABORT_ERR'; + +/** Timeout error code. */ +export const TIMEOUT_ERR = 'TIMEOUT_ERR'; + +/** Not found error code. */ +export const NOT_FOUND_ERR = 'NOT_FOUND_ERR'; + +/** Not supported error code. */ +export const NOT_SUPPORTED_ERR = 'NOT_SUPPORTED_ERR'; + +/** Invalid state error code. */ +export const INVALID_STATE_ERR = 'INVALID_STATE_ERR'; + +/** Generic operation failed error code. */ +export const ERR_OPERATION_FAILED = 'ERR_OPERATION_FAILED'; + +/** Error code for missing dependency. */ +export const ERR_DEPENDENCY_MISSING = 'ERR_DEPENDENCY_MISSING'; + +/** Error code for already exist content. */ +export const ERR_EXIST = 'ERR_EXIST'; diff --git a/packages/commons/src/error/enums.ts b/packages/commons/src/error/enums.ts deleted file mode 100644 index 4e96b3e6..00000000 --- a/packages/commons/src/error/enums.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Error names. */ -export enum ErrorName { - Coded = 'CodedError', - Abort = 'AbortError', - InvalidState = 'InvalidStateError', - NotFound = 'NotFoundError', - OpFailed = 'OperationError', -} - -/** Error codes. */ -export enum ErrorCode { - Error = 'ERR', - Abort = 'ABORT_ERR', - InvalidArg = 'ERR_INVALID_ARG_VALUE', - InvalidState = 'ERR_INVALID_STATE', - Exist = 'ERR_EXIST', - NotFound = 'ERR_NOT_FOUND', - MissingDep = 'ERR_MISSING_DEPENDENCY', - OpFailed = 'ERR_OPERATION_FAILED', - UnsupportedOp = 'ERR_UNSUPPORTED_OPERATION', - CryptoInvalidIV = 'ERR_CRYPTO_INVALID_IV', - CryptoInvalidKey = 'ERR_CRYPTO_INVALID_KEYPAIR', - CryptoKeyLen = 'ERR_CRYPTO_INVALID_KEYLEN', - CryptoMsgLen = 'ERR_CRYPTO_INVALID_MESSAGELEN', -} diff --git a/packages/commons/src/error/error.ts b/packages/commons/src/error/error.ts index 60958872..fad484b8 100644 --- a/packages/commons/src/error/error.ts +++ b/packages/commons/src/error/error.ts @@ -1,9 +1,9 @@ -import { ErrorCode, ErrorName } from './enums.js'; +import { ERR } from './code.js'; /** Error with error code. */ export class CodedError extends Error { /** Thr error code. */ - public readonly code: ErrorCode | string; + public readonly code: string; /** The error cause. */ public override readonly cause?: E; @@ -11,25 +11,31 @@ export class CodedError extends Error { /** Returns any data passed when initializing the error. */ public detail?: T; - constructor( + public constructor( message?: string, options?: CodedErrorOptions, ) { super(message, options); - this.name = options?.name ?? ErrorName.Coded; - this.code = options?.code ?? ErrorCode.Error; + this.name = options?.name ?? CodedError.name; + this.code = options?.code ?? ERR; this.detail = options?.detail; } } /** Options for initializing a {@link CodedError}. */ -export interface CodedErrorOptions extends ErrorOptions { - /** Error code. */ - code?: ErrorCode | string; - +export interface CodedErrorOptions extends ErrorCodeDetailOptions { /** Error name. */ name?: string; +} + +/** Options for initializing an error with code and detail. */ +export interface ErrorCodeDetailOptions extends ErrorDetailOptions { + /** Error code. */ + code?: string; +} +/** Options for initializing an error with detail. */ +export interface ErrorDetailOptions extends ErrorOptions { /** Error details. */ detail?: T; } diff --git a/packages/commons/src/error/errors.ts b/packages/commons/src/error/errors.ts index 98646148..d9255132 100644 --- a/packages/commons/src/error/errors.ts +++ b/packages/commons/src/error/errors.ts @@ -1,47 +1,64 @@ -import { ErrorCode, ErrorName } from './enums.js'; -import { CodedError } from './error.js'; +import { + ABORT_ERR, ERR_OPERATION_FAILED, INVALID_STATE_ERR, NOT_FOUND_ERR, NOT_SUPPORTED_ERR, TIMEOUT_ERR +} from './code.js'; +import { CodedError, ErrorCodeDetailOptions, ErrorDetailOptions } from './error.js'; -/** Creates an AbortError. */ -export function abortError(reason: string = ErrorName.Abort, cause?: E): CodedError { - return new CodedError(reason, { - name: ErrorName.Abort, - code: ErrorCode.Abort, - cause, - }); +/** An AbortError. */ +export class AbortError extends CodedError { + public constructor( + message = AbortError.name, + options?: ErrorDetailOptions, + ) { + super(message, { name: AbortError.name, code: ABORT_ERR, ...options }); + } } -/** Creates a NotFoundError. */ -export function notFoundError( - reason: string = ErrorName.NotFound, detail?: T, cause?: E -): CodedError { - return new CodedError(reason, { - name: ErrorName.NotFound, - code: ErrorCode.NotFound, - detail, - cause, - }); +/** A TimeoutError. */ +export class TimeoutError extends CodedError { + public constructor( + message = TimeoutError.name, + options?: ErrorDetailOptions, + ) { + super(message, { name: TimeoutError.name, code: TIMEOUT_ERR, ...options }); + } } -/** Creates an InvalidStateError. */ -export function invalidStateError( - reason: string = ErrorName.InvalidState, code: string = ErrorCode.InvalidState, detail?: T, cause?: E -): CodedError { - return new CodedError(reason, { - name: ErrorName.InvalidState, - code, - detail, - cause, - }); +/** A NotFoundError. */ +export class NotFoundError extends CodedError { + public constructor( + message = NotFoundError.name, + options?: ErrorDetailOptions, + ) { + super(message, { name: NotFoundError.name, code: NOT_FOUND_ERR, ...options }); + } } -/** Creates an OperationError. */ -export function operationError( - reason: string = ErrorName.OpFailed, code: string = ErrorCode.OpFailed, detail?: T, cause?: E -): CodedError { - return new CodedError(reason, { - name: ErrorName.OpFailed, - code, - detail, - cause, - }); +/** A NotSupportedError. */ +export class NotSupportedError extends CodedError { + public constructor( + message = NotSupportedError.name, + options?: ErrorDetailOptions, + ) { + super(message, { name: NotSupportedError.name, code: NOT_SUPPORTED_ERR, ...options }); + } +} + +/** An InvalidStateError. */ +export class InvalidStateError extends CodedError { + public constructor( + message = InvalidStateError.name, + options?: ErrorDetailOptions, + ) { + super(message, { name: InvalidStateError.name, code: INVALID_STATE_ERR, ...options }); + } +} + +/** An OperationError. */ +export class OperationError extends CodedError { + public constructor( + message = InvalidStateError.name, + options?: ErrorCodeDetailOptions, + ) { + super(message, { name: OperationError.name, code: ERR_OPERATION_FAILED, ...options }); + } } diff --git a/packages/commons/src/error/index.ts b/packages/commons/src/error/index.ts index 4ddb65c9..c9195089 100644 --- a/packages/commons/src/error/index.ts +++ b/packages/commons/src/error/index.ts @@ -1,3 +1,3 @@ -export * from './enums.js'; +export * from './code.js'; export * from './error.js'; export * from './errors.js'; diff --git a/packages/cqrs/src/__tests__/iterator.spec.ts b/packages/cqrs/src/__tests__/iterator.spec.ts index 92040a0e..340c98d2 100644 --- a/packages/cqrs/src/__tests__/iterator.spec.ts +++ b/packages/cqrs/src/__tests__/iterator.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; -import { ErrorName, immediate } from '@mithic/commons'; +import { immediate } from '@mithic/commons'; import { AsyncSubscriber } from '../iterator.js'; import { SimpleMessageBus } from '@mithic/messaging'; @@ -74,7 +74,7 @@ describe(AsyncSubscriber.name, () => { actualError = e; } - expect((actualError as Error).name).toBe(ErrorName.Abort); + expect((actualError as Error).name).toBe('AbortError'); expect(result).toEqual(events.slice(0, 2)); expect(subscriber['running']).toBe(false); }); diff --git a/packages/crdt/src/aggregate.ts b/packages/crdt/src/aggregate.ts index 0289269d..0d147ca8 100644 --- a/packages/crdt/src/aggregate.ts +++ b/packages/crdt/src/aggregate.ts @@ -1,65 +1,26 @@ -import { AbortOptions, CodedError, MaybePromise } from '@mithic/commons'; +import { AbortOptions, MaybePromise } from '@mithic/commons'; /** Abstract aggregate type. */ export interface Aggregate< - Command, - EventRef, Event extends AggregateEvent, + Command, Event, QueryResult, QueryOptions extends AbortOptions = AbortOptions, > { - /** Accepted event types of this aggregate. */ - readonly event: Readonly>; - /** Queries the state of this {@link Aggregate}. */ query(options?: QueryOptions): QueryResult; /** Handles a command and produces an event. */ command(command: Command, options?: AbortOptions): MaybePromise; - /** Applies given event and returns a reference to it. */ - apply(event: Event, options?: AggregateApplyOptions): MaybePromise; + /** Applies given event. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reduce(event: Event, options?: AggregateReduceOptions): MaybePromise; /** Validates given event and returns any error. */ - validate(event: Event, options?: AbortOptions): MaybePromise; -} - -/** Aggregate event interface in Flux standard action format. */ -export interface AggregateEvent { - /** Event type. */ - readonly type: A; - - /** Event payload. */ - readonly payload: T; - - /** Event metadata. */ - readonly meta: AggregateEventMeta; -} - -/** {@link AggregateEvent} metadata. */ -export interface AggregateEventMeta { - /** Parent event references, on which this event depends. */ - readonly parents: readonly Ref[]; - - /** Event target aggregate root ID. */ - readonly root?: Ref; - - /** (Logical) timestamp at which the event is created/persisted. */ - readonly createdAt?: number; -} - -/** Common metadata for {@link Aggregate} command. */ -export interface AggregateCommandMeta { - /** Reference to (root event of) the target aggregate. Creates a new aggregate if not specified. */ - readonly ref?: Ref; - - /** Timestamp of this command. */ - readonly createdAt?: number; - - /** A random number to make a unique event when creating a new aggregate. */ - readonly nonce?: number; + validate(event: Event, options?: AbortOptions): MaybePromise; } -/** Options for {@link Aggregate} apply method. */ -export interface AggregateApplyOptions extends AbortOptions { +/** Options for {@link Aggregate} reduce method. */ +export interface AggregateReduceOptions extends AbortOptions { /** Whether to validate input. Defaults to true. */ readonly validate?: boolean; } diff --git a/packages/crdt/src/model/__tests__/lseq.spec.ts b/packages/crdt/src/model/__tests__/lseq.spec.ts index 59bc480a..f86f5376 100644 --- a/packages/crdt/src/model/__tests__/lseq.spec.ts +++ b/packages/crdt/src/model/__tests__/lseq.spec.ts @@ -1,8 +1,7 @@ import { BTreeMap } from '@mithic/collections'; import { MockId } from '../../__tests__/mocks.js'; -import { LSeq, LSeqCommand, LSeqEventType } from '../lseq.js'; +import { LSeqAggregate, LSeqCommand, LSeqCommandType, LSeqEvent, LSeqEventType } from '../lseq.js'; import { ORMap, MapQuery } from '../map.js'; -import { ErrorCode, operationError } from '@mithic/commons'; import { getFieldValueKey, getHeadIndexKey } from '../keys.js'; type V = string | number | boolean; @@ -17,21 +16,21 @@ const INDEX1 = 'kkkkkkkkUU'; const INDEX2 = 'KUUUUUUUU'; const INDEX3 = 'PkkkkkkkkU'; const INDEX4 = 'ckkkkkkkUU'; -const CMD_EMPTY = { createdAt: 1 }; -const CMD_ADD = { ref: ROOT, add: [VALUE0, VALUE1], createdAt: 2 }; -const CMD_ADD2 = { ref: ROOT, index: 'A', add: [VALUE1, VALUE2], createdAt: 3 }; -const CMD_DEL = { ref: ROOT, index: 'UUUUUUUU', add: [VALUE1, VALUE3], del: 1, createdAt: 4 }; -const CMD_ADD_CONCURRENT = { ref: ROOT, add: [VALUE2, VALUE3], createdAt: 5 }; - -describe(LSeq.name, () => { - let lseq: LSeq; +const CMD_EMPTY = { type: LSeqCommandType.Update, payload: {}, meta: { time: 1 } } satisfies LSeqCommand; +const CMD_ADD = { type: LSeqCommandType.Update, payload: { add: [VALUE0, VALUE1] }, meta: { root: ROOT, time: 2 } } satisfies LSeqCommand; +const CMD_ADD2 = { type: LSeqCommandType.Update, payload: { index: 'A', add: [VALUE1, VALUE2] }, meta: { root: ROOT, time: 3 } } satisfies LSeqCommand; +const CMD_DEL = { type: LSeqCommandType.Update, payload: {index: 'UUUUUUUU', add: [VALUE1, VALUE3], del: 1 }, meta: { root: ROOT, time: 4 } } satisfies LSeqCommand; +const CMD_ADD_CONCURRENT = { type: LSeqCommandType.Update, payload: { add: [VALUE2, VALUE3] }, meta: { root: ROOT, time: 5 } } satisfies LSeqCommand; + +describe(LSeqAggregate.name, () => { + let lseq: LSeqAggregate; let store: BTreeMap; beforeEach(() => { const map = new ORMap({ - eventRef: (event) => new MockId(new Uint8Array(event.meta.createdAt || 0)), + eventRef: (event) => new MockId(new Uint8Array(event.meta?.time || 0)), }); - lseq = new LSeq({ + lseq = new LSeqAggregate({ map, rand: () => 0.5, }); @@ -48,20 +47,19 @@ describe(LSeq.name, () => { expect(event).toEqual({ type: LSeqEventType.New, payload: { ops: [] }, - meta: { parents: [], createdAt: 1 }, - }); + meta: { prev: [], time: 1 }, + } satisfies LSeqEvent); }); it('should return valid event for new set command', async () => { - const event = await lseq.command({ add: [VALUE0, VALUE1], nonce: 123, createdAt: 1 }); + const event = await lseq.command({ type: LSeqCommandType.Update, payload: { add: [VALUE0, VALUE1] }, meta: { id: '123', time: 1 } }); expect(event).toEqual({ type: LSeqEventType.New, payload: { ops: [[INDEX0, VALUE0, false], [INDEX1, VALUE1, false]], - nonce: 123, }, - meta: { parents: [], createdAt: 1 }, - }); + meta: { prev: [], id: '123', time: 1 }, + } satisfies LSeqEvent); }); it('should return valid event for set set command', async () => { @@ -71,14 +69,14 @@ describe(LSeq.name, () => { payload: { ops: [[INDEX0, VALUE0, false], [INDEX1, VALUE1, false]] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - }); + meta: { prev: [], root: ROOT, time: 2 }, + } satisfies LSeqEvent); }); it('should return valid event for set delete/replace command', async () => { const concurrentEvent = await lseq.command(CMD_ADD_CONCURRENT); await applyCommand(CMD_ADD); - await lseq.apply(concurrentEvent); + await lseq.reduce(concurrentEvent); const event = await lseq.command(CMD_DEL); expect(event).toEqual({ type: LSeqEventType.Update, @@ -86,10 +84,10 @@ describe(LSeq.name, () => { ops: [[INDEX0, VALUE1, false, 0, 1], [INDEX4, VALUE3, false]] }, meta: { - parents: [new MockId(new Uint8Array(2)), new MockId(new Uint8Array(5))], - root: ROOT, createdAt: 4 + prev: [new MockId(new Uint8Array(2)), new MockId(new Uint8Array(5))], + root: ROOT, time: 4 }, - }); + } satisfies LSeqEvent); }); it('should ignore delete operation if nothing can be deleted', async () => { @@ -100,32 +98,32 @@ describe(LSeq.name, () => { ops: [[INDEX1, VALUE1, false], ['sssssssskkU', VALUE3, false]] }, meta: { - parents: [], - root: ROOT, createdAt: 4 + prev: [], + root: ROOT, time: 4 }, - }); + } satisfies LSeqEvent); }); it('should throw for empty update command', async () => { - await expect(() => lseq.command({ ref: ROOT, createdAt: 2 })) - .rejects.toEqual(operationError('Empty operation', ErrorCode.InvalidArg)); + await expect(() => lseq.command({ type: LSeqCommandType.Update, payload: {}, meta: { root: ROOT, time: 2 } })) + .rejects.toEqual(new TypeError('empty operation')); }); }); describe('query', () => { it('should return empty result for empty / undefined maps', async () => { await applyCommand(); - for await (const _ of lseq.query({ ref: ROOT })) { + for await (const _ of lseq.query({ root: ROOT })) { throw new Error('should not be called'); } }); it.each([ - [[CMD_ADD], { ref: ROOT }, [[INDEX0, VALUE0], [INDEX1, VALUE1]] as const], - [[CMD_ADD, CMD_ADD2], { ref: ROOT }, [[INDEX2, VALUE1], [INDEX3, VALUE2], [INDEX0, VALUE0], [INDEX1, VALUE1]] as const], - [[CMD_ADD, CMD_ADD2], { ref: ROOT, limit: 2 }, [[INDEX2, VALUE1], [INDEX3, VALUE2]] as const], - [[CMD_ADD, CMD_ADD2], { ref: ROOT, limit: 2, reverse: true }, [[INDEX1, VALUE1], [INDEX0, VALUE0]] as const], - [[CMD_ADD, CMD_ADD2, CMD_DEL], { ref: ROOT }, [[INDEX2, VALUE1], [INDEX3, VALUE2], [INDEX0, VALUE1], [INDEX4, VALUE3], [INDEX1, VALUE1]] as const], + [[CMD_ADD], { root: ROOT }, [[INDEX0, VALUE0], [INDEX1, VALUE1]] as const], + [[CMD_ADD, CMD_ADD2], { root: ROOT }, [[INDEX2, VALUE1], [INDEX3, VALUE2], [INDEX0, VALUE0], [INDEX1, VALUE1]] as const], + [[CMD_ADD, CMD_ADD2], { root: ROOT, limit: 2 }, [[INDEX2, VALUE1], [INDEX3, VALUE2]] as const], + [[CMD_ADD, CMD_ADD2], { root: ROOT, limit: 2, reverse: true }, [[INDEX1, VALUE1], [INDEX0, VALUE0]] as const], + [[CMD_ADD, CMD_ADD2, CMD_DEL], { root: ROOT }, [[INDEX2, VALUE1], [INDEX3, VALUE2], [INDEX0, VALUE1], [INDEX4, VALUE3], [INDEX1, VALUE1]] as const], ])( 'should return correct results for non-empty LSeqs', async (cmds: LSeqCommand[], query: MapQuery, expected: readonly (readonly [string, V])[]) => { @@ -145,10 +143,10 @@ describe(LSeq.name, () => { await applyCommand(); const concurrentEvent = await lseq.command(CMD_ADD_CONCURRENT); await applyCommand(CMD_ADD); - await lseq.apply(concurrentEvent); + await lseq.reduce(concurrentEvent); const results = []; - for await (const entry of lseq.query({ ref: ROOT })) { + for await (const entry of lseq.query({ root: ROOT })) { results.push(entry); } expect(results).toEqual([[INDEX0, VALUE0], [INDEX0, VALUE2], [INDEX1, VALUE1], [INDEX1, VALUE3]]); @@ -166,8 +164,8 @@ describe(LSeq.name, () => { it('should return error for malformed events', async () => { expect(await lseq.validate({ type: LSeqEventType.Update, payload: { ops: [] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError('Empty operation', ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError('empty operation')); }); }); @@ -186,13 +184,13 @@ describe(LSeq.name, () => { }); it('should remove all concurrent values on delete', async () => { - const eventRef1 = new MockId(new Uint8Array(CMD_ADD.createdAt)); - const eventRef2 = new MockId(new Uint8Array(CMD_ADD_CONCURRENT.createdAt)); + const eventRef1 = new MockId(new Uint8Array(CMD_ADD.meta.time)); + const eventRef2 = new MockId(new Uint8Array(CMD_ADD_CONCURRENT.meta.time)); await applyCommand(); const concurrentEvent = await lseq.command(CMD_ADD_CONCURRENT); await applyCommand(CMD_ADD); - await lseq.apply(concurrentEvent); + await lseq.reduce(concurrentEvent); await applyCommand(CMD_DEL); expect(store.get(getHeadIndexKey(`${ROOT}`, `${INDEX0}`, eventRef1.toString()))).toBeUndefined(); @@ -202,14 +200,14 @@ describe(LSeq.name, () => { }); it('should throw error for malformed events when validate = true', async () => { - await expect(() => lseq.apply({ + await expect(() => lseq.reduce({ type: LSeqEventType.Update, - payload: { ops: [] }, meta: { parents: [], createdAt: 1 }, - })).rejects.toEqual(operationError('Empty operation', ErrorCode.UnsupportedOp)); + payload: { ops: [] }, meta: { prev: [], time: 1 }, + })).rejects.toEqual(new TypeError('empty operation')); }); }); async function applyCommand(cmd: LSeqCommand = CMD_EMPTY) { - return await lseq.apply(await lseq.command(cmd)); + return await lseq.reduce(await lseq.command(cmd)); } }); diff --git a/packages/crdt/src/model/__tests__/map.spec.ts b/packages/crdt/src/model/__tests__/map.spec.ts index 3cb1d975..a84a7281 100644 --- a/packages/crdt/src/model/__tests__/map.spec.ts +++ b/packages/crdt/src/model/__tests__/map.spec.ts @@ -1,8 +1,8 @@ import { BTreeMap } from '@mithic/collections'; -import { ORMap, MapCommand, MapEventType, MapQuery } from '../map.js'; +import { ERR_DEPENDENCY_MISSING, OperationError } from '@mithic/commons'; +import { ORMap, MapCommand, MapEventType, MapQuery, MapCommandType, MapEvent } from '../map.js'; import { MockId } from '../../__tests__/mocks.js'; import { getEventIndexKey, getFieldValueKey, getHeadIndexKey } from '../keys.js'; -import { ErrorCode, operationError } from '@mithic/commons'; type V = string | number | boolean | null; @@ -18,12 +18,12 @@ const VALUE2 = 123; const VALUE22 = 456; const VALUE3 = true; const VALUE32 = false; -const CMD_EMPTY = { set: {}, createdAt: 1 } satisfies MapCommand; -const CMD_NEW = { set: { [FIELD0]: VALUE0 }, nonce: 123, createdAt: 1 } satisfies MapCommand; -const CMD_WITH_FIELDS = { ref: ROOT, set: { [FIELD1]: VALUE1, [FIELD2]: VALUE2 }, createdAt: 2 } satisfies MapCommand; -const CMD_WITH_FIELDS2 = { ref: ROOT, set: { [FIELD3]: VALUE3 }, createdAt: 3 } satisfies MapCommand; -const CMD_WITH_UPDEL = { ref: ROOT, set: { [FIELD2]: VALUE22, [FIELD3]: VALUE32 }, del: [FIELD1], createdAt: 4 } satisfies MapCommand; -const CMD_WITH_FIELDS_CONCURRENT = { ref: ROOT, set: { [FIELD1]: VALUE12 }, createdAt: 5 } satisfies MapCommand; +const CMD_EMPTY = { type: MapCommandType.Update, payload: { set: {} }, meta: { time: 1 } } satisfies MapCommand; +const CMD_NEW = { type: MapCommandType.Update, payload: { set: { [FIELD0]: VALUE0 } }, meta: { id: '123', time: 1 } } satisfies MapCommand; +const CMD_WITH_FIELDS = { type: MapCommandType.Update, payload: { set: { [FIELD1]: VALUE1, [FIELD2]: VALUE2 } }, meta: { root: ROOT, time: 2 } } satisfies MapCommand; +const CMD_WITH_FIELDS2 = { type: MapCommandType.Update, payload: {set: { [FIELD3]: VALUE3 } }, meta: { root: ROOT, time: 3 } } satisfies MapCommand; +const CMD_WITH_UPDEL = { type: MapCommandType.Update, payload: { set: { [FIELD2]: VALUE22, [FIELD3]: VALUE32 }, del: [FIELD1] }, meta: { root: ROOT, time: 4 } } satisfies MapCommand; +const CMD_WITH_FIELDS_CONCURRENT = { type: MapCommandType.Update, payload: { set: { [FIELD1]: VALUE12 } }, meta: { root: ROOT, time: 5 } } satisfies MapCommand; describe(ORMap.name, () => { let map: ORMap; @@ -31,7 +31,7 @@ describe(ORMap.name, () => { beforeEach(() => { map = new ORMap({ - eventRef: (event) => new MockId(new Uint8Array(event.meta.createdAt || 0)), + eventRef: (event) => new MockId(new Uint8Array(event.meta?.time || 0)), trackEventTime: true, }); store = map['store'] as BTreeMap; @@ -47,8 +47,8 @@ describe(ORMap.name, () => { expect(event).toEqual({ type: MapEventType.New, payload: { ops: [] }, - meta: { parents: [], createdAt: 1 }, - }); + meta: { prev: [], time: 1 }, + } satisfies MapEvent); }); it('should return valid event for new map command', async () => { @@ -57,10 +57,9 @@ describe(ORMap.name, () => { type: MapEventType.New, payload: { ops: [[FIELD0, VALUE0, false]], - nonce: 123, }, - meta: { parents: [], createdAt: 1 }, - }); + meta: { prev: [], id: '123', time: 1 }, + } satisfies MapEvent); }); it('should return valid event for set map command', async () => { @@ -73,8 +72,8 @@ describe(ORMap.name, () => { [FIELD2, VALUE2, false] ] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - }); + meta: { prev: [], root: ROOT, time: 2 }, + } satisfies MapEvent); }); it('should return valid event for set map command with dependency', async () => { @@ -91,10 +90,10 @@ describe(ORMap.name, () => { ] }, meta: { - parents: [new MockId(new Uint8Array(2)), new MockId(new Uint8Array(3))], - root: ROOT, createdAt: 4 + prev: [new MockId(new Uint8Array(2)), new MockId(new Uint8Array(3))], + root: ROOT, time: 4 }, - }); + } satisfies MapEvent); }); it('should ignore delete operation if field does not already exist', async () => { @@ -109,15 +108,15 @@ describe(ORMap.name, () => { ] }, meta: { - parents: [new MockId(new Uint8Array(3))], - root: ROOT, createdAt: 4 + prev: [new MockId(new Uint8Array(3))], + root: ROOT, time: 4 }, - }); + } satisfies MapEvent); }); it('should throw for empty set command', async () => { - await expect(() => map.command({ ref: ROOT, set: {}, createdAt: 2 })) - .rejects.toEqual(operationError('Empty operation', ErrorCode.InvalidArg)); + await expect(() => map.command({ type: MapCommandType.Update, payload: { set: {} }, meta: { root: ROOT, time: 2 } })) + .rejects.toEqual(new TypeError('empty operation')); }); }); @@ -131,40 +130,40 @@ describe(ORMap.name, () => { it('should return error for malformed events', async () => { expect(await map.validate({ type: MapEventType.Update, payload: { ops: [] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError('Empty operation', ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError('empty operation')); expect(await map.validate({ type: MapEventType.Update, payload: { ops: [['field', true, false]] }, - meta: { parents: [], createdAt: 2 }, - })).toEqual(operationError('Missing root', ErrorCode.InvalidArg)); + meta: { prev: [], time: 2 }, + })).toEqual(new TypeError('missing root')); expect(await map.validate({ type: MapEventType.Update, payload: { ops: [['', true, false]] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError(`Invalid operation: ""`, ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError(`invalid operation: ""`)); expect(await map.validate({ type: MapEventType.Update, payload: { ops: [['field', 123, true]] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError(`Invalid operation: "field"`, ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError(`invalid operation: "field"`)); expect(await map.validate({ type: MapEventType.Update, payload: { ops: [['field', true, false, 0]] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError(`Invalid operation: "field"`, ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError(`invalid operation: "field"`)); }); it('should return error for missing dependent events', async () => { expect(await map.validate({ type: MapEventType.Update, payload: { ops: [['field', true, false, 0]] }, - meta: { parents: [new MockId(new Uint8Array(2))], root: ROOT, createdAt: 2 }, - })).toEqual(operationError('Missing dependencies', ErrorCode.MissingDep, [new MockId(new Uint8Array(2)), ROOT])); + meta: { prev: [new MockId(new Uint8Array(2))], root: ROOT, time: 2 }, + })).toEqual(new OperationError('missing dependencies', { code: ERR_DEPENDENCY_MISSING, detail: [new MockId(new Uint8Array(2)), ROOT] })); }); }); @@ -188,10 +187,10 @@ describe(ORMap.name, () => { await applyCommand(CMD_NEW); // 3 store entries const cmd1 = await map.command(CMD_WITH_FIELDS); // 5 store entries const cmd2 = await map.command(CMD_WITH_FIELDS_CONCURRENT); // 3 store entries - await map.apply(cmd1); - await map.apply(cmd2); - const eventRef1 = new MockId(new Uint8Array(CMD_WITH_FIELDS.createdAt)); - const eventRef2 = new MockId(new Uint8Array(CMD_WITH_FIELDS_CONCURRENT.createdAt)); + await map.reduce(cmd1); + await map.reduce(cmd2); + const eventRef1 = new MockId(new Uint8Array(CMD_WITH_FIELDS.meta.time)); + const eventRef2 = new MockId(new Uint8Array(CMD_WITH_FIELDS_CONCURRENT.meta.time)); expect(store.size).toEqual(11); @@ -202,15 +201,15 @@ describe(ORMap.name, () => { }); it('should resolve concurrent values on update', async () => { - const eventRef1 = new MockId(new Uint8Array(CMD_WITH_FIELDS.createdAt)); - const eventRef2 = new MockId(new Uint8Array(CMD_WITH_FIELDS_CONCURRENT.createdAt)); - const eventRef3 = new MockId(new Uint8Array(CMD_WITH_UPDEL.createdAt)); + const eventRef1 = new MockId(new Uint8Array(CMD_WITH_FIELDS.meta.time)); + const eventRef2 = new MockId(new Uint8Array(CMD_WITH_FIELDS_CONCURRENT.meta.time)); + const eventRef3 = new MockId(new Uint8Array(CMD_WITH_UPDEL.meta.time)); await applyCommand(CMD_NEW); // 3 store entries const cmd1 = await map.command(CMD_WITH_FIELDS); // 5 store entries const cmd2 = await map.command(CMD_WITH_FIELDS_CONCURRENT); // 3 store entries - await map.apply(cmd1); - await map.apply(cmd2); + await map.reduce(cmd1); + await map.reduce(cmd2); await applyCommand(CMD_WITH_UPDEL); // +5 -4 -2 store entries expect(store.size).toEqual(10); @@ -227,27 +226,27 @@ describe(ORMap.name, () => { }); it('should throw error for malformed events when validate = true', async () => { - await expect(() => map.apply({ + await expect(() => map.reduce({ type: MapEventType.Update, - payload: { ops: [] }, meta: { parents: [], createdAt: 1 }, - })).rejects.toEqual(operationError('Empty operation', ErrorCode.UnsupportedOp)); + payload: { ops: [] }, meta: { prev: [], time: 1 }, + })).rejects.toEqual(new TypeError('empty operation')); }); }); describe('query', () => { it('should return empty result for empty / undefined maps', async () => { await applyCommand(); - for await (const _ of map.query({ ref: ROOT })) { + for await (const _ of map.query({ root: ROOT })) { throw new Error('should not be called'); } }); it.each([ - [[CMD_WITH_FIELDS], { ref: ROOT }, [[FIELD1, VALUE1], [FIELD2, VALUE2]] as const], - [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { ref: ROOT }, [[FIELD1, VALUE1], [FIELD2, VALUE2], [FIELD3, VALUE3]] as const], - [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { ref: ROOT, limit: 2 }, [[FIELD1, VALUE1], [FIELD2, VALUE2]] as const], - [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { ref: ROOT, limit: 2, reverse: true }, [[FIELD3, VALUE3], [FIELD2, VALUE2]] as const], - [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { ref: ROOT, gte: FIELD2, lte: FIELD2 }, [[FIELD2, VALUE2]] as const], + [[CMD_WITH_FIELDS], { root: ROOT }, [[FIELD1, VALUE1], [FIELD2, VALUE2]] as const], + [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { root: ROOT }, [[FIELD1, VALUE1], [FIELD2, VALUE2], [FIELD3, VALUE3]] as const], + [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { root: ROOT, limit: 2 }, [[FIELD1, VALUE1], [FIELD2, VALUE2]] as const], + [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { root: ROOT, limit: 2, reverse: true }, [[FIELD3, VALUE3], [FIELD2, VALUE2]] as const], + [[CMD_WITH_FIELDS, CMD_WITH_FIELDS2], { root: ROOT, gte: FIELD2, lte: FIELD2 }, [[FIELD2, VALUE2]] as const], ])( 'should return correct results for non-empty maps', async (cmds: MapCommand[], query: MapQuery, expected: readonly (readonly [string, V])[]) => { @@ -267,11 +266,11 @@ describe(ORMap.name, () => { await applyCommand(CMD_NEW); const event1 = await map.command(CMD_WITH_FIELDS); const event2 = await map.command(CMD_WITH_UPDEL); - await map.apply(event1); - await map.apply(event2); + await map.reduce(event1); + await map.reduce(event2); const results = []; - for await (const entry of map.query({ ref: ROOT })) { + for await (const entry of map.query({ root: ROOT })) { results.push(entry); } expect(results).toEqual([ @@ -285,17 +284,17 @@ describe(ORMap.name, () => { await applyCommand(CMD_NEW); const event1 = await map.command(CMD_WITH_FIELDS); const event2 = await map.command(CMD_WITH_UPDEL); - await map.apply(event1); - await map.apply(event2); + await map.reduce(event1); + await map.reduce(event2); const results = []; - for await (const entry of map.query({ ref: ROOT, gte: FIELD1, lte: FIELD3, lww: true })) { + for await (const entry of map.query({ root: ROOT, gte: FIELD1, lte: FIELD3, lww: true })) { results.push(entry); } expect(results).toEqual([[FIELD1, VALUE1], [FIELD2, VALUE22], [FIELD3, VALUE32]]); results.length = 0; - for await (const entry of map.query({ ref: ROOT, gte: FIELD1, lww: true, limit: 2 })) { + for await (const entry of map.query({ root: ROOT, gte: FIELD1, lww: true, limit: 2 })) { results.push(entry); } expect(results).toEqual([[FIELD1, VALUE1], [FIELD2, VALUE22]]); @@ -303,6 +302,6 @@ describe(ORMap.name, () => { }); async function applyCommand(cmd: MapCommand = CMD_EMPTY) { - return await map.apply(await map.command(cmd)); + return await map.reduce(await map.command(cmd)); } }); diff --git a/packages/crdt/src/model/__tests__/set.spec.ts b/packages/crdt/src/model/__tests__/set.spec.ts index 21a99e46..85a5ff7e 100644 --- a/packages/crdt/src/model/__tests__/set.spec.ts +++ b/packages/crdt/src/model/__tests__/set.spec.ts @@ -1,7 +1,6 @@ import { BTreeMap } from '@mithic/collections'; -import { ORSet, SetCommand, SetEventType, SetQuery } from '../set.js'; +import { ORSet, SetCommand, SetCommandType, SetEvent, SetEventType, SetQuery } from '../set.js'; import { MockId } from '../../__tests__/mocks.js'; -import { ErrorCode, operationError } from '@mithic/commons'; import { getFieldValueKey, getHeadIndexKey } from '../keys.js'; import { ORMap } from '../map.js'; @@ -12,11 +11,11 @@ const VALUE0 = 'v0'; const VALUE1 = 'v1'; const VALUE2 = 123; const VALUE3 = true; -const CMD_EMPTY = { createdAt: 1 }; -const CMD_NEW = { add: [VALUE0], nonce: 123, createdAt: 1 }; -const CMD_ADD = { ref: ROOT, add: [VALUE1, VALUE2], createdAt: 2 }; -const CMD_ADD_CONCURRENT = { ref: ROOT, add: [VALUE2, VALUE3], createdAt: 3 }; -const CMD_DEL = { ref: ROOT, add: [VALUE1], del: [VALUE2], createdAt: 4 }; +const CMD_EMPTY = { type: SetCommandType.Update, payload: {}, meta: { time: 1 } } satisfies SetCommand; +const CMD_NEW = { type: SetCommandType.Update, payload: { add: [VALUE0] }, meta: { id: '123', time: 1 } } satisfies SetCommand; +const CMD_ADD = { type: SetCommandType.Update, payload: { add: [VALUE1, VALUE2] }, meta: { root: ROOT, time: 2 } } satisfies SetCommand; +const CMD_ADD_CONCURRENT = { type: SetCommandType.Update, payload: { add: [VALUE2, VALUE3] }, meta: { root: ROOT, time: 3 } } satisfies SetCommand; +const CMD_DEL = { type: SetCommandType.Update, payload: { add: [VALUE1], del: [VALUE2] }, meta: { root: ROOT, time: 4 } } satisfies SetCommand; describe(ORSet.name, () => { let set: ORSet; @@ -24,7 +23,7 @@ describe(ORSet.name, () => { beforeEach(() => { const map = new ORMap({ - eventRef: (event) => new MockId(new Uint8Array(event.meta.createdAt || 0)), + eventRef: (event) => new MockId(new Uint8Array(event.meta?.time || 0)), }) set = new ORSet({ map, @@ -36,17 +35,17 @@ describe(ORSet.name, () => { describe('query', () => { it('should return empty result for empty / undefined sets', async () => { await applyCommand(); - for await (const _ of set.query({ ref: ROOT })) { + for await (const _ of set.query({ root: ROOT })) { throw new Error('should not be called'); } }); it.each([ - [[CMD_ADD], { ref: ROOT }, [VALUE2, VALUE1] as const], - [[CMD_ADD, CMD_ADD_CONCURRENT], { ref: ROOT }, [VALUE2, VALUE3, VALUE1] as const], - [[CMD_ADD, CMD_ADD_CONCURRENT], { ref: ROOT, limit: 2 }, [VALUE2, VALUE3] as const], - [[CMD_ADD, CMD_ADD_CONCURRENT], { ref: ROOT, limit: 2, reverse: true }, [VALUE1, VALUE3] as const], - [[CMD_ADD, CMD_ADD_CONCURRENT, CMD_DEL], { ref: ROOT, gte: VALUE2, lte: VALUE1 }, [VALUE3, VALUE1] as const], + [[CMD_ADD], { root: ROOT }, [VALUE2, VALUE1] as const], + [[CMD_ADD, CMD_ADD_CONCURRENT], { root: ROOT }, [VALUE2, VALUE3, VALUE1] as const], + [[CMD_ADD, CMD_ADD_CONCURRENT], { root: ROOT, limit: 2 }, [VALUE2, VALUE3] as const], + [[CMD_ADD, CMD_ADD_CONCURRENT], { root: ROOT, limit: 2, reverse: true }, [VALUE1, VALUE3] as const], + [[CMD_ADD, CMD_ADD_CONCURRENT, CMD_DEL], { root: ROOT, gte: VALUE2, lte: VALUE1 }, [VALUE3, VALUE1] as const], ])( 'should return correct results for non-empty sets', async (cmds: SetCommand[], query: SetQuery, expected: readonly V[]) => { @@ -66,10 +65,10 @@ describe(ORSet.name, () => { await applyCommand(); const concurrentEvent = await set.command(CMD_ADD_CONCURRENT); await applyCommand(CMD_ADD); - await set.apply(concurrentEvent); + await set.reduce(concurrentEvent); const results = []; - for await (const entry of set.query({ ref: ROOT })) { + for await (const entry of set.query({ root: ROOT })) { results.push(entry); } expect(results).toEqual([VALUE2, VALUE3, VALUE1]); @@ -86,8 +85,8 @@ describe(ORSet.name, () => { expect(event).toEqual({ type: SetEventType.New, payload: { ops: [] }, - meta: { parents: [], createdAt: 1 }, - }); + meta: { prev: [], time: 1 }, + } satisfies SetEvent); }); it('should return valid event for new set command', async () => { @@ -96,10 +95,9 @@ describe(ORSet.name, () => { type: SetEventType.New, payload: { ops: [[VALUE0]], - nonce: 123, }, - meta: { parents: [], createdAt: 1 }, - }); + meta: { prev: [], id: '123', time: 1 }, + } satisfies SetEvent); }); it('should return valid event for set set command', async () => { @@ -109,14 +107,14 @@ describe(ORSet.name, () => { payload: { ops: [[VALUE2], [VALUE1]] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - }); + meta: { prev: [], root: ROOT, time: 2 }, + } satisfies SetEvent); }); it('should return valid event for set delete command', async () => { const concurrentEvent = await set.command(CMD_ADD_CONCURRENT); await applyCommand(CMD_ADD); - await set.apply(concurrentEvent); + await set.reduce(concurrentEvent); const event = await set.command(CMD_DEL); expect(event).toEqual({ type: SetEventType.Update, @@ -124,10 +122,10 @@ describe(ORSet.name, () => { ops: [[VALUE2, 0, 1]] }, meta: { - parents: [new MockId(new Uint8Array(2)), new MockId(new Uint8Array(3))], - root: ROOT, createdAt: 4 + prev: [new MockId(new Uint8Array(2)), new MockId(new Uint8Array(3))], + root: ROOT, time: 4 }, - }); + } satisfies SetEvent); }); it('should ignore delete operation if field does not already exist', async () => { @@ -139,15 +137,15 @@ describe(ORSet.name, () => { ops: [[VALUE1]] }, meta: { - parents: [], - root: ROOT, createdAt: 4 + prev: [], + root: ROOT, time: 4 }, - }); + } satisfies SetEvent); }); it('should throw for empty update command', async () => { - await expect(() => set.command({ ref: ROOT, createdAt: 2 })) - .rejects.toEqual(operationError('Empty operation', ErrorCode.InvalidArg)); + await expect(() => set.command({ type: SetCommandType.Update, payload: {}, meta: { root: ROOT, time: 2 } })) + .rejects.toEqual(new TypeError('empty operation')); }); }); @@ -161,20 +159,20 @@ describe(ORSet.name, () => { it('should return error for malformed events', async () => { expect(await set.validate({ type: SetEventType.Update, payload: { ops: [] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError('Empty operation', ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError('empty operation')); expect(await set.validate({ type: SetEventType.Update, payload: { ops: [['value']] }, - meta: { parents: [], createdAt: 2 }, - })).toEqual(operationError('Missing root', ErrorCode.InvalidArg)); + meta: { prev: [], time: 2 }, + })).toEqual(new TypeError('missing root')); expect(await set.validate({ type: SetEventType.Update, payload: { ops: [['value', 0]] }, - meta: { parents: [], root: ROOT, createdAt: 2 }, - })).toEqual(operationError(`Invalid operation: "value"`, ErrorCode.InvalidArg)); + meta: { prev: [], root: ROOT, time: 2 }, + })).toEqual(new TypeError(`invalid operation: "value"`)); }); }); @@ -196,10 +194,10 @@ describe(ORSet.name, () => { await applyCommand(CMD_NEW); const cmd1 = await set.command(CMD_ADD); const cmd2 = await set.command(CMD_ADD_CONCURRENT); - await set.apply(cmd1); - await set.apply(cmd2); - const eventRef1 = new MockId(new Uint8Array(CMD_ADD.createdAt)); - const eventRef2 = new MockId(new Uint8Array(CMD_ADD_CONCURRENT.createdAt)); + await set.reduce(cmd1); + await set.reduce(cmd2); + const eventRef1 = new MockId(new Uint8Array(CMD_ADD.meta.time)); + const eventRef2 = new MockId(new Uint8Array(CMD_ADD_CONCURRENT.meta.time)); expect(store.get(getHeadIndexKey(`${ROOT}`, `${VALUE2}`, eventRef1.toString()))).toEqual(eventRef1); expect(store.get(getHeadIndexKey(`${ROOT}`, `${VALUE2}`, eventRef2.toString()))).toEqual(eventRef2); @@ -208,14 +206,14 @@ describe(ORSet.name, () => { }); it('should remove all concurrent values on delete', async () => { - const eventRef1 = new MockId(new Uint8Array(CMD_ADD.createdAt)); - const eventRef2 = new MockId(new Uint8Array(CMD_ADD_CONCURRENT.createdAt)); + const eventRef1 = new MockId(new Uint8Array(CMD_ADD.meta.time)); + const eventRef2 = new MockId(new Uint8Array(CMD_ADD_CONCURRENT.meta.time)); await applyCommand(CMD_NEW); const cmd1 = await set.command(CMD_ADD); const cmd2 = await set.command(CMD_ADD_CONCURRENT); - await set.apply(cmd1); - await set.apply(cmd2); + await set.reduce(cmd1); + await set.reduce(cmd2); await applyCommand(CMD_DEL); expect(store.size).toEqual(6); // 3 entries remaining @@ -227,14 +225,14 @@ describe(ORSet.name, () => { }); it('should throw error for malformed events when validate = true', async () => { - await expect(() => set.apply({ + await expect(() => set.reduce({ type: SetEventType.Update, - payload: { ops: [] }, meta: { parents: [], createdAt: 1 }, - })).rejects.toEqual(operationError('Empty operation', ErrorCode.UnsupportedOp)); + payload: { ops: [] }, meta: { prev: [], time: 1 }, + })).rejects.toEqual(new TypeError('empty operation')); }); }); async function applyCommand(cmd: SetCommand = CMD_EMPTY) { - return await set.apply(await set.command(cmd)); + return await set.reduce(await set.command(cmd)); } }); diff --git a/packages/crdt/src/model/defaults.ts b/packages/crdt/src/model/defaults.ts index 3cba0158..a3d8ad39 100644 --- a/packages/crdt/src/model/defaults.ts +++ b/packages/crdt/src/model/defaults.ts @@ -1,14 +1,14 @@ -import { ErrorCode, operationError, sha256 } from '@mithic/commons'; -import { AggregateEvent } from '../aggregate.js'; +import { InvalidStateError, sha256 } from '@mithic/commons'; +import { StandardEvent } from '@mithic/cqrs'; /** Default eventRef implementation that uses multiformats and @ipld/dag-cbor as optional dependency. */ export const defaultEventRef = await (async () => { try { const { CID } = await import('multiformats'); const dagCbor = await import('@ipld/dag-cbor'); - return (event: AggregateEvent) => + return (event: StandardEvent) => CID.createV1(dagCbor.code, sha256.digest(dagCbor.encode(event))) as unknown as Ref; } catch (_) { - return () => { throw operationError('multiformats or @ipld/dag-cbor not available', ErrorCode.InvalidState); }; + return () => { throw new InvalidStateError('multiformats or @ipld/dag-cbor not available'); }; } })(); diff --git a/packages/crdt/src/model/lseq.ts b/packages/crdt/src/model/lseq.ts index f5457ce8..fdcd3103 100644 --- a/packages/crdt/src/model/lseq.ts +++ b/packages/crdt/src/model/lseq.ts @@ -1,20 +1,19 @@ -import { AbortOptions, CodedError, ContentId, StringEquatable, SyncOrAsyncIterable } from '@mithic/commons'; -import { AggregateApplyOptions, AggregateCommandMeta, AggregateEvent, Aggregate } from '../aggregate.js'; -import { ORMap, MapCommand, MapEvent, MapEventPayload, MapQuery, MapAggregate } from './map.js'; +import { AbortOptions, ContentId, StringEquatable, SyncOrAsyncIterable } from '@mithic/commons'; +import { AggregateReduceOptions, Aggregate } from '../aggregate.js'; +import { ORMap, MapCommand, MapEvent, MapEventPayload, MapQuery, MapAggregate, MapEventType, MapCommandType } from './map.js'; import { getFractionalIndices } from './keys.js'; +import { StandardCommand, StandardEvent } from '@mithic/cqrs'; /** Linear sequence of values based on {@link ORMap} of base64 fractional index to values. */ -export class LSeq< +export class LSeqAggregate< Ref extends StringEquatable = ContentId, V = string | number | boolean | null -> implements Aggregate, Ref, LSeqEvent, SyncOrAsyncIterable<[string, V]>, MapQuery> +> implements Aggregate, LSeqEvent, SyncOrAsyncIterable<[string, V]>, MapQuery> { protected readonly map: MapAggregate; protected readonly rand: () => number; protected readonly indexRandBits: number; - public readonly event = LSeqEventType; - public constructor({ map = new ORMap(), rand = Math.random, @@ -29,28 +28,26 @@ export class LSeq< return this.map.query(options); } - public async command(command: LSeqCommand = {}, options?: AbortOptions): Promise> { - const type = command.ref === void 0 ? this.event.New : this.event.Update; - const toDeleteCount = type === this.event.New ? 0 : command.del || 0; + public async command(command: LSeqCommand, options?: AbortOptions): Promise> { + const type = command.meta?.root === void 0 ? LSeqEventType.New : LSeqEventType.Update; + const toDeleteCount = type === LSeqEventType.New ? 0 : command.payload.del || 0; const set: Record = {}; const del: string[] = []; const mapCmd: MapCommand = { - ref: command.ref, - createdAt: command.createdAt, - nonce: command.nonce, - set, - del, + type: MapCommandType.Update, + payload: { set, del }, + meta: command.meta, }; const deletedIndices: string[] = []; - let startIndex = command.index; + let startIndex = command.payload.index; let endIndex; - if (command.ref) { + if (command.meta?.root) { let currentIndex: string | undefined; let indexCount = 0; for await (const [index] of this.map.query({ - ref: command.ref, + root: command.meta.root, gte: startIndex, limit: toDeleteCount + 1, signal: options?.signal, @@ -68,7 +65,7 @@ export class LSeq< } let i = 0; - const adds = command.add || []; + const adds = command.payload.add || []; for (; i < deletedIndices.length; ++i) { if (i < adds.length) { set[deletedIndices[i]] = adds[i]; @@ -87,22 +84,28 @@ export class LSeq< return { ...mapEvent, type }; } - public async apply(event: LSeqEvent, options?: AggregateApplyOptions): Promise { + public async reduce(event: LSeqEvent, options?: AggregateReduceOptions): Promise { const mapEvent = this.toORMapEvent(event); - return this.map.apply(mapEvent, options); + return this.map.reduce(mapEvent, options); } - public async validate(event: LSeqEvent, options?: AbortOptions): Promise { + public async validate(event: LSeqEvent, options?: AbortOptions): Promise { const mapEvent = this.toORMapEvent(event); return this.map.validate(mapEvent, options); } protected toORMapEvent(event: LSeqEvent): MapEvent { - return { ...event, type: event.type === this.event.New ? this.map.event.New : this.map.event.Update }; + return { ...event, type: event.type === LSeqEventType.New ? MapEventType.New : MapEventType.Update }; } } -/** Event type for {@link LSeq}. */ +/** Command type for {@link LSeqAggregate}. */ +export enum LSeqCommandType { + /** Sets or deletes set fields. */ + Update = 'LSEQ_OPS', +} + +/** Event type for {@link LSeqAggregate}. */ export enum LSeqEventType { /** Creates a new lseq. */ New = 'LSEQ_NEW', @@ -111,8 +114,11 @@ export enum LSeqEventType { Update = 'LSEQ_OPS', } -/** Command for {@link LSeq}. */ -export interface LSeqCommand extends AggregateCommandMeta { +/** Command for {@link LSeqAggregate}. */ +export type LSeqCommand = StandardCommand, Ref>; + +/** Command payload for {@link LSeqAggregate}. */ +export interface LSeqCommandPayload { /** The base64 fractional index at which insertion or deletion should occur. Defaults to the start. */ readonly index?: string; @@ -123,10 +129,10 @@ export interface LSeqCommand extends AggregateCommandMeta { readonly del?: number; } -/** Event for {@link LSeq}. */ -export type LSeqEvent = AggregateEvent>; +/** Event for {@link LSeqAggregate}. */ +export type LSeqEvent = StandardEvent, Ref>; -/** Options for creating an {@link LSeq}. */ +/** Options for creating an {@link LSeqAggregate}. */ export interface LSeqOptions, V> { /** Backing {@link MapAggregate}. */ readonly map?: MapAggregate; diff --git a/packages/crdt/src/model/map.ts b/packages/crdt/src/model/map.ts index 7428f435..feff7c19 100644 --- a/packages/crdt/src/model/map.ts +++ b/packages/crdt/src/model/map.ts @@ -1,14 +1,15 @@ import { - AbortOptions, CodedError, ContentId, ErrorCode, MaybePromise, StringEquatable, SyncOrAsyncIterable, operationError, + AbortOptions, ContentId, MaybePromise, OperationError, StringEquatable, SyncOrAsyncIterable, } from '@mithic/commons'; import { BTreeMap, MaybeAsyncMapBatch, RangeQueryable } from '@mithic/collections'; -import { AggregateApplyOptions, AggregateCommandMeta, AggregateEvent, Aggregate } from '../aggregate.js'; +import { AggregateReduceOptions, Aggregate } from '../aggregate.js'; import { getEventIndexKey, getFieldNameFromKey, getFieldValueKey, getHeadIndexKey, getPrefixEndKey } from './keys.js'; import { defaultEventRef } from './defaults.js'; +import { StandardCommand, StandardEvent } from '../../../cqrs/dist/event.js'; /** Abstract map aggregate type. */ export type MapAggregate = - Aggregate, Ref, MapEvent, SyncOrAsyncIterable<[string, V]>, MapQuery>; + Aggregate, MapEvent, SyncOrAsyncIterable<[string, V]>, MapQuery>; /** Observed-remove multivalued map. */ export class ORMap< @@ -19,8 +20,6 @@ export class ORMap< protected readonly store: MaybeAsyncMapBatch & RangeQueryable; protected readonly trackEventTime: boolean; - public readonly event = MapEventType; - public constructor({ eventRef = defaultEventRef, store = new BTreeMap(5), @@ -31,14 +30,14 @@ export class ORMap< this.trackEventTime = trackEventTime; } - public async command(command: MapCommand = {}, options?: AbortOptions): Promise> { - const rootRef = command.ref; + public async command(command: MapCommand, options?: AbortOptions): Promise> { + const rootRef = command.meta?.root; const rootRefStr = `${rootRef}`; - const type = rootRef === void 0 ? this.event.New : this.event.Update; + const type = rootRef === void 0 ? MapEventType.New : MapEventType.Update; const ops: [string, V | null, boolean, ...number[]][] = []; - const entries = command.set || {}; - const fields = [...(command.del || []), ...Object.keys(entries)]; + const entries = command.payload.set || {}; + const fields = [...(command.payload.del || []), ...Object.keys(entries)]; const parents: Ref[] = []; const parentsMap: Record = {}; @@ -47,7 +46,7 @@ export class ORMap< const value = entries[field] ?? null; const thisParents: number[] = []; - if (type === this.event.Update) { // find parents if this is an update event + if (type === MapEventType.Update) { // find parents if this is an update event for await (const parentRef of this.store.values({ gt: getHeadIndexKey(rootRefStr, field), lt: getPrefixEndKey(getHeadIndexKey(rootRefStr, field)), @@ -59,7 +58,7 @@ export class ORMap< } } - const isDelete = i++ < (command.del?.length || 0); + const isDelete = i++ < (command.payload.del?.length || 0); if (isDelete && !thisParents.length) { continue; // nothing to delete } @@ -67,18 +66,18 @@ export class ORMap< ops.push([field, value, isDelete, ...thisParents]); } - if (type === this.event.Update && !fields.length) { - throw operationError('Empty operation', ErrorCode.InvalidArg); + if (type === MapEventType.Update && !fields.length) { + throw new TypeError('empty operation'); } return { type, - payload: { ops, nonce: type === this.event.New ? command.nonce : void 0 }, - meta: { parents, root: rootRef, createdAt: command.createdAt } + payload: { ops }, + meta: { prev: parents, root: rootRef, id: command.meta?.id, time: command.meta?.time } }; } - public async apply(event: MapEvent, options?: AggregateApplyOptions): Promise { + public async reduce(event: MapEvent, options?: AggregateReduceOptions): Promise { if (options?.validate ?? true) { const error = await this.validate(event, options); if (error) { throw error; } @@ -86,7 +85,7 @@ export class ORMap< const eventRef = await this.eventRef(event, options); const eventRefStr = `${eventRef}`; - const root = event.type === this.event.Update ? event.meta.root as Ref : eventRef; + const root = event.type === MapEventType.Update ? event.meta?.root as Ref : eventRef; const rootRefStr = `${root}`; // return early if event found in store @@ -96,7 +95,7 @@ export class ORMap< // save event timestamp if enabled const entries: [string, (Ref | V | number)?][] = this.trackEventTime ? [ - [getEventIndexKey(eventRefStr), event.meta.createdAt || 0], + [getEventIndexKey(eventRefStr), event.meta?.time || 0], ] : []; // update field values @@ -108,7 +107,7 @@ export class ORMap< ); } for (const parentIndex of parents) { - const parentRef = `${event.meta.parents[parentIndex]}`; + const parentRef = `${event.meta?.prev?.[parentIndex]}`; entries.push( [getHeadIndexKey(rootRefStr, field, parentRef), void 0], [getFieldValueKey(rootRefStr, field, parentRef), void 0] @@ -117,19 +116,19 @@ export class ORMap< } for await (const error of this.store.updateMany(entries, options)) { - if (error) { throw operationError('Failed to save indices', ErrorCode.OpFailed, void 0, error); } + if (error) { throw new OperationError('failed to save indices', { cause: error }); } } return eventRef; } - public async validate(event: MapEvent, options?: AbortOptions): Promise | undefined> { - if (event.type === this.event.Update) { + public async validate(event: MapEvent, options?: AbortOptions): Promise { + if (event.type === MapEventType.Update) { if (!event.payload.ops.length) { - return operationError('Empty operation', ErrorCode.InvalidArg); + return new TypeError('empty operation'); } - if (event.meta.root === void 0) { - return operationError('Missing root', ErrorCode.InvalidArg); + if (event.meta?.root === void 0) { + return new TypeError('missing root'); } } @@ -137,33 +136,33 @@ export class ORMap< for (const [field, _, isDelete, ...parents] of event.payload.ops) { let isValid = !!field && (!!parents.length || !isDelete); for (const parent of parents) { - if (event.meta.parents[parent] === void 0) { + if (event.meta?.prev?.[parent] === void 0) { isValid = false; break; } } if (!isValid) { - return operationError(`Invalid operation: "${field}"`, ErrorCode.InvalidArg); + return new TypeError(`invalid operation: "${field}"`); } } // verify that event parents have been processed // this is possible only if we are tracking the event keys along with their timestamps if (this.trackEventTime) { - const parentKeys = event.meta.parents.map((parent) => getEventIndexKey(`${parent}`)); - if (event.meta.root !== void 0) { + const parentKeys = event.meta?.prev?.map((parent) => getEventIndexKey(`${parent}`)) || []; + if (event.meta?.root !== void 0) { parentKeys.push(getEventIndexKey(`${event.meta.root}`)); } const missing = []; let i = 0; for await (const value of this.store.getMany(parentKeys, options)) { if (value === void 0) { - missing.push(event.meta.parents[i]); + missing.push(event.meta?.prev?.[i] ?? event.meta?.root); } ++i; } if (missing.length) { - return operationError('Missing dependencies', ErrorCode.MissingDep, missing); + return new OperationError('missing dependencies', { detail: missing }); } } } @@ -180,7 +179,7 @@ export class ORMap< /** Query map entries and return all concurrent field values. */ protected async * queryMV(options: MapQuery): AsyncIterable<[string, V]> { - const map = `${options.ref}`; + const map = `${options.root}`; const limit = options.limit ?? Infinity; let currentField: string | undefined; let fieldCount = 0; @@ -206,7 +205,7 @@ export class ORMap< /** Queries entries by last-write-wins. */ protected async * queryLWW(options: MapQuery): AsyncIterable<[string, V]> { - const map = `${options.ref}`; + const map = `${options.root}`; const limit = options.limit ?? Infinity; // Get concurrent event refs for each field @@ -276,6 +275,12 @@ export class ORMap< } } +/** Command type for {@link MapAggregate}. */ +export enum MapCommandType { + /** Sets or deletes map fields. */ + Update = 'MAP_OPS', +} + /** Event type for {@link MapAggregate}. */ export enum MapEventType { /** Creates a new map. */ @@ -288,7 +293,7 @@ export enum MapEventType { /** Query options for {@link MapAggregate}. */ export interface MapQuery extends AbortOptions { /** Reference to (root event of) the map. */ - readonly ref: Ref; + readonly root: Ref; /** * Whether to resolve concurrent values by last-write-wins by comparing createdAt time followed by event refs. @@ -310,7 +315,10 @@ export interface MapQuery extends AbortOptions { } /** Command for {@link MapAggregate}. */ -export interface MapCommand extends AggregateCommandMeta { +export type MapCommand = StandardCommand, Ref>; + +/** Command payload for {@link MapAggregate}. */ +export interface MapCommandPayload { /** Sets given field-value pairs to the map. */ readonly set?: Readonly>; @@ -319,15 +327,12 @@ export interface MapCommand extends AggregateCommandMeta { } /** Event for {@link MapAggregate}. */ -export type MapEvent = AggregateEvent>; +export type MapEvent = StandardEvent, Ref>; /** Event payload for {@link MapAggregate}. */ export interface MapEventPayload { /** Operations to set given field pairs to the map with references to parent event indices. */ readonly ops: readonly [field: string, value: V | null, isDelete: boolean, ...parentIndices: number[]][]; - - /** A random number to make a unique event when creating a new map. Undefined for `Set` events. */ - readonly nonce?: number; } /** Options for creating an {@link ORMap}. */ diff --git a/packages/crdt/src/model/set.ts b/packages/crdt/src/model/set.ts index 5e2fb482..c798b286 100644 --- a/packages/crdt/src/model/set.ts +++ b/packages/crdt/src/model/set.ts @@ -1,12 +1,13 @@ import { - AbortOptions, CodedError, ContentId, MaybePromise, StringEquatable, SyncOrAsyncIterable + AbortOptions, ContentId, MaybePromise, StringEquatable, SyncOrAsyncIterable } from '@mithic/commons'; -import { AggregateApplyOptions, AggregateCommandMeta, AggregateEvent, Aggregate } from '../aggregate.js'; -import { ORMap, MapCommand, MapEvent, MapAggregate } from './map.js'; +import { AggregateReduceOptions, Aggregate } from '../aggregate.js'; +import { ORMap, MapCommand, MapEvent, MapAggregate, MapEventType, MapCommandType } from './map.js'; +import { StandardCommand, StandardEvent } from '@mithic/cqrs'; /** Abstract set aggregate type. */ export type SetAggregate = - Aggregate, Ref, SetEvent, SyncOrAsyncIterable, SetQuery>; + Aggregate, SetEvent, SyncOrAsyncIterable, SetQuery>; /** Observed-remove set of values based on {@link ORMap} of stringified values to values. */ export class ORSet< @@ -16,8 +17,6 @@ export class ORSet< protected readonly map: MapAggregate; protected readonly stringify: (value: V, options?: AbortOptions) => MaybePromise; - public readonly event = SetEventType; - public constructor({ map = new ORMap(), stringify = (value) => JSON.stringify(value), @@ -35,7 +34,7 @@ export class ORSet< for await (const [hash, value] of this.map.query({ gte, lte, - ref: options.ref, + root: options.root, reverse: options.reverse, limit: options.limit, signal: options.signal, @@ -47,25 +46,23 @@ export class ORSet< } } - public async command(command: SetCommand = {}, options?: AbortOptions): Promise> { - const type = command.ref === void 0 ? this.event.New : this.event.Update; + public async command(command: SetCommand, options?: AbortOptions): Promise> { + const type = command.meta?.root === void 0 ? SetEventType.New : SetEventType.Update; const values: Record = {}; const set: Record = {}; const del: string[] = []; const mapCmd: MapCommand = { - ref: command.ref, - createdAt: command.createdAt, - nonce: command.nonce, - set, - del, + type: MapCommandType.Update, + payload: { set, del }, + meta: command.meta, }; - for (const value of command.del || []) { + for (const value of command.payload.del || []) { const hash = await this.stringify(value, options); values[hash] = value; del.push(hash); } - for (const value of command.add || []) { + for (const value of command.payload.add || []) { const hash = await this.stringify(value, options); values[hash] = value; set[hash] = value; @@ -83,17 +80,17 @@ export class ORSet< return { type, - payload: { ops, nonce: mapEvent.payload.nonce }, + payload: { ops }, meta: mapEvent.meta, }; } - public async apply(event: SetEvent, options?: AggregateApplyOptions): Promise { + public async reduce(event: SetEvent, options?: AggregateReduceOptions): Promise { const mapEvent = await this.toORMapEvent(event, options); - return this.map.apply(mapEvent, options); + return this.map.reduce(mapEvent, options); } - public async validate(event: SetEvent, options?: AbortOptions): Promise { + public async validate(event: SetEvent, options?: AbortOptions): Promise { const mapEvent = await this.toORMapEvent(event, options); return this.map.validate(mapEvent, options); } @@ -106,13 +103,19 @@ export class ORSet< } return { - type: event.type === this.event.New ? this.map.event.New : this.map.event.Update, - payload: { ops, nonce: event.payload.nonce }, + type: event.type === SetEventType.New ? MapEventType.New : MapEventType.Update, + payload: { ops }, meta: event.meta, }; } } +/** Command type for {@link SetAggregate}. */ +export enum SetCommandType { + /** Sets or deletes set fields. */ + Update = 'SET_OPS', +} + /** Event type for {@link SetAggregate}. */ export enum SetEventType { /** Creates a new set. */ @@ -125,7 +128,7 @@ export enum SetEventType { /** Query options for {@link SetAggregate}. */ export interface SetQuery extends AbortOptions { /** Reference to (root event of) the set. */ - readonly ref: Ref; + readonly root: Ref; /** Returns only value greater than or equal to given value. */ readonly gte?: V; @@ -141,7 +144,10 @@ export interface SetQuery extends AbortOptions { } /** Command for {@link SetAggregate}. */ -export interface SetCommand extends AggregateCommandMeta { +export type SetCommand = StandardCommand, Ref>; + +/** Command payload for {@link SetAggregate}. */ +export interface SetCommandPayload { /** Adds given values to the set. */ readonly add?: readonly V[]; @@ -150,15 +156,12 @@ export interface SetCommand extends AggregateCommandMeta { } /** Event for {@link SetAggregate}. */ -export type SetEvent = AggregateEvent>; +export type SetEvent = StandardEvent, Ref>; /** Event payload for {@link SetAggregate}. */ export interface SetEventPayload { /** Operations to add or delete given values in the set. */ readonly ops: readonly [value: V, ...parentIdxToDelete: number[]][]; - - /** A random number to make a unique event when creating a new set. */ - readonly nonce?: number; } /** Options for creating an {@link ORSet}. */ diff --git a/packages/eventstore/src/__tests__/replicate.spec.ts b/packages/eventstore/src/__tests__/replicate.spec.ts index 8de268f6..977db36a 100644 --- a/packages/eventstore/src/__tests__/replicate.spec.ts +++ b/packages/eventstore/src/__tests__/replicate.spec.ts @@ -72,10 +72,10 @@ describe(replicateEvents.name, () => { it('should throw error if target.putMany() fails', async () => { jest.mocked(target.putMany).mockImplementation(async function * (values: string[]) { for (let i = 0; i < values.length; i++) { - yield ['key', new Error('Failed to put')]; + yield ['key', new Error('failed to put')]; } }); - await expect(replicateEvents({ source, target, batchSize: 2 })).rejects.toThrow('Failed to put'); + await expect(replicateEvents({ source, target, batchSize: 2 })).rejects.toThrow('failed to put'); }); }); diff --git a/packages/eventstore/src/base/base.ts b/packages/eventstore/src/base/base.ts index 4eccc045..8a65a109 100644 --- a/packages/eventstore/src/base/base.ts +++ b/packages/eventstore/src/base/base.ts @@ -1,6 +1,6 @@ import { AppendOnlyAutoKeyMap, AutoKeyMapBatch, Batch } from '@mithic/collections'; import { - AbortOptions, CodedError, ContentId, ErrorCode, MaybePromise, SyncOrAsyncGenerator, operationError + AbortOptions, CodedError, ContentId, ERR_EXIST, MaybePromise, OperationError, SyncOrAsyncGenerator } from '@mithic/commons'; import { EventStore, EventStorePutOptions, EventStoreQueryOptions } from '../store.js'; import { DEFAULT_BATCH_SIZE } from '../defaults.js'; @@ -24,10 +24,10 @@ export abstract class BaseMapEventStore< return value; } - public async validate(value: V, options?: AbortOptions): Promise | undefined> { + public async validate(value: V, options?: AbortOptions): Promise { const key = await this.data.getKey(value, options); if (await this.data.has(key, options)) { - return operationError('Already exists', ErrorCode.Exist, [key]); + return new OperationError('already exists', { code: ERR_EXIST, detail: [key] }); } } @@ -55,8 +55,8 @@ export abstract class BaseMapEventStore< if (options?.validate ?? true) { const error = await this.validate(value, options); if (error) { - if (error.code === ErrorCode.Exist) { - return (error.detail as K[])[0]; + if ((error as CodedError)?.code === ERR_EXIST) { + return ((error as CodedError).detail as K[])[0]; } throw error; } @@ -75,7 +75,7 @@ export abstract class BaseMapEventStore< yield [ key, error instanceof Error && (error as CodedError)?.code ? - error : operationError('Failed to put', ErrorCode.OpFailed, key, error) + error : new OperationError('failed to put', { cause: error, detail: [key] }) ]; } } diff --git a/packages/eventstore/src/base/dag.ts b/packages/eventstore/src/base/dag.ts index 24853ea5..f11073ac 100644 --- a/packages/eventstore/src/base/dag.ts +++ b/packages/eventstore/src/base/dag.ts @@ -1,6 +1,6 @@ import { AppendOnlyAutoKeyMap, AutoKeyMapBatch } from '@mithic/collections'; import { - AbortOptions, CodedError, ContentId, ErrorCode, StringEquatable, equalsOrSameString, operationError + AbortOptions, ContentId, ERR_DEPENDENCY_MISSING, OperationError, StringEquatable, equalsOrSameString } from '@mithic/commons'; import { StandardEvent } from '@mithic/cqrs/event'; import { EventStore, EventStorePutOptions } from '../store.js'; @@ -31,7 +31,7 @@ export abstract class BaseDagEventStore< super(data, queryPageSize); } - public override async validate(value: V, options?: AbortOptions): Promise | undefined> { + public override async validate(value: V, options?: AbortOptions): Promise { const error = await super.validate(value, options); if (error) { return error; @@ -39,7 +39,7 @@ export abstract class BaseDagEventStore< const event = this.toStandardEvent(value); if (!event) { - return operationError('Invalid event value', ErrorCode.InvalidArg); + return new TypeError('invalid event value'); } const parents = this.useCache ? this.currentEventDeps : []; @@ -49,12 +49,12 @@ export abstract class BaseDagEventStore< const rootId = event.meta?.root; if (!deps.length) { if (rootId !== void 0) { // if specified, root Id must be a dependency - return operationError('Missing dependency to root Id', ErrorCode.MissingDep, [rootId]); + return new TypeError('missing dependency to root Id'); } return; } if (rootId === void 0) { // root Id must be specified if there are dependencies - return operationError('Missing root Id', ErrorCode.InvalidArg); + return new TypeError('missing root Id'); } const missing: K[] = []; @@ -72,11 +72,11 @@ export abstract class BaseDagEventStore< } if (missing.length) { - return operationError('Missing dependencies', ErrorCode.MissingDep, missing); + return new OperationError('missing dependencies', { code: ERR_DEPENDENCY_MISSING, detail: missing }); } if (!hasSameRoot) { // root Id must match one of parents' root - return operationError('Missing dependency to root Id', ErrorCode.MissingDep, [rootId]); + return new OperationError('missing dependency to root Id', { code: ERR_DEPENDENCY_MISSING, detail: [rootId] }); } } diff --git a/packages/eventstore/src/store.ts b/packages/eventstore/src/store.ts index c24dddbc..06003741 100644 --- a/packages/eventstore/src/store.ts +++ b/packages/eventstore/src/store.ts @@ -1,7 +1,9 @@ import { AppendOnlyAutoKeyMap, AutoKeyMapPutBatch, MaybeAsyncReadonlyMapBatch, ReadonlyAutoKeyMap } from '@mithic/collections'; -import { AbortOptions, CodedError, ContentId, MaybeAsyncIterableIterator, MaybePromise, SyncOrAsyncGenerator } from '@mithic/commons'; +import { + AbortOptions, ContentId, MaybeAsyncIterableIterator, MaybePromise, SyncOrAsyncGenerator +} from '@mithic/commons'; /** An append-only event store. */ export interface EventStore> @@ -17,7 +19,7 @@ export interface ReadonlyEventStore, MaybeAsyncReadonlyMapBatch, EventStoreQuery { /** Validates given event and returns any error. */ - validate(value: V, options?: AbortOptions): MaybePromise | undefined>; + validate(value: V, options?: AbortOptions): MaybePromise; } /** Query APIs for an event store. */ diff --git a/packages/eventstore/src/store/__tests__/dag.spec.ts b/packages/eventstore/src/store/__tests__/dag.spec.ts index 8a5766c5..ebf2f5df 100644 --- a/packages/eventstore/src/store/__tests__/dag.spec.ts +++ b/packages/eventstore/src/store/__tests__/dag.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { ContentAddressedMapStore } from '@mithic/collections'; -import { operationError, ErrorCode } from '@mithic/commons'; +import { ERR_DEPENDENCY_MISSING, OperationError } from '@mithic/commons'; import { DagEventStore } from '../dag.js'; import { MockEventType, MockId } from '../../__tests__/mocks.js'; @@ -65,7 +65,7 @@ describe(DagEventStore.name, () => { meta: { root: ID1, prev: [ID1, ID3] } }; await expect(store.put(event)).rejects - .toThrowError(operationError('Missing dependencies', ErrorCode.MissingDep, [ID3])); + .toThrowError(new OperationError('missing dependencies',{ code: ERR_DEPENDENCY_MISSING, detail: [ID3] })); }); it('should throw an error if root Id is invalid', async () => { @@ -75,7 +75,7 @@ describe(DagEventStore.name, () => { meta: { root: ID2, prev: [ID1] } }; await expect(store.put(event)).rejects - .toThrowError(operationError('Missing dependency to root Id', ErrorCode.InvalidArg)); + .toThrowError(new TypeError('missing dependency to root Id')); }); it('should throw an error if the root Id is missing', async () => { @@ -84,7 +84,7 @@ describe(DagEventStore.name, () => { payload: [3, new MockId(new Uint8Array([1, 3, 5]))], meta: { prev: [ID1] } }; - await expect(store.put(event)).rejects.toThrowError(operationError('Missing root Id', ErrorCode.InvalidArg)); + await expect(store.put(event)).rejects.toThrowError(new TypeError('missing root Id')); }); }); @@ -101,7 +101,7 @@ describe(DagEventStore.name, () => { } expect(results).toEqual([ [key1], - [key2, operationError('Missing dependencies', ErrorCode.MissingDep, [ID3])] + [key2, new OperationError('missing dependencies', { code: ERR_DEPENDENCY_MISSING, detail: [ID3] })] ]); }); diff --git a/packages/eventstore/src/store/__tests__/indexed.spec.ts b/packages/eventstore/src/store/__tests__/indexed.spec.ts index 439e7955..4e809b8d 100644 --- a/packages/eventstore/src/store/__tests__/indexed.spec.ts +++ b/packages/eventstore/src/store/__tests__/indexed.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { BTreeMap, ContentAddressedMapStore } from '@mithic/collections'; -import { ErrorCode, operationError } from '@mithic/commons'; +import { ERR_DEPENDENCY_MISSING, OperationError } from '@mithic/commons'; import { StandardEventMeta } from '@mithic/cqrs/event'; import { IndexedEventStore } from '../indexed.js'; import { MockEventType, MockId } from '../../__tests__/mocks.js'; @@ -67,7 +67,7 @@ describe(IndexedEventStore.name, () => { meta: { root: ID1, prev: [ID1, ID3] } }; await expect(store.put(event)).rejects - .toThrowError(operationError('Missing dependencies', ErrorCode.MissingDep, [ID3])); + .toThrowError(new OperationError('missing dependencies', { code: ERR_DEPENDENCY_MISSING, detail: [ID3] })); }); it('should throw an error if root Id is invalid', async () => { @@ -77,7 +77,7 @@ describe(IndexedEventStore.name, () => { meta: { root: ID2, prev: [ID1] } }; await expect(store.put(event)).rejects - .toThrowError(operationError('Missing dependency to root Id', ErrorCode.InvalidArg)); + .toThrowError(new TypeError('missing dependency to root Id')); }); it('should throw an error if the root Id is missing', async () => { @@ -86,7 +86,7 @@ describe(IndexedEventStore.name, () => { payload: [3, new MockId(new Uint8Array([1, 3, 5]))], meta: { prev: [ID1] } }; - await expect(store.put(event)).rejects.toThrowError(operationError('Missing root Id', ErrorCode.InvalidArg)); + await expect(store.put(event)).rejects.toThrowError(new TypeError('missing root Id')); }); }); @@ -103,7 +103,7 @@ describe(IndexedEventStore.name, () => { } expect(results).toEqual([ [key1], - [key2, operationError('Missing dependencies', ErrorCode.MissingDep, [ID3])] + [key2, new OperationError('missing dependencies', { code: ERR_DEPENDENCY_MISSING, detail: [ID3] })] ]); }); diff --git a/packages/eventstore/src/store/dag.ts b/packages/eventstore/src/store/dag.ts index 8e7a65a9..1064a43c 100644 --- a/packages/eventstore/src/store/dag.ts +++ b/packages/eventstore/src/store/dag.ts @@ -3,7 +3,7 @@ import { MaybeAsyncReadonlySet, MaybeAsyncSet, MaybeAsyncSetBatch } from '@mithic/collections'; import { - AbortOptions, ContentId, ErrorCode, StringEquatable, SyncOrAsyncIterable, equalsOrSameString, operationError + AbortOptions, ContentId, InvalidStateError, OperationError, StringEquatable, SyncOrAsyncIterable, equalsOrSameString } from '@mithic/commons'; import { StandardEvent } from '@mithic/cqrs/event'; import { BaseDagEventStore } from '../base/index.js'; @@ -16,7 +16,7 @@ const decodeCID = await (async () => { const { CID } = await import('multiformats'); return (key: string) => CID.parse(key) as unknown as K; } catch (_) { - return () => { throw operationError('multiformats not available', ErrorCode.InvalidState); }; + return () => { throw new InvalidStateError('multiformats not available'); }; } })(); @@ -102,7 +102,7 @@ export class DagEventStore< if (parents?.length) { for await (const error of heads.deleteMany(parents, options)) { if (error) { - throw operationError('Failed to update head', ErrorCode.OpFailed, void 0, error); + throw new OperationError('failed to update head', { cause: error }); } } } @@ -138,7 +138,7 @@ export class DagEventStore< options )) { if (error) { - throw operationError('Failed to update head', ErrorCode.OpFailed, void 0, error); + throw new OperationError('failed to update head', { cause: error }); } } diff --git a/packages/eventstore/src/store/indexed.ts b/packages/eventstore/src/store/indexed.ts index 37ba4e58..1c4d348a 100644 --- a/packages/eventstore/src/store/indexed.ts +++ b/packages/eventstore/src/store/indexed.ts @@ -2,7 +2,7 @@ import { AppendOnlyAutoKeyMap, AutoKeyMapBatch, BTreeMap, Batch, ContentAddressedMapStore, MaybeAsyncMap, MaybeAsyncMapBatch, RangeQueryable } from '@mithic/collections'; -import { AbortOptions, ContentId, ErrorCode, MaybePromise, StringEquatable, operationError } from '@mithic/commons'; +import { AbortOptions, ContentId, MaybePromise, OperationError, StringEquatable } from '@mithic/commons'; import { StandardEvent } from '@mithic/cqrs/event'; import { BaseDagEventStore } from '../base/index.js'; import { DEFAULT_EVENT_TYPE_SEPARATOR, DEFAULT_KEY_ENCODER, atomicHybridTime } from '../defaults.js'; @@ -60,7 +60,7 @@ export class IndexedEventStore< const event = this.toStandardEvent(newValue); if (!event) { - throw operationError('Invalid event', ErrorCode.InvalidArg); + throw new TypeError('invalid event'); } // update indices @@ -79,7 +79,7 @@ export class IndexedEventStore< for await (const error of Batch.updateMapMany(this.index, entries, options)) { if (error) { - throw operationError('Failed to save indices', ErrorCode.OpFailed, void 0, error); + throw new OperationError('failed to save indices', { cause: error }); } } diff --git a/packages/messaging/src/error.ts b/packages/messaging/src/error.ts index 913188fe..e394dcd4 100644 --- a/packages/messaging/src/error.ts +++ b/packages/messaging/src/error.ts @@ -1,4 +1,4 @@ -import { CodedError } from '@mithic/commons'; +import { CodedError, ErrorCodeDetailOptions } from '@mithic/commons'; /** Message validation error. */ export class MessageValidationError extends CodedError { @@ -15,17 +15,14 @@ export class MessageValidationError extends CodedError } /** Options for initializing a {@link MessageValidationError}. */ -export interface MessageValidationErrorOptions extends ErrorOptions { +export interface MessageValidationErrorOptions extends ErrorCodeDetailOptions { /** Error code. */ code?: MessageValidationErrorCode; - - /** Error details. */ - detail?: T; } /** Message validation error code. */ export enum MessageValidationErrorCode { - /** The message should be ignored. */ + /** The message should be ignored, due to being duplicate or outdated. */ Ignore = 'ignore', /** The message is considered invalid, and it should be rejected. */ diff --git a/packages/messaging/src/impl/__tests__/direct.spec.ts b/packages/messaging/src/impl/__tests__/direct.spec.ts index b8520378..1e48c136 100644 --- a/packages/messaging/src/impl/__tests__/direct.spec.ts +++ b/packages/messaging/src/impl/__tests__/direct.spec.ts @@ -64,14 +64,14 @@ describe(DirectMessageBus.name, () => { const callback = jest.fn(() => undefined); bus.subscribe(callback); expect(mockBus.topicValidators.get(TOPIC)?.(DATA, { topic: TOPIC, from: PEER_ID_2 })) - .toEqual(new MessageValidationError('Invalid message', { code: MessageValidationErrorCode.Ignore })); + .toEqual(new MessageValidationError('invalid message', { code: MessageValidationErrorCode.Ignore })); }); it('should ignore message from invalid topic ID', () => { const callback = jest.fn(() => undefined); bus.subscribe(callback); expect(mockBus.topicValidators.get(TOPIC)?.(DATA, { topic: FULL_TOPIC2, from: OTHER_PEER_ID })) - .toEqual(new MessageValidationError('Invalid message', { code: MessageValidationErrorCode.Ignore })); + .toEqual(new MessageValidationError('invalid message', { code: MessageValidationErrorCode.Ignore })); }); it('should use given validator to validate messages before passing to handler', () => { diff --git a/packages/messaging/src/impl/direct.ts b/packages/messaging/src/impl/direct.ts index 74518917..992e1e51 100644 --- a/packages/messaging/src/impl/direct.ts +++ b/packages/messaging/src/impl/direct.ts @@ -68,7 +68,7 @@ export class DirectMessageBus { } catch (e) { err = e; } - expect((err as Error)?.name).toBe(ErrorName.Abort); + expect((err as Error)?.name).toBe('AbortError'); }); it('should reject with AbortError if aborted', async () => { @@ -80,6 +79,6 @@ describe(waitForPeer.name, () => { } catch (e) { err = e; } - expect((err as Error)?.name).toBe(ErrorName.Abort); + expect((err as Error)?.name).toBe('AbortError'); }); }); diff --git a/packages/messaging/src/utils/wait-peer.ts b/packages/messaging/src/utils/wait-peer.ts index 652dfaf4..7e44a776 100644 --- a/packages/messaging/src/utils/wait-peer.ts +++ b/packages/messaging/src/utils/wait-peer.ts @@ -1,4 +1,4 @@ -import { abortError, AbortOptions, equalsOrSameString, StringEquatable } from '@mithic/commons'; +import { AbortError, AbortOptions, equalsOrSameString, StringEquatable } from '@mithic/commons'; import { MessageSubscriptionPeers, MessageSubscriptionState } from '../peer-aware.js'; /** Interval in milliseconds to wait for next connection check. */ @@ -30,7 +30,7 @@ export async function waitForPeer>( } if (!subscribed) { // no longer subscribed = channel closed clearInterval(interval); - return reject(abortError('channel closed')); + return reject(new AbortError('channel closed')); } if (await isPeerConnected(sub, topic, peer, options)) { diff --git a/packages/plugins/ipfs/src/__tests__/blockstore.spec.ts b/packages/plugins/ipfs/src/__tests__/blockstore.spec.ts index 5daf4d6f..6e577f6f 100644 --- a/packages/plugins/ipfs/src/__tests__/blockstore.spec.ts +++ b/packages/plugins/ipfs/src/__tests__/blockstore.spec.ts @@ -1,10 +1,10 @@ import { afterAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { OperationError } from '@mithic/commons'; import { MemoryBlockstore } from 'blockstore-core'; import { Blockstore } from 'interface-blockstore'; import { BlockCodec, CID } from 'multiformats'; import { identity } from 'multiformats/hashes/identity'; import { BlockstoreMap } from '../blockstore.js'; -import { ErrorCode, operationError } from '@mithic/commons'; const DATA = new Uint8Array([1, 2, 3]); const DATA_CID = CID.createV1(0x999, identity.digest(DATA)); @@ -153,7 +153,7 @@ describe(BlockstoreMap.name, () => { expect(results).toEqual([ [DATA_CID], - [cid2, operationError('Failed to put', ErrorCode.OpFailed, cid2, error)] + [cid2, new OperationError('failed to put', { detail: cid2, cause: error })] ]); expect(putMock).toHaveBeenCalledWith(DATA_CID, DATA, options); expect(putMock).toHaveBeenCalledWith(cid2, data2, options); diff --git a/packages/plugins/ipfs/src/blockstore.ts b/packages/plugins/ipfs/src/blockstore.ts index 96690698..5388a304 100644 --- a/packages/plugins/ipfs/src/blockstore.ts +++ b/packages/plugins/ipfs/src/blockstore.ts @@ -1,5 +1,5 @@ import { AutoKeyMap, AutoKeyMapBatch } from '@mithic/collections'; -import { AbortOptions, CodedError, ErrorCode, MaybePromise, operationError, sha256 } from '@mithic/commons'; +import { AbortOptions, CodedError, MaybePromise, OperationError, sha256 } from '@mithic/commons'; import { Blockstore } from 'interface-blockstore'; import { BlockCodec, CID, SyncMultihashHasher } from 'multiformats'; @@ -68,11 +68,11 @@ export class BlockstoreMap for (const value of values) { try { yield [await this.put(value, options)]; - } catch (error) { + } catch (cause) { const key = this.getKey(value); yield [ key, - operationError('Failed to put', (error as CodedError)?.code ?? ErrorCode.OpFailed, key, error) + new OperationError('failed to put', { cause, code: (cause as CodedError)?.code, detail: key }) ]; } } @@ -84,7 +84,7 @@ export class BlockstoreMap } function mapGetError(err: unknown): void { - if ((err as CodedError)?.code === ErrorCode.NotFound) { + if ((err as CodedError)?.code === 'ERR_NOT_FOUND') { return; } throw err; diff --git a/packages/plugins/level/src/map.ts b/packages/plugins/level/src/map.ts index 2666fb7b..c04438a8 100644 --- a/packages/plugins/level/src/map.ts +++ b/packages/plugins/level/src/map.ts @@ -1,5 +1,5 @@ import { MaybeAsyncMap, MaybeAsyncMapBatch, RangeQueryOptions, RangeQueryable } from '@mithic/collections'; -import { AbortOptions, AsyncDisposableCloseable, CodedError, ErrorCode, Startable, operationError } from '@mithic/commons'; +import { AbortOptions, AsyncDisposableCloseable, CodedError, OperationError, Startable } from '@mithic/commons'; import { AbstractLevel, AbstractOpenOptions } from 'abstract-level'; const LEVEL_NOT_FOUND = 'LEVEL_NOT_FOUND'; @@ -88,7 +88,7 @@ export class LevelMap for (const [key] of entries) { if (error) { - yield operationError('Failed to delete', ErrorCode.OpFailed, key, error); + yield new OperationError('failed to set', { detail: key, cause: error }); } else { yield; } @@ -110,7 +110,7 @@ export class LevelMap for (const key of keys) { if (error) { - yield operationError('Failed to delete', ErrorCode.OpFailed, key, error); + yield new OperationError('failed to delete', { detail: key, cause: error }); } else { yield; } @@ -136,7 +136,7 @@ export class LevelMap for (const [key] of entries) { if (error) { - yield operationError('Failed to update', ErrorCode.OpFailed, key, error); + yield new OperationError('failed to update', { detail: key, cause: error }); } else { yield; } diff --git a/packages/plugins/redis/src/__tests__/map.spec.ts b/packages/plugins/redis/src/__tests__/map.spec.ts index add87b9d..98e8d85c 100644 --- a/packages/plugins/redis/src/__tests__/map.spec.ts +++ b/packages/plugins/redis/src/__tests__/map.spec.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { OperationError } from '@mithic/commons'; import { RedisClientType, commandOptions } from '@redis/client'; import { createMockRedisClient, createMockRedisClientMultiCommand } from '../__tests__/mocks.js'; import { RedisMap } from '../map.js'; import { RangeQueryOptions } from '@mithic/collections'; -import { ErrorCode, operationError } from '@mithic/commons'; const HASH_KEY = 'hash-key'; const RANGE_KEY = 'range-key'; @@ -142,7 +142,10 @@ describe(RedisMap.name, () => { for await (const error of map.setMany([[KEY1, VALUE1], [KEY2, VALUE2]])) { results.push(error); } - expect(results).toEqual([operationError('Failed to set', ErrorCode.OpFailed, KEY1, 'error'), operationError('Failed to set', ErrorCode.OpFailed, KEY2, 'error')]); + expect(results).toEqual([ + new OperationError('failed to set', { detail: KEY1, cause: 'error' }), + new OperationError('failed to set', { detail: KEY2, cause: 'error' }) + ]); }); }); @@ -169,7 +172,10 @@ describe(RedisMap.name, () => { for await (const error of map.deleteMany([KEY1, KEY2])) { results.push(error); } - expect(results).toEqual([operationError('Failed to delete', ErrorCode.OpFailed, KEY1, 'error'), operationError('Failed to delete', ErrorCode.OpFailed, KEY2, 'error')]); + expect(results).toEqual([ + new OperationError('failed to delete', { detail: KEY1, cause: 'error' }), + new OperationError('failed to delete', { detail: KEY2, cause: 'error' }) + ]); }); }); @@ -198,7 +204,10 @@ describe(RedisMap.name, () => { for await (const error of map.updateMany([[KEY1, VALUE1], [KEY2, void 0]])) { results.push(error); } - expect(results).toEqual([operationError('Failed to update', ErrorCode.OpFailed, KEY1, 'error'), operationError('Failed to update', ErrorCode.OpFailed, KEY2, 'error')]); + expect(results).toEqual([ + new OperationError('failed to update', { detail: KEY1, cause: 'error' }), + new OperationError('failed to update', { detail: KEY2, cause: 'error' }) + ]); }); }); diff --git a/packages/plugins/redis/src/map.ts b/packages/plugins/redis/src/map.ts index 3708760b..c6aab6da 100644 --- a/packages/plugins/redis/src/map.ts +++ b/packages/plugins/redis/src/map.ts @@ -1,5 +1,5 @@ import { MaybeAsyncMap, MaybeAsyncMapBatch, RangeQueryOptions, RangeQueryable } from '@mithic/collections'; -import { AbortOptions, AsyncDisposableCloseable, CodedError, ErrorCode, Startable, operationError } from '@mithic/commons'; +import { AbortOptions, AsyncDisposableCloseable, CodedError, OperationError, Startable } from '@mithic/commons'; import { commandOptions, RedisClientType } from '@redis/client'; import { RedisValueType } from './type.js'; @@ -89,7 +89,7 @@ export class RedisMap