@kognitivedev/shared 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/__tests__/shared.test.d.ts +1 -0
- package/dist/__tests__/shared.test.js +200 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.js +41 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +67 -0
- package/dist/events.d.ts +34 -0
- package/dist/events.js +58 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +36 -0
- package/dist/logger.d.ts +30 -0
- package/dist/logger.js +34 -0
- package/dist/streaming.d.ts +35 -0
- package/dist/streaming.js +67 -0
- package/dist/telemetry.d.ts +40 -0
- package/dist/telemetry.js +97 -0
- package/dist/template.d.ts +2 -0
- package/dist/template.js +13 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +16 -0
- package/dist/utils/id.d.ts +4 -0
- package/dist/utils/id.js +10 -0
- package/dist/utils/retry.d.ts +27 -0
- package/dist/utils/retry.js +36 -0
- package/package.json +39 -0
- package/src/__tests__/shared.test.ts +249 -0
- package/src/cache.ts +49 -0
- package/src/errors.ts +69 -0
- package/src/events.ts +79 -0
- package/src/handlebars.d.ts +4 -0
- package/src/index.ts +41 -0
- package/src/logger.ts +40 -0
- package/src/streaming.ts +78 -0
- package/src/telemetry.ts +117 -0
- package/src/template.ts +10 -0
- package/src/types.ts +43 -0
- package/src/utils/id.ts +8 -0
- package/src/utils/retry.ts +66 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Multi-mode streaming protocol for agents and workflows.
|
|
4
|
+
*
|
|
5
|
+
* Inspired by LangGraph's 5 streaming modes:
|
|
6
|
+
* - values: Full state snapshot after every step
|
|
7
|
+
* - updates: State deltas only (what changed)
|
|
8
|
+
* - messages: Token-by-token LLM output
|
|
9
|
+
* - custom: Application-specific events from user code (via emit())
|
|
10
|
+
* - debug: Full execution trace with node entry/exit, tool I/O
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.parseStreamModes = parseStreamModes;
|
|
14
|
+
exports.createSSEEncoder = createSSEEncoder;
|
|
15
|
+
exports.sseHeaders = sseHeaders;
|
|
16
|
+
const VALID_MODES = new Set(["values", "updates", "messages", "custom", "debug"]);
|
|
17
|
+
/**
|
|
18
|
+
* Parse and validate stream modes from a comma-separated string or array.
|
|
19
|
+
* Returns ["messages"] if input is empty/undefined (default mode).
|
|
20
|
+
*/
|
|
21
|
+
function parseStreamModes(input) {
|
|
22
|
+
if (!input)
|
|
23
|
+
return ["messages"];
|
|
24
|
+
const raw = Array.isArray(input) ? input : input.split(",").map(s => s.trim());
|
|
25
|
+
const modes = [];
|
|
26
|
+
for (const mode of raw) {
|
|
27
|
+
if (!VALID_MODES.has(mode)) {
|
|
28
|
+
throw new Error(`Invalid stream mode: "${mode}". Valid modes: ${[...VALID_MODES].join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
if (!modes.includes(mode)) {
|
|
31
|
+
modes.push(mode);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return modes.length > 0 ? modes : ["messages"];
|
|
35
|
+
}
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
/**
|
|
38
|
+
* TransformStream that encodes StreamEvents into SSE wire format.
|
|
39
|
+
*
|
|
40
|
+
* Output format per event:
|
|
41
|
+
* event: <event>\n
|
|
42
|
+
* data: <JSON>\n
|
|
43
|
+
* \n
|
|
44
|
+
*/
|
|
45
|
+
function createSSEEncoder() {
|
|
46
|
+
return new TransformStream({
|
|
47
|
+
transform(event, controller) {
|
|
48
|
+
const lines = [
|
|
49
|
+
`event: ${event.event}`,
|
|
50
|
+
`data: ${JSON.stringify(event.data)}`,
|
|
51
|
+
"",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n");
|
|
54
|
+
controller.enqueue(encoder.encode(lines));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Create SSE Response headers.
|
|
60
|
+
*/
|
|
61
|
+
function sseHeaders() {
|
|
62
|
+
return {
|
|
63
|
+
"Content-Type": "text/event-stream",
|
|
64
|
+
"Cache-Control": "no-cache",
|
|
65
|
+
"Connection": "keep-alive",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight OpenTelemetry wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Uses @opentelemetry/api as an optional peer dependency.
|
|
5
|
+
* If not installed, all functions are no-ops — zero overhead.
|
|
6
|
+
* Users bring their own OTEL SDK and exporters.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { withSpan } from '@kognitivedev/shared';
|
|
11
|
+
*
|
|
12
|
+
* const result = await withSpan('agent.generate', async (span) => {
|
|
13
|
+
* span.setAttribute('agent.name', 'support');
|
|
14
|
+
* return await generateText({ ... });
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
interface SpanLike {
|
|
19
|
+
setAttribute(key: string, value: string | number | boolean): void;
|
|
20
|
+
setStatus(status: {
|
|
21
|
+
code: number;
|
|
22
|
+
message?: string;
|
|
23
|
+
}): void;
|
|
24
|
+
end(): void;
|
|
25
|
+
isRecording(): boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Create a span. Returns a no-op span if OTEL is not configured.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createSpan(name: string, attributes?: Record<string, string | number | boolean>): SpanLike;
|
|
31
|
+
/**
|
|
32
|
+
* End a span, optionally recording an error.
|
|
33
|
+
*/
|
|
34
|
+
export declare function endSpan(span: SpanLike, error?: Error): void;
|
|
35
|
+
/**
|
|
36
|
+
* Execute a function within a traced span.
|
|
37
|
+
* If OTEL is not configured, runs the function directly with no overhead.
|
|
38
|
+
*/
|
|
39
|
+
export declare function withSpan<T>(name: string, fn: (span: SpanLike) => Promise<T>, attributes?: Record<string, string | number | boolean>): Promise<T>;
|
|
40
|
+
export type { SpanLike };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight OpenTelemetry wrapper.
|
|
4
|
+
*
|
|
5
|
+
* Uses @opentelemetry/api as an optional peer dependency.
|
|
6
|
+
* If not installed, all functions are no-ops — zero overhead.
|
|
7
|
+
* Users bring their own OTEL SDK and exporters.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { withSpan } from '@kognitivedev/shared';
|
|
12
|
+
*
|
|
13
|
+
* const result = await withSpan('agent.generate', async (span) => {
|
|
14
|
+
* span.setAttribute('agent.name', 'support');
|
|
15
|
+
* return await generateText({ ... });
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.createSpan = createSpan;
|
|
21
|
+
exports.endSpan = endSpan;
|
|
22
|
+
exports.withSpan = withSpan;
|
|
23
|
+
// Lazy-loaded OTEL references
|
|
24
|
+
let _tracer = null;
|
|
25
|
+
let _otelLoaded = false;
|
|
26
|
+
function getTracer() {
|
|
27
|
+
if (_otelLoaded)
|
|
28
|
+
return _tracer;
|
|
29
|
+
_otelLoaded = true;
|
|
30
|
+
try {
|
|
31
|
+
// Dynamic require — only succeeds if @opentelemetry/api is installed
|
|
32
|
+
const api = require("@opentelemetry/api");
|
|
33
|
+
const tracer = api.trace.getTracer("@kognitivedev/shared");
|
|
34
|
+
// Check if it's a NoopTracer (no SDK configured)
|
|
35
|
+
const testSpan = tracer.startSpan("__test__");
|
|
36
|
+
const isNoop = !testSpan.isRecording();
|
|
37
|
+
testSpan.end();
|
|
38
|
+
if (isNoop) {
|
|
39
|
+
_tracer = null;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
_tracer = tracer;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (_a) {
|
|
46
|
+
// @opentelemetry/api not installed — all no-ops
|
|
47
|
+
_tracer = null;
|
|
48
|
+
}
|
|
49
|
+
return _tracer;
|
|
50
|
+
}
|
|
51
|
+
// No-op span for when OTEL is not available
|
|
52
|
+
const noopSpan = {
|
|
53
|
+
setAttribute: () => { },
|
|
54
|
+
setStatus: () => { },
|
|
55
|
+
end: () => { },
|
|
56
|
+
isRecording: () => false,
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Create a span. Returns a no-op span if OTEL is not configured.
|
|
60
|
+
*/
|
|
61
|
+
function createSpan(name, attributes) {
|
|
62
|
+
const tracer = getTracer();
|
|
63
|
+
if (!tracer)
|
|
64
|
+
return noopSpan;
|
|
65
|
+
const span = tracer.startSpan(name);
|
|
66
|
+
if (attributes) {
|
|
67
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
68
|
+
span.setAttribute(key, value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return span;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* End a span, optionally recording an error.
|
|
75
|
+
*/
|
|
76
|
+
function endSpan(span, error) {
|
|
77
|
+
if (error) {
|
|
78
|
+
span.setStatus({ code: 2, message: error.message }); // SpanStatusCode.ERROR = 2
|
|
79
|
+
}
|
|
80
|
+
span.end();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Execute a function within a traced span.
|
|
84
|
+
* If OTEL is not configured, runs the function directly with no overhead.
|
|
85
|
+
*/
|
|
86
|
+
async function withSpan(name, fn, attributes) {
|
|
87
|
+
const span = createSpan(name, attributes);
|
|
88
|
+
try {
|
|
89
|
+
const result = await fn(span);
|
|
90
|
+
span.end();
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
endSpan(span, error);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderTemplate = renderTemplate;
|
|
7
|
+
// Use the pre-built dist to avoid `require.extensions` warning in webpack/Next.js
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
|
+
const handlebars_1 = __importDefault(require("handlebars/dist/cjs/handlebars"));
|
|
10
|
+
function renderTemplate(template, variables) {
|
|
11
|
+
const compiled = handlebars_1.default.compile(template, { noEscape: true });
|
|
12
|
+
return compiled(variables);
|
|
13
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified resource identifier for multi-tenant operations.
|
|
3
|
+
* Used across all packages to identify the scope of operations.
|
|
4
|
+
*/
|
|
5
|
+
export interface ResourceId {
|
|
6
|
+
organizationId?: string;
|
|
7
|
+
projectId: string;
|
|
8
|
+
userId?: string;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Generic result type for operations that can succeed or fail.
|
|
13
|
+
*/
|
|
14
|
+
export type Result<T, E = Error> = {
|
|
15
|
+
ok: true;
|
|
16
|
+
value: T;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
error: E;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Helper to create a successful result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ok<T>(value: T): Result<T, never>;
|
|
25
|
+
/**
|
|
26
|
+
* Helper to create a failed result.
|
|
27
|
+
*/
|
|
28
|
+
export declare function err<E = Error>(error: E): Result<never, E>;
|
|
29
|
+
/**
|
|
30
|
+
* Generic metadata record.
|
|
31
|
+
*/
|
|
32
|
+
export type Metadata = Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Disposable resource interface for cleanup.
|
|
35
|
+
*/
|
|
36
|
+
export interface Disposable {
|
|
37
|
+
dispose(): Promise<void>;
|
|
38
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ok = ok;
|
|
4
|
+
exports.err = err;
|
|
5
|
+
/**
|
|
6
|
+
* Helper to create a successful result.
|
|
7
|
+
*/
|
|
8
|
+
function ok(value) {
|
|
9
|
+
return { ok: true, value };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Helper to create a failed result.
|
|
13
|
+
*/
|
|
14
|
+
function err(error) {
|
|
15
|
+
return { ok: false, error };
|
|
16
|
+
}
|
package/dist/utils/id.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateId = generateId;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
/**
|
|
6
|
+
* Generate a unique ID using crypto.randomUUID.
|
|
7
|
+
*/
|
|
8
|
+
function generateId() {
|
|
9
|
+
return (0, crypto_1.randomUUID)();
|
|
10
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Logger } from "../logger";
|
|
2
|
+
export interface RetryAttempt {
|
|
3
|
+
attempt: number;
|
|
4
|
+
error: {
|
|
5
|
+
message: string;
|
|
6
|
+
type: string;
|
|
7
|
+
};
|
|
8
|
+
delayMs: number;
|
|
9
|
+
timestamp: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RetryMetadata {
|
|
12
|
+
totalAttempts: number;
|
|
13
|
+
attempts: RetryAttempt[];
|
|
14
|
+
}
|
|
15
|
+
export interface RetryOptions {
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
baseDelayMs?: number;
|
|
18
|
+
operationName?: string;
|
|
19
|
+
logger?: Logger;
|
|
20
|
+
/** When provided, retry attempt details are pushed into this object. */
|
|
21
|
+
collectRetryMetadata?: RetryMetadata;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Execute a function with exponential backoff retry.
|
|
25
|
+
* Extracted from EnhancedMemoryOrchestrator for shared use.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withRetry = withRetry;
|
|
4
|
+
/**
|
|
5
|
+
* Execute a function with exponential backoff retry.
|
|
6
|
+
* Extracted from EnhancedMemoryOrchestrator for shared use.
|
|
7
|
+
*/
|
|
8
|
+
async function withRetry(fn, options = {}) {
|
|
9
|
+
var _a, _b;
|
|
10
|
+
const { maxRetries = 3, baseDelayMs = 100, operationName = "operation", logger, collectRetryMetadata, } = options;
|
|
11
|
+
let lastError = null;
|
|
12
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
lastError = err;
|
|
18
|
+
const delay = Math.pow(2, attempt) * baseDelayMs;
|
|
19
|
+
if (collectRetryMetadata) {
|
|
20
|
+
collectRetryMetadata.totalAttempts = attempt;
|
|
21
|
+
collectRetryMetadata.attempts.push({
|
|
22
|
+
attempt,
|
|
23
|
+
error: { message: lastError.message, type: (_b = (_a = lastError.constructor) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : "Error" },
|
|
24
|
+
delayMs: delay,
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (attempt < maxRetries) {
|
|
29
|
+
logger === null || logger === void 0 ? void 0 : logger.warn(`Retry ${attempt}/${maxRetries} for ${operationName}`, { delay });
|
|
30
|
+
await new Promise((res) => setTimeout(res, delay));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
logger === null || logger === void 0 ? void 0 : logger.error(`Operation ${operationName} failed after ${maxRetries} retries`, lastError);
|
|
35
|
+
throw lastError;
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kognitivedev/shared",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc -w",
|
|
12
|
+
"prepublishOnly": "npm run build",
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"handlebars": "^4.7.8"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.0.0",
|
|
20
|
+
"@types/node": "^20.0.0",
|
|
21
|
+
"vitest": "^3.0.0"
|
|
22
|
+
},
|
|
23
|
+
"description": "Foundation utilities for the Kognitive AI framework",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"kognitive",
|
|
26
|
+
"ai",
|
|
27
|
+
"utilities",
|
|
28
|
+
"events",
|
|
29
|
+
"cache",
|
|
30
|
+
"logger"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/kognitivedev/kognitive",
|
|
36
|
+
"directory": "packages/shared"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://kognitive.dev"
|
|
39
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
InMemoryEventBus,
|
|
4
|
+
InMemoryCacheAdapter,
|
|
5
|
+
ConsoleLogger,
|
|
6
|
+
NoopLogger,
|
|
7
|
+
withRetry,
|
|
8
|
+
generateId,
|
|
9
|
+
ok,
|
|
10
|
+
err,
|
|
11
|
+
KognitiveError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
TimeoutError,
|
|
15
|
+
ApprovalDeniedError,
|
|
16
|
+
RetryableError,
|
|
17
|
+
} from "../index";
|
|
18
|
+
|
|
19
|
+
// ─── EventBus ───
|
|
20
|
+
|
|
21
|
+
describe("InMemoryEventBus", () => {
|
|
22
|
+
let bus: InMemoryEventBus;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
bus = new InMemoryEventBus();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("emits events to registered handlers", () => {
|
|
29
|
+
const handler = vi.fn();
|
|
30
|
+
bus.on("test", handler);
|
|
31
|
+
bus.emit("test", { value: 42 });
|
|
32
|
+
expect(handler).toHaveBeenCalledWith({ value: 42 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("supports multiple handlers for same event", () => {
|
|
36
|
+
const h1 = vi.fn();
|
|
37
|
+
const h2 = vi.fn();
|
|
38
|
+
bus.on("evt", h1);
|
|
39
|
+
bus.on("evt", h2);
|
|
40
|
+
bus.emit("evt", "data");
|
|
41
|
+
expect(h1).toHaveBeenCalledWith("data");
|
|
42
|
+
expect(h2).toHaveBeenCalledWith("data");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("unsubscribes via returned function", () => {
|
|
46
|
+
const handler = vi.fn();
|
|
47
|
+
const unsub = bus.on("evt", handler);
|
|
48
|
+
unsub();
|
|
49
|
+
bus.emit("evt", "data");
|
|
50
|
+
expect(handler).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("once fires handler only once", () => {
|
|
54
|
+
const handler = vi.fn();
|
|
55
|
+
bus.once("evt", handler);
|
|
56
|
+
bus.emit("evt", 1);
|
|
57
|
+
bus.emit("evt", 2);
|
|
58
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(handler).toHaveBeenCalledWith(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("removeAll clears specific event", () => {
|
|
63
|
+
const h1 = vi.fn();
|
|
64
|
+
const h2 = vi.fn();
|
|
65
|
+
bus.on("a", h1);
|
|
66
|
+
bus.on("b", h2);
|
|
67
|
+
bus.removeAll("a");
|
|
68
|
+
bus.emit("a", 1);
|
|
69
|
+
bus.emit("b", 2);
|
|
70
|
+
expect(h1).not.toHaveBeenCalled();
|
|
71
|
+
expect(h2).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("removeAll with no arg clears all events", () => {
|
|
75
|
+
const h1 = vi.fn();
|
|
76
|
+
const h2 = vi.fn();
|
|
77
|
+
bus.on("a", h1);
|
|
78
|
+
bus.on("b", h2);
|
|
79
|
+
bus.removeAll();
|
|
80
|
+
bus.emit("a", 1);
|
|
81
|
+
bus.emit("b", 2);
|
|
82
|
+
expect(h1).not.toHaveBeenCalled();
|
|
83
|
+
expect(h2).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not throw on emit with no handlers", () => {
|
|
87
|
+
expect(() => bus.emit("nonexistent", {})).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── Cache ───
|
|
92
|
+
|
|
93
|
+
describe("InMemoryCacheAdapter", () => {
|
|
94
|
+
let cache: InMemoryCacheAdapter;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
cache = new InMemoryCacheAdapter();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("get returns null for missing key", async () => {
|
|
101
|
+
expect(await cache.get("missing")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("set and get round-trips", async () => {
|
|
105
|
+
await cache.set("key", { value: 42 });
|
|
106
|
+
expect(await cache.get("key")).toEqual({ value: 42 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("delete removes entry", async () => {
|
|
110
|
+
await cache.set("key", "value");
|
|
111
|
+
await cache.delete("key");
|
|
112
|
+
expect(await cache.get("key")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("TTL expires entries", async () => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
await cache.set("key", "value", 1); // 1 second TTL
|
|
118
|
+
expect(await cache.get("key")).toBe("value");
|
|
119
|
+
vi.advanceTimersByTime(2000);
|
|
120
|
+
expect(await cache.get("key")).toBeNull();
|
|
121
|
+
vi.useRealTimers();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("reports size", async () => {
|
|
125
|
+
await cache.set("a", 1);
|
|
126
|
+
await cache.set("b", 2);
|
|
127
|
+
expect(cache.size).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("clear removes all entries", async () => {
|
|
131
|
+
await cache.set("a", 1);
|
|
132
|
+
await cache.set("b", 2);
|
|
133
|
+
cache.clear();
|
|
134
|
+
expect(cache.size).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ─── withRetry ───
|
|
139
|
+
|
|
140
|
+
describe("withRetry", () => {
|
|
141
|
+
it("succeeds on first try", async () => {
|
|
142
|
+
const fn = vi.fn().mockResolvedValue("ok");
|
|
143
|
+
const result = await withRetry(fn, { maxRetries: 3 });
|
|
144
|
+
expect(result).toBe("ok");
|
|
145
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("retries and succeeds on second attempt", async () => {
|
|
149
|
+
const fn = vi.fn()
|
|
150
|
+
.mockRejectedValueOnce(new Error("fail"))
|
|
151
|
+
.mockResolvedValue("ok");
|
|
152
|
+
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 1 });
|
|
153
|
+
expect(result).toBe("ok");
|
|
154
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throws after max retries", async () => {
|
|
158
|
+
const fn = vi.fn().mockRejectedValue(new Error("always fails"));
|
|
159
|
+
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow("always fails");
|
|
160
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ─── Utilities ───
|
|
165
|
+
|
|
166
|
+
describe("generateId", () => {
|
|
167
|
+
it("returns a UUID string", () => {
|
|
168
|
+
const id = generateId();
|
|
169
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("generates unique IDs", () => {
|
|
173
|
+
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
|
174
|
+
expect(ids.size).toBe(100);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("Result helpers", () => {
|
|
179
|
+
it("ok creates success result", () => {
|
|
180
|
+
const result = ok(42);
|
|
181
|
+
expect(result.ok).toBe(true);
|
|
182
|
+
if (result.ok) expect(result.value).toBe(42);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("err creates failure result", () => {
|
|
186
|
+
const result = err(new Error("fail"));
|
|
187
|
+
expect(result.ok).toBe(false);
|
|
188
|
+
if (!result.ok) expect(result.error.message).toBe("fail");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ─── Error Types ───
|
|
193
|
+
|
|
194
|
+
describe("Error types", () => {
|
|
195
|
+
it("KognitiveError has code and name", () => {
|
|
196
|
+
const e = new KognitiveError("msg", "CODE");
|
|
197
|
+
expect(e.name).toBe("KognitiveError");
|
|
198
|
+
expect(e.code).toBe("CODE");
|
|
199
|
+
expect(e.message).toBe("msg");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("ValidationError has field", () => {
|
|
203
|
+
const e = new ValidationError("bad input", "email");
|
|
204
|
+
expect(e.name).toBe("ValidationError");
|
|
205
|
+
expect(e.field).toBe("email");
|
|
206
|
+
expect(e.code).toBe("VALIDATION");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("NotFoundError formats message", () => {
|
|
210
|
+
const e = new NotFoundError("User", "123");
|
|
211
|
+
expect(e.message).toBe("User not found: 123");
|
|
212
|
+
expect(e.code).toBe("NOT_FOUND");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("TimeoutError has timeoutMs", () => {
|
|
216
|
+
const e = new TimeoutError("timed out", 5000);
|
|
217
|
+
expect(e.timeoutMs).toBe(5000);
|
|
218
|
+
expect(e.code).toBe("TIMEOUT");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("ApprovalDeniedError has tool info", () => {
|
|
222
|
+
const e = new ApprovalDeniedError("search");
|
|
223
|
+
expect(e.message).toContain("search");
|
|
224
|
+
expect(e.code).toBe("APPROVAL_DENIED");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("RetryableError is retryable", () => {
|
|
228
|
+
const e = new RetryableError("transient");
|
|
229
|
+
expect(e.code).toBe("RETRYABLE");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─── Logger ───
|
|
234
|
+
|
|
235
|
+
describe("Loggers", () => {
|
|
236
|
+
it("ConsoleLogger logs without throwing", () => {
|
|
237
|
+
const logger = new ConsoleLogger("[Test]");
|
|
238
|
+
expect(() => logger.debug("test")).not.toThrow();
|
|
239
|
+
expect(() => logger.info("test")).not.toThrow();
|
|
240
|
+
expect(() => logger.warn("test")).not.toThrow();
|
|
241
|
+
expect(() => logger.error("test", new Error("e"))).not.toThrow();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("NoopLogger does nothing", () => {
|
|
245
|
+
const logger = new NoopLogger();
|
|
246
|
+
expect(() => logger.debug("test")).not.toThrow();
|
|
247
|
+
expect(() => logger.info("test")).not.toThrow();
|
|
248
|
+
});
|
|
249
|
+
});
|