diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index a6f8150..5b27cb3 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -50,6 +50,12 @@ Authorization: Bearer The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /records/:id/migrate`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema. +### The taxonomy root + +Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`) stay outside the hierarchy for that reason. + +The root adds no other structure. `StackVersionConflictError` remains a sibling of `StackConflictError` rather than a subtype (see the 409/412 rows above), and no other pair is related either, so catching a leaf class never catches a different failure by accident. + ### Wire error body Every non-2xx response whose failure maps to the core error taxonomy carries a JSON body of the shape: @@ -68,7 +74,9 @@ Every non-2xx response whose failure maps to the core error taxonomy carries a J Each error code that carries extra structured data gets its own uniquely-named, uniquely-typed field, present only for that code — `details` for `code: "validation"` (`StackValidationError.errors`), `versionConflict` for `code: "version_conflict"` (the data an `ifVersion` retry loop needs: which record, what it expected, what actually won the race), `schemaDrift` for `code: "schema_drift"` (which Type, and which specific fields made the change non-additive). This keeps each field's shape fixed rather than making any one field polymorphic across codes. -`code` is the authoritative discriminator — HTTP status is a transport hint (proxies and intermediaries rewrite statuses more often than bodies). Each core error class exposes the mapping as a static `code` (e.g. `StackPermissionError.code === 'permission'`), so a server serializes a caught error mechanically rather than via a hand-maintained switch, and `APIAdapter` reconstructs the same class from the response. +`code` is the authoritative discriminator — HTTP status is a transport hint (proxies and intermediaries rewrite statuses more often than bodies). Each core error class exposes the mapping both as a static (`StackPermissionError.code === 'permission'`) and on every instance (`err.code`), so serializing a caught error is a status lookup on the instance rather than a hand-maintained chain of class tests, and `APIAdapter` reconstructs the same class from the response. The vocabulary itself is `StackErrorCode` in `@haverstack/core` — it lives with the classes that carry it, and `@haverstack/wire-types` re-exports it as `WireErrorCode`. + +Note that an instance `code` discriminates but doesn't narrow: TypeScript won't refine a `StackError` to a subclass from a literal `code` check, so reaching the payload fields (`errors`, `versionConflict` state, `violations`) still means an `instanceof` on the three classes that define them. Those three are leaves with no subtype relation, so unlike a full class ladder the checks are order-independent. When a response has no parseable wire error body (a foreign server implementation, or a proxy that strips bodies but preserves status), `APIAdapter` still recovers the precise error from status alone for the unambiguous statuses (400/403/404/412/413/422) — **not** for 500, since that status is a generic "unhandled server exception" signal and would misclassify ordinary server bugs as `StackMigrationError`. `schema_drift` is the one deliberate exception to one-code-per-status: it shares **409** with `conflict` (both are "operation conflicts with a constraint" in HTTP terms), so status-only reconstruction of a bodyless 409 degrades to the generic `StackConflictError` — the precise class is only recoverable with a parseable body. When neither the body nor the status yields a typed error, `APIAdapter` throws its own generic `APIAdapterError`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 518040a..31d43b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export { Stack, ScopedStack, + StackError, StackValidationError, StackMigrationError, StackPermissionError, @@ -24,6 +25,7 @@ export { assertQueryCapabilities, } from './stack.js'; export type { + StackErrorCode, StackClient, CreateRecordOptions, StackOptions, diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index a58373f..daa0688 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -151,8 +151,46 @@ export type DefineTypeOptions = { migratesFrom?: TypeId; }; -export class StackValidationError extends Error { +/** + * The wire-protocol discriminator vocabulary, one code per Stack-domain + * error class. Lives here rather than in @haverstack/wire-types because the + * classes that carry these codes are defined here; wire-types re-exports it + * as WireErrorCode. See docs/spec/wire-format.md § Wire error body. + */ +export type StackErrorCode = + | 'bad_request' + | 'permission' + | 'not_found' + | 'conflict' + | 'version_conflict' + | 'validation' + | 'migration' + | 'schema_drift' + | 'payload_too_large'; + +/** + * Root of the Stack error taxonomy. A single `instanceof StackError` answers + * "is this a Stack-domain error or a bug?" — the question a server's error + * middleware asks before serializing a wire body, and one a nine-arm + * instanceof ladder answers only by exhaustion. Every subclass carries its + * discriminator as an instance `code`, so serialization is a lookup rather + * than a chain of class tests. + * + * Membership implies a wire mapping: every code has an entry in + * WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError, + * InvalidDidError) deliberately stay outside this hierarchy. + * + * Subclassing adds no hierarchy beyond this root — notably + * StackVersionConflictError is a sibling of StackConflictError, not a + * subtype. See docs/spec/wire-format.md § Error responses. + */ +export abstract class StackError extends Error { + abstract readonly code: StackErrorCode; +} + +export class StackValidationError extends StackError { static readonly code = 'validation' as const; + override readonly code = StackValidationError.code; constructor(public readonly errors: ValidationError[]) { super( `Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'), @@ -161,8 +199,9 @@ export class StackValidationError extends Error { } } -export class StackMigrationError extends Error { +export class StackMigrationError extends StackError { static readonly code = 'migration' as const; + override readonly code = StackMigrationError.code; constructor(message: string) { super(message); this.name = 'StackMigrationError'; @@ -170,8 +209,9 @@ export class StackMigrationError extends Error { } /** Thrown by ScopedStack when a requester lacks permission for the operation. */ -export class StackPermissionError extends Error { +export class StackPermissionError extends StackError { static readonly code = 'permission' as const; + override readonly code = StackPermissionError.code; constructor(message = 'Permission denied') { super(message); this.name = 'StackPermissionError'; @@ -179,8 +219,9 @@ export class StackPermissionError extends Error { } /** Thrown when a record (or specific version) does not exist. */ -export class StackNotFoundError extends Error { +export class StackNotFoundError extends StackError { static readonly code = 'not_found' as const; + override readonly code = StackNotFoundError.code; constructor(message: string) { super(message); this.name = 'StackNotFoundError'; @@ -188,8 +229,9 @@ export class StackNotFoundError extends Error { } /** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */ -export class StackConflictError extends Error { +export class StackConflictError extends StackError { static readonly code = 'conflict' as const; + override readonly code = StackConflictError.code; constructor(message: string) { super(message); this.name = 'StackConflictError'; @@ -202,8 +244,9 @@ export class StackConflictError extends Error { * different recovery stories and HTTP statuses (409 vs. 412). See * docs/spec/versioning.md § Optimistic concurrency. */ -export class StackVersionConflictError extends Error { +export class StackVersionConflictError extends StackError { static readonly code = 'version_conflict' as const; + override readonly code = StackVersionConflictError.code; constructor( message: string, readonly recordId: string, @@ -221,8 +264,9 @@ export class StackVersionConflictError extends Error { * undecodable pagination cursor). Distinct from StackValidationError, which * means the request was well-formed but content failed schema validation. */ -export class StackQueryError extends Error { +export class StackQueryError extends StackError { static readonly code = 'bad_request' as const; + override readonly code = StackQueryError.code; constructor(message: string) { super(message); this.name = 'StackQueryError'; @@ -258,8 +302,9 @@ export function assertQueryCapabilities( * sent; a server still enforces 413 authoritatively regardless. See * docs/spec/wire-format.md § Attachments. */ -export class StackPayloadTooLargeError extends Error { +export class StackPayloadTooLargeError extends StackError { static readonly code = 'payload_too_large' as const; + override readonly code = StackPayloadTooLargeError.code; constructor(message: string) { super(message); this.name = 'StackPayloadTooLargeError'; @@ -281,8 +326,9 @@ function assertAttachmentSize(byteLength: number, maxAttachmentBytes: number | n * in-place redefinition. See docs/spec/data-model.md § Schema drift * detection. */ -export class StackSchemaDriftError extends Error { +export class StackSchemaDriftError extends StackError { static readonly code = 'schema_drift' as const; + override readonly code = StackSchemaDriftError.code; constructor( public readonly typeId: TypeId, public readonly violations: SchemaDriftViolation[], diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 56688bb..5e6107f 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -1,8 +1,10 @@ import { describe, test, expect, beforeEach, vi } from 'vitest'; import { Stack, + StackError, StackValidationError, StackMigrationError, + StackPermissionError, StackNotFoundError, StackConflictError, StackVersionConflictError, @@ -10,7 +12,8 @@ import { StackQueryError, StackPayloadTooLargeError, } from '../src/stack.js'; -import { generateId, crockford32Encode } from '../src/id.js'; +import { generateId, crockford32Encode, IdGenerationError } from '../src/id.js'; +import { InvalidDidError } from '../src/did.js'; import { MemoryAdapter, IncapableMemoryAdapter } from '../src/testing.js'; import type { BlobFileInfo, StackAdapter, StackRecord } from '../src/types.js'; @@ -2568,3 +2571,75 @@ describe('collectAttachmentGarbage', () => { expect(result.reclaimedBytes).toBe(2); }); }); + +// ------------------------------------------------------- +// Error taxonomy +// ------------------------------------------------------- + +// One instance per member, constructed with the minimum each requires. +const everyStackError = (): StackError[] => [ + new StackValidationError([{ path: 'text', message: 'expected string' }]), + new StackMigrationError('no migration path'), + new StackPermissionError(), + new StackNotFoundError('Record "1hk153x0a00b" not found.'), + new StackConflictError('Attachment is still referenced.'), + new StackVersionConflictError('Version mismatch.', '1hk153x0a00b', 3, 5), + new StackQueryError('Undecodable pagination cursor.'), + new StackSchemaDriftError(NOTE_V1, [{ path: 'text', message: 'type changed' }]), + new StackPayloadTooLargeError('Attachment exceeds the limit.'), +]; + +describe('error taxonomy', () => { + test('every Stack-domain error descends from StackError and from Error', () => { + for (const err of everyStackError()) { + expect(err, err.name).toBeInstanceOf(StackError); + expect(err, err.name).toBeInstanceOf(Error); + } + }); + + test('each class exposes the same code as an instance property and a static', () => { + const statics = [ + StackValidationError, + StackMigrationError, + StackPermissionError, + StackNotFoundError, + StackConflictError, + StackVersionConflictError, + StackQueryError, + StackSchemaDriftError, + StackPayloadTooLargeError, + ]; + const instances = everyStackError(); + for (const [i, cls] of statics.entries()) { + expect(instances[i].code, cls.name).toBe(cls.code); + } + }); + + test('codes are distinct, so a code identifies exactly one class', () => { + const codes = everyStackError().map((e) => e.code); + expect(new Set(codes).size).toBe(codes.length); + }); + + test('a version conflict is a sibling of a plain conflict, never a subtype', () => { + const version = new StackVersionConflictError('mismatch', '1hk153x0a00b', 3, 5); + expect(version).not.toBeInstanceOf(StackConflictError); + expect(new StackConflictError('blocked')).not.toBeInstanceOf(StackVersionConflictError); + }); + + test('a schema drift is a sibling of a plain conflict, never a subtype', () => { + const drift = new StackSchemaDriftError(NOTE_V1, [{ path: 'text', message: 'type changed' }]); + expect(drift).not.toBeInstanceOf(StackConflictError); + }); + + test('errors with no wire representation stay outside the taxonomy', () => { + expect(new IdGenerationError('clock went backwards')).not.toBeInstanceOf(StackError); + expect(new InvalidDidError('malformed did:key')).not.toBeInstanceOf(StackError); + expect(new Error('ordinary bug')).not.toBeInstanceOf(StackError); + }); + + test('errors thrown by real operations are catchable as StackError', async () => { + await expect(stack.create(NOTE_V1, { text: 42 })).rejects.toBeInstanceOf(StackError); + await expect(stack.get('1hk153x0a00b')).resolves.toBeNull(); + await expect(stack.update('1hk153x0a00b', { text: 'x' })).rejects.toBeInstanceOf(StackError); + }); +}); diff --git a/packages/wire-types/package.json b/packages/wire-types/package.json index 5b883f5..41900a7 100644 --- a/packages/wire-types/package.json +++ b/packages/wire-types/package.json @@ -17,14 +17,16 @@ "scripts": { "prepublishOnly": "pnpm run build", "build": "tsc -p tsconfig.build.json", + "test": "vitest run", "typecheck": "tsc --noEmit", - "lint": "eslint src" + "lint": "eslint src tests" }, "dependencies": { "@haverstack/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^2.0.0" } } diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 1831329..82eeb15 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -6,8 +6,10 @@ import type { Permission, ValidationError, SchemaDriftViolation, + StackErrorCode, } from '@haverstack/core'; import { + StackError, StackValidationError, StackPermissionError, StackNotFoundError, @@ -117,16 +119,12 @@ export function parseDate(val: unknown): Date | undefined { // authoritative discriminator; status is a transport hint. See // docs/spec/wire-format.md § Error responses. -export type WireErrorCode = - | 'bad_request' - | 'permission' - | 'not_found' - | 'conflict' - | 'version_conflict' - | 'validation' - | 'migration' - | 'schema_drift' - | 'payload_too_large'; +/** + * Alias of core's StackErrorCode: the vocabulary belongs with the classes + * that carry it, and StackError.code is typed by it, so re-declaring the + * union here would be a second copy to drift. + */ +export type WireErrorCode = StackErrorCode; export type WireError = { error: { @@ -211,81 +209,28 @@ export function isWireError(body: unknown): body is WireError { /** * Convert a thrown core error into its wire response. Used by server - * implementations. Returns null for errors outside the core taxonomy — - * callers fall back to their own generic error handling. + * implementations. Returns null for anything that isn't a StackError — + * callers fall back to their own generic error handling, so an ordinary bug + * stays a bare 500 rather than being dressed as a protocol error. */ export function serializeError(err: unknown): { status: number; body: WireError } | null { + if (!(err instanceof StackError)) return null; + const error: WireError['error'] = { code: err.code, message: err.message }; + // The three classes carrying structured payload still need instanceof: a + // literal `code` doesn't narrow a class type to its subclass in TypeScript. + // Order-independent, since these are leaves with no subtype relation. if (err instanceof StackValidationError) { - return { - status: WIRE_ERROR_STATUS.validation, - body: { error: { code: 'validation', message: err.message, details: err.errors } }, - }; - } - if (err instanceof StackPermissionError) { - return { - status: WIRE_ERROR_STATUS.permission, - body: { error: { code: 'permission', message: err.message } }, - }; - } - if (err instanceof StackNotFoundError) { - return { - status: WIRE_ERROR_STATUS.not_found, - body: { error: { code: 'not_found', message: err.message } }, - }; - } - if (err instanceof StackVersionConflictError) { - return { - status: WIRE_ERROR_STATUS.version_conflict, - body: { - error: { - code: 'version_conflict', - message: err.message, - versionConflict: { - recordId: err.recordId, - expectedVersion: err.expectedVersion, - actualVersion: err.actualVersion, - }, - }, - }, - }; - } - if (err instanceof StackConflictError) { - return { - status: WIRE_ERROR_STATUS.conflict, - body: { error: { code: 'conflict', message: err.message } }, - }; - } - if (err instanceof StackQueryError) { - return { - status: WIRE_ERROR_STATUS.bad_request, - body: { error: { code: 'bad_request', message: err.message } }, - }; - } - if (err instanceof StackMigrationError) { - return { - status: WIRE_ERROR_STATUS.migration, - body: { error: { code: 'migration', message: err.message } }, - }; - } - if (err instanceof StackSchemaDriftError) { - return { - status: WIRE_ERROR_STATUS.schema_drift, - body: { - error: { - code: 'schema_drift', - message: err.message, - schemaDrift: { typeId: err.typeId, violations: err.violations }, - }, - }, - }; - } - if (err instanceof StackPayloadTooLargeError) { - return { - status: WIRE_ERROR_STATUS.payload_too_large, - body: { error: { code: 'payload_too_large', message: err.message } }, + error.details = err.errors; + } else if (err instanceof StackVersionConflictError) { + error.versionConflict = { + recordId: err.recordId, + expectedVersion: err.expectedVersion, + actualVersion: err.actualVersion, }; + } else if (err instanceof StackSchemaDriftError) { + error.schemaDrift = { typeId: err.typeId, violations: err.violations }; } - return null; + return { status: WIRE_ERROR_STATUS[err.code], body: { error } }; } /** Reconstruct the core error a WireError body describes. */ diff --git a/packages/wire-types/tests/errors.test.ts b/packages/wire-types/tests/errors.test.ts new file mode 100644 index 0000000..4b2a8dc --- /dev/null +++ b/packages/wire-types/tests/errors.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + StackError, + StackValidationError, + StackMigrationError, + StackPermissionError, + StackNotFoundError, + StackConflictError, + StackVersionConflictError, + StackQueryError, + StackSchemaDriftError, + StackPayloadTooLargeError, +} from '@haverstack/core'; +import { + serializeError, + deserializeError, + errorForStatus, + isWireError, + WIRE_ERROR_STATUS, +} from '../src/index.js'; + +/** One instance per taxonomy member, with the class its code must rebuild. */ +const everyStackError = [ + new StackValidationError([{ path: 'title', message: 'expected string' }]), + new StackMigrationError('no migration path from note@1 to note@3'), + new StackPermissionError(), + new StackNotFoundError('Record "1hk153x0a00b" not found.'), + new StackConflictError('Attachment is still referenced.'), + new StackVersionConflictError('Version mismatch.', '1hk153x0a00b', 3, 5), + new StackQueryError('Undecodable pagination cursor.'), + new StackSchemaDriftError('note@1', [{ path: 'title', message: 'type changed' }]), + new StackPayloadTooLargeError('Attachment exceeds the 50000000-byte limit.'), +]; + +describe('serializeError', () => { + it('serializes every StackError to its declared code and status', () => { + for (const err of everyStackError) { + const wire = serializeError(err); + expect(wire, err.name).not.toBeNull(); + expect(wire!.body.error.code, err.name).toBe(err.code); + expect(wire!.status, err.name).toBe(WIRE_ERROR_STATUS[err.code]); + expect(wire!.body.error.message, err.name).toBe(err.message); + expect(isWireError(wire!.body), err.name).toBe(true); + } + }); + + it('returns null for errors outside the taxonomy', () => { + expect(serializeError(new Error('ordinary bug'))).toBeNull(); + expect(serializeError(new TypeError('undefined is not a function'))).toBeNull(); + expect(serializeError('not an error at all')).toBeNull(); + expect(serializeError(undefined)).toBeNull(); + }); + + it('keeps version_conflict at 412, distinct from conflict at 409', () => { + const conflict = serializeError(new StackConflictError('blocked'))!; + const version = serializeError( + new StackVersionConflictError('mismatch', '1hk153x0a00b', 3, 5), + )!; + expect(conflict.status).toBe(409); + expect(version.status).toBe(412); + expect(version.body.error.versionConflict).toEqual({ + recordId: '1hk153x0a00b', + expectedVersion: 3, + actualVersion: 5, + }); + }); + + it('carries structured payload only for the codes that define it', () => { + const validation = serializeError( + new StackValidationError([{ path: 'title', message: 'expected string' }]), + )!; + expect(validation.body.error.details).toEqual([{ path: 'title', message: 'expected string' }]); + expect(validation.body.error.versionConflict).toBeUndefined(); + expect(validation.body.error.schemaDrift).toBeUndefined(); + + const drift = serializeError( + new StackSchemaDriftError('note@1', [{ path: 'title', message: 'type changed' }]), + )!; + expect(drift.body.error.schemaDrift).toEqual({ + typeId: 'note@1', + violations: [{ path: 'title', message: 'type changed' }], + }); + expect(drift.body.error.details).toBeUndefined(); + + const permission = serializeError(new StackPermissionError())!; + expect(permission.body.error.details).toBeUndefined(); + expect(permission.body.error.versionConflict).toBeUndefined(); + expect(permission.body.error.schemaDrift).toBeUndefined(); + }); +}); + +describe('error round trip', () => { + it('rebuilds the same class and code for every taxonomy member', () => { + for (const err of everyStackError) { + const rebuilt = deserializeError(serializeError(err)!.body); + expect(rebuilt.constructor, err.name).toBe(err.constructor); + expect(rebuilt, err.name).toBeInstanceOf(StackError); + expect((rebuilt as StackError).code, err.name).toBe(err.code); + } + }); + + it('preserves the fields an ifVersion retry loop needs', () => { + const rebuilt = deserializeError( + serializeError(new StackVersionConflictError('mismatch', '1hk153x0a00b', 3, 5))!.body, + ); + expect(rebuilt).toBeInstanceOf(StackVersionConflictError); + const conflict = rebuilt as StackVersionConflictError; + expect(conflict.recordId).toBe('1hk153x0a00b'); + expect(conflict.expectedVersion).toBe(3); + expect(conflict.actualVersion).toBe(5); + }); +}); + +describe('errorForStatus', () => { + it('recovers a typed error from the unambiguous statuses', () => { + expect(errorForStatus(403, 'nope')).toBeInstanceOf(StackPermissionError); + expect(errorForStatus(404, 'nope')).toBeInstanceOf(StackNotFoundError); + expect(errorForStatus(412, 'nope')).toBeInstanceOf(StackVersionConflictError); + expect(errorForStatus(413, 'nope')).toBeInstanceOf(StackPayloadTooLargeError); + expect(errorForStatus(422, 'nope')).toBeInstanceOf(StackValidationError); + expect(errorForStatus(400, 'nope')).toBeInstanceOf(StackQueryError); + }); + + it('degrades a bodyless 409 to the generic conflict class', () => { + const err = errorForStatus(409, 'nope'); + expect(err).toBeInstanceOf(StackConflictError); + expect(err).not.toBeInstanceOf(StackSchemaDriftError); + }); + + it('refuses to infer a migration error from a bare 500', () => { + expect(errorForStatus(500, 'nope')).toBeNull(); + }); +}); diff --git a/packages/wire-types/tsconfig.build.json b/packages/wire-types/tsconfig.build.json index 299c632..57d0596 100644 --- a/packages/wire-types/tsconfig.build.json +++ b/packages/wire-types/tsconfig.build.json @@ -3,5 +3,6 @@ "compilerOptions": { "rootDir": "src", "noEmit": false - } + }, + "exclude": ["tests/**/*.ts"] } diff --git a/packages/wire-types/tsconfig.json b/packages/wire-types/tsconfig.json index fda526d..dbd41f4 100644 --- a/packages/wire-types/tsconfig.json +++ b/packages/wire-types/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist", "noEmit": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/packages/wire-types/vitest.config.ts b/packages/wire-types/vitest.config.ts new file mode 100644 index 0000000..e0ec2fb --- /dev/null +++ b/packages/wire-types/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + }, + }, + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e0f755..ea68072 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: typescript: specifier: ^5.5.0 version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.17) packages: