From b96358b1b44016b4ee8bcba91556c1ee088dc521 Mon Sep 17 00:00:00 2001 From: Alvis HT Tang Date: Thu, 1 Oct 2026 14:47:51 +0100 Subject: [PATCH] fix: share state symbols across module copies Each state key was a module-local Symbol(), so a second copy of xception in the same process (e.g. two bundled copies) could not read the cause, severity, tags, namespace, meta or code of an instance created by the other copy, and instanceof checks across copies failed. Register every key with Symbol.for('xception.') so all copies resolve the same registry-global symbol. --- spec/symbols.spec.ts | 120 +++++++++++++++++++++++++++++++++++++++++++ src/symbols.ts | 17 +++--- 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 spec/symbols.spec.ts diff --git a/spec/symbols.spec.ts b/spec/symbols.spec.ts new file mode 100644 index 0000000..842a364 --- /dev/null +++ b/spec/symbols.spec.ts @@ -0,0 +1,120 @@ +/* + * *** MIT LICENSE *** + * ------------------------------------------------------------------------- + * This code may be modified and distributed under the MIT license. + * See the LICENSE file for details. + * ------------------------------------------------------------------------- + * + * @summary Tests on state symbols shared across module copies + * + * @author Alvis HT Tang + * @license MIT + * @copyright Copyright (c) 2026 - All Rights Reserved. + * ------------------------------------------------------------------------- + */ + +import { describe, expect, it, vi } from 'vitest'; + +type Copy = typeof import('#index'); + +/** + * load an independent instance of the whole module graph, as a second bundled + * copy of xception in the same process would be + * @returns the freshly evaluated module namespace + */ +async function loadCopy(): Promise { + vi.resetModules(); + + return import('#index'); +} + +const copyA = await loadCopy(); +const copyB = await loadCopy(); + +describe('two module copies', () => { + it('should evaluate each copy into its own class', () => { + expect(copyB.Xception).not.toBe(copyA.Xception); + }); + + it('should resolve every state symbol to the same registry-global key', () => { + const pick = ({ + $cause, + $code, + $meta, + $namespace, + $severity, + $tags, + }: Copy): Record => ({ + $cause, + $code, + $meta, + $namespace, + $severity, + $tags, + }); + + expect(pick(copyB)).toEqual(pick(copyA)); + }); + + it('should treat an instance from another copy as an Xception', () => { + const error = new copyA.Xception('from copy A'); + + expect(error).toBeInstanceOf(copyB.Xception); + }); + + it('should treat a named subclass instance from another copy as that subclass', () => { + const NotFoundA = copyA.createXceptionClass('NotFoundError'); + const NotFoundB = copyB.createXceptionClass('NotFoundError'); + + expect(new NotFoundA('missing')).toBeInstanceOf(NotFoundB); + }); + + it('should inherit severity and merge tags when wrapping a cause from another copy', () => { + const cause = new copyA.Xception('inner', { + severity: 'warning', + tags: ['inner'], + }); + + const error = new copyB.Xception('outer', { cause, tags: ['outer'] }); + + expect(error).toEqual( + expect.objectContaining({ + cause, + severity: 'warning', + tags: ['inner', 'outer'], + }), + ); + }); + + it('should carry namespace, meta and tags when converting an error from another copy', () => { + const cause = new copyA.Xception('inner', { + namespace: 'copy:a', + meta: { id: 1 }, + tags: ['inner'], + severity: 'warning', + }); + + const error = copyB.xception(cause, { tags: ['outer'] }); + + expect(error).toEqual( + expect.objectContaining({ + namespace: 'copy:a', + meta: { id: 1 }, + tags: ['inner', 'outer'], + severity: 'warning', + }), + ); + }); + + it('should serialize an error whose cause comes from another copy', () => { + const cause = new copyA.Xception('inner', { code: 'E_INNER' }); + + const error = new copyB.Xception('outer', { cause }); + + expect(error.toJSON()).toEqual( + expect.objectContaining({ + cause: expect.objectContaining({ code: 'E_INNER', message: 'inner' }), + }), + ); + }); +}); diff --git a/src/symbols.ts b/src/symbols.ts index fb92037..eecc655 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -12,10 +12,13 @@ * ------------------------------------------------------------------------- */ -export const $namespace = Symbol('namespace'); -export const $tags = Symbol('tags'); -export const $cause = Symbol('cause'); -export const $meta = Symbol('meta'); -export const $severity = Symbol('severity'); -export const $code = Symbol('code'); -export const $brands = Symbol('xception.brands'); +// NOTE: every key is registry-global so that an instance created by one copy of +// this module (e.g. a second bundled copy in the same process) keeps its state +// readable by another copy, which a module-local Symbol() would hide +export const $namespace: unique symbol = Symbol.for('xception.namespace'); +export const $tags: unique symbol = Symbol.for('xception.tags'); +export const $cause: unique symbol = Symbol.for('xception.cause'); +export const $meta: unique symbol = Symbol.for('xception.meta'); +export const $severity: unique symbol = Symbol.for('xception.severity'); +export const $code: unique symbol = Symbol.for('xception.code'); +export const $brands: unique symbol = Symbol.for('xception.brands');