te.js 2.1.5 → 2.2.0
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/auto-docs/analysis/handler-analyzer.test.js +106 -0
- package/auto-docs/analysis/source-resolver.test.js +58 -0
- package/auto-docs/constants.js +13 -2
- package/auto-docs/openapi/generator.js +7 -5
- package/auto-docs/openapi/generator.test.js +132 -0
- package/auto-docs/openapi/spec-builders.js +39 -19
- package/cli/docs-command.js +44 -36
- package/cors/index.test.js +82 -0
- package/database/index.js +3 -1
- package/database/mongodb.js +17 -11
- package/database/redis.js +53 -44
- package/docs/configuration.md +24 -10
- package/docs/error-handling.md +134 -50
- package/lib/llm/client.js +40 -10
- package/lib/llm/index.js +14 -1
- package/lib/llm/parse.test.js +60 -0
- package/package.json +3 -1
- package/radar/index.js +281 -0
- package/rate-limit/index.js +8 -11
- package/rate-limit/index.test.js +64 -0
- package/server/ammo/body-parser.js +156 -152
- package/server/ammo/body-parser.test.js +79 -0
- package/server/ammo/enhancer.js +8 -4
- package/server/ammo.js +216 -17
- package/server/context/request-context.js +51 -0
- package/server/context/request-context.test.js +53 -0
- package/server/endpoint.js +15 -0
- package/server/error.js +56 -3
- package/server/error.test.js +45 -0
- package/server/errors/channels/base.js +31 -0
- package/server/errors/channels/channels.test.js +148 -0
- package/server/errors/channels/console.js +64 -0
- package/server/errors/channels/index.js +111 -0
- package/server/errors/channels/log.js +27 -0
- package/server/errors/llm-cache.js +102 -0
- package/server/errors/llm-cache.test.js +160 -0
- package/server/errors/llm-error-service.js +77 -16
- package/server/errors/llm-rate-limiter.js +72 -0
- package/server/errors/llm-rate-limiter.test.js +105 -0
- package/server/files/uploader.js +38 -26
- package/server/handler.js +5 -3
- package/server/targets/registry.js +9 -9
- package/server/targets/registry.test.js +108 -0
- package/te.js +214 -57
- package/utils/auto-register.js +1 -1
- package/utils/configuration.js +23 -9
- package/utils/configuration.test.js +58 -0
- package/utils/errors-llm-config.js +142 -9
- package/utils/request-logger.js +49 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { getChannels, buildPayload } from './index.js';
|
|
3
|
+
import { ConsoleChannel } from './console.js';
|
|
4
|
+
import { LogChannel } from './log.js';
|
|
5
|
+
|
|
6
|
+
describe('getChannels()', () => {
|
|
7
|
+
it('returns a ConsoleChannel for "console"', () => {
|
|
8
|
+
const channels = getChannels('console', './test.log');
|
|
9
|
+
expect(channels).toHaveLength(1);
|
|
10
|
+
expect(channels[0]).toBeInstanceOf(ConsoleChannel);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns a LogChannel for "log"', () => {
|
|
14
|
+
const channels = getChannels('log', './test.log');
|
|
15
|
+
expect(channels).toHaveLength(1);
|
|
16
|
+
expect(channels[0]).toBeInstanceOf(LogChannel);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns both channels for "both"', () => {
|
|
20
|
+
const channels = getChannels('both', './test.log');
|
|
21
|
+
expect(channels).toHaveLength(2);
|
|
22
|
+
const types = channels.map((c) => c.constructor.name);
|
|
23
|
+
expect(types).toContain('ConsoleChannel');
|
|
24
|
+
expect(types).toContain('LogChannel');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('defaults to ConsoleChannel for unknown values', () => {
|
|
28
|
+
const channels = getChannels('unknown', './test.log');
|
|
29
|
+
expect(channels).toHaveLength(1);
|
|
30
|
+
expect(channels[0]).toBeInstanceOf(ConsoleChannel);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns same ConsoleChannel singleton across calls', () => {
|
|
34
|
+
const a = getChannels('console', './test.log')[0];
|
|
35
|
+
const b = getChannels('console', './test.log')[0];
|
|
36
|
+
expect(a).toBe(b);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('LogChannel uses the provided logFile path', () => {
|
|
40
|
+
const channels = getChannels('log', './my-errors.log');
|
|
41
|
+
expect(channels[0].logFile).toBe('./my-errors.log');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('buildPayload()', () => {
|
|
46
|
+
const codeContext = {
|
|
47
|
+
snippets: [
|
|
48
|
+
{ file: '/app/handler.js', line: 10, snippet: '→ ammo.throw()' },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
it('builds a complete payload', () => {
|
|
53
|
+
const payload = buildPayload({
|
|
54
|
+
method: 'POST',
|
|
55
|
+
path: '/users',
|
|
56
|
+
originalError: new Error('DB error'),
|
|
57
|
+
codeContext,
|
|
58
|
+
statusCode: 500,
|
|
59
|
+
message: 'Internal Server Error',
|
|
60
|
+
devInsight: 'DB connection may be down.',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(payload.method).toBe('POST');
|
|
64
|
+
expect(payload.path).toBe('/users');
|
|
65
|
+
expect(payload.statusCode).toBe(500);
|
|
66
|
+
expect(payload.message).toBe('Internal Server Error');
|
|
67
|
+
expect(payload.devInsight).toBe('DB connection may be down.');
|
|
68
|
+
expect(payload.error).toEqual({ type: 'Error', message: 'DB error' });
|
|
69
|
+
expect(payload.codeContext).toBe(codeContext);
|
|
70
|
+
expect(typeof payload.timestamp).toBe('string');
|
|
71
|
+
expect(() => new Date(payload.timestamp)).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles string error', () => {
|
|
75
|
+
const payload = buildPayload({
|
|
76
|
+
method: 'GET',
|
|
77
|
+
path: '/items',
|
|
78
|
+
originalError: 'Not found',
|
|
79
|
+
codeContext,
|
|
80
|
+
statusCode: 404,
|
|
81
|
+
message: 'Not found',
|
|
82
|
+
});
|
|
83
|
+
expect(payload.error).toEqual({ type: 'string', message: 'Not found' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('sets error to null when no originalError', () => {
|
|
87
|
+
const payload = buildPayload({
|
|
88
|
+
method: 'GET',
|
|
89
|
+
path: '/',
|
|
90
|
+
originalError: null,
|
|
91
|
+
codeContext,
|
|
92
|
+
statusCode: 500,
|
|
93
|
+
message: 'Error',
|
|
94
|
+
});
|
|
95
|
+
expect(payload.error).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('includes cached flag when provided', () => {
|
|
99
|
+
const payload = buildPayload({
|
|
100
|
+
method: 'GET',
|
|
101
|
+
path: '/',
|
|
102
|
+
originalError: null,
|
|
103
|
+
codeContext,
|
|
104
|
+
statusCode: 404,
|
|
105
|
+
message: 'Not found',
|
|
106
|
+
cached: true,
|
|
107
|
+
});
|
|
108
|
+
expect(payload.cached).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('includes rateLimited flag when provided', () => {
|
|
112
|
+
const payload = buildPayload({
|
|
113
|
+
method: 'GET',
|
|
114
|
+
path: '/',
|
|
115
|
+
originalError: null,
|
|
116
|
+
codeContext,
|
|
117
|
+
statusCode: 500,
|
|
118
|
+
message: 'Error',
|
|
119
|
+
rateLimited: true,
|
|
120
|
+
});
|
|
121
|
+
expect(payload.rateLimited).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('omits cached and rateLimited when not provided', () => {
|
|
125
|
+
const payload = buildPayload({
|
|
126
|
+
method: 'GET',
|
|
127
|
+
path: '/',
|
|
128
|
+
originalError: null,
|
|
129
|
+
codeContext,
|
|
130
|
+
statusCode: 200,
|
|
131
|
+
message: 'OK',
|
|
132
|
+
});
|
|
133
|
+
expect(payload).not.toHaveProperty('cached');
|
|
134
|
+
expect(payload).not.toHaveProperty('rateLimited');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('omits devInsight when not provided', () => {
|
|
138
|
+
const payload = buildPayload({
|
|
139
|
+
method: 'GET',
|
|
140
|
+
path: '/',
|
|
141
|
+
originalError: null,
|
|
142
|
+
codeContext,
|
|
143
|
+
statusCode: 500,
|
|
144
|
+
message: 'Error',
|
|
145
|
+
});
|
|
146
|
+
expect(payload).not.toHaveProperty('devInsight');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console channel: pretty-prints LLM error results to the terminal using ansi-colors.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import ansi from 'ansi-colors';
|
|
6
|
+
import { ErrorChannel } from './base.js';
|
|
7
|
+
|
|
8
|
+
const { red, yellow, cyan, white, bold, dim, italic } = ansi;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format an HTTP status code with color (red for 5xx, yellow for 4xx, white for others).
|
|
12
|
+
* @param {number} statusCode
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function colorStatus(statusCode) {
|
|
16
|
+
if (statusCode >= 500) return red(bold(String(statusCode)));
|
|
17
|
+
if (statusCode >= 400) return yellow(bold(String(statusCode)));
|
|
18
|
+
return white(bold(String(statusCode)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ConsoleChannel extends ErrorChannel {
|
|
22
|
+
async dispatch(payload) {
|
|
23
|
+
const {
|
|
24
|
+
timestamp,
|
|
25
|
+
method,
|
|
26
|
+
path,
|
|
27
|
+
statusCode,
|
|
28
|
+
message,
|
|
29
|
+
devInsight,
|
|
30
|
+
error,
|
|
31
|
+
cached,
|
|
32
|
+
rateLimited,
|
|
33
|
+
} = payload;
|
|
34
|
+
|
|
35
|
+
const time = dim(italic(new Date(timestamp).toLocaleTimeString()));
|
|
36
|
+
const route = white(`${method} ${path}`);
|
|
37
|
+
const status = colorStatus(statusCode);
|
|
38
|
+
|
|
39
|
+
const flags = [];
|
|
40
|
+
if (cached) flags.push(cyan('[CACHED]'));
|
|
41
|
+
if (rateLimited) flags.push(yellow('[RATE LIMITED]'));
|
|
42
|
+
const flagStr = flags.length ? ' ' + flags.join(' ') : '';
|
|
43
|
+
|
|
44
|
+
const lines = [
|
|
45
|
+
``,
|
|
46
|
+
`${time} ${red('[LLM ERROR]')} ${route} → ${status}${flagStr}`,
|
|
47
|
+
` ${white(message)}`,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (devInsight) {
|
|
51
|
+
lines.push(` ${cyan('⟶')} ${cyan(devInsight)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (error?.message && !rateLimited) {
|
|
55
|
+
lines.push(
|
|
56
|
+
` ${dim(`original: ${error.type ? error.type + ': ' : ''}${error.message}`)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push('');
|
|
61
|
+
|
|
62
|
+
process.stderr.write(lines.join('\n'));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel registry for LLM error output.
|
|
3
|
+
* Maps channel config values ('console' | 'log' | 'both') to channel instances.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ConsoleChannel } from './console.js';
|
|
7
|
+
import { LogChannel } from './log.js';
|
|
8
|
+
|
|
9
|
+
/** @type {ConsoleChannel|null} */
|
|
10
|
+
let _console = null;
|
|
11
|
+
|
|
12
|
+
/** @type {Map<string, LogChannel>} */
|
|
13
|
+
const _logInstances = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get (or create) the singleton ConsoleChannel.
|
|
17
|
+
* @returns {ConsoleChannel}
|
|
18
|
+
*/
|
|
19
|
+
function getConsoleChannel() {
|
|
20
|
+
if (!_console) _console = new ConsoleChannel();
|
|
21
|
+
return _console;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get (or create) a LogChannel for the given file path.
|
|
26
|
+
* @param {string} logFile
|
|
27
|
+
* @returns {LogChannel}
|
|
28
|
+
*/
|
|
29
|
+
function getLogChannel(logFile) {
|
|
30
|
+
if (!_logInstances.has(logFile)) {
|
|
31
|
+
_logInstances.set(logFile, new LogChannel(logFile));
|
|
32
|
+
}
|
|
33
|
+
return _logInstances.get(logFile);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve channel instances for the given config value and log file.
|
|
38
|
+
* @param {'console'|'log'|'both'} channel
|
|
39
|
+
* @param {string} logFile
|
|
40
|
+
* @returns {import('./base.js').ErrorChannel[]}
|
|
41
|
+
*/
|
|
42
|
+
export function getChannels(channel, logFile) {
|
|
43
|
+
switch (channel) {
|
|
44
|
+
case 'log':
|
|
45
|
+
return [getLogChannel(logFile)];
|
|
46
|
+
case 'both':
|
|
47
|
+
return [getConsoleChannel(), getLogChannel(logFile)];
|
|
48
|
+
case 'console':
|
|
49
|
+
default:
|
|
50
|
+
return [getConsoleChannel()];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the standard channel payload from available context and LLM result.
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {string} opts.method
|
|
58
|
+
* @param {string} opts.path
|
|
59
|
+
* @param {Error|string|null|undefined} opts.originalError
|
|
60
|
+
* @param {{ snippets: Array<{ file: string, line: number, snippet: string }> }} opts.codeContext
|
|
61
|
+
* @param {number} opts.statusCode
|
|
62
|
+
* @param {string} opts.message
|
|
63
|
+
* @param {string} [opts.devInsight]
|
|
64
|
+
* @param {boolean} [opts.cached]
|
|
65
|
+
* @param {boolean} [opts.rateLimited]
|
|
66
|
+
* @returns {import('./base.js').ChannelPayload}
|
|
67
|
+
*/
|
|
68
|
+
export function buildPayload({
|
|
69
|
+
method,
|
|
70
|
+
path,
|
|
71
|
+
originalError,
|
|
72
|
+
codeContext,
|
|
73
|
+
statusCode,
|
|
74
|
+
message,
|
|
75
|
+
devInsight,
|
|
76
|
+
cached,
|
|
77
|
+
rateLimited,
|
|
78
|
+
}) {
|
|
79
|
+
let errorSummary = null;
|
|
80
|
+
if (originalError != null && typeof originalError.message === 'string') {
|
|
81
|
+
errorSummary = {
|
|
82
|
+
type: originalError.constructor?.name ?? 'Error',
|
|
83
|
+
message: originalError.message ?? '',
|
|
84
|
+
};
|
|
85
|
+
} else if (originalError != null) {
|
|
86
|
+
errorSummary = { type: 'string', message: String(originalError) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
timestamp: new Date().toISOString(),
|
|
91
|
+
method: method ?? '',
|
|
92
|
+
path: path ?? '',
|
|
93
|
+
statusCode,
|
|
94
|
+
message,
|
|
95
|
+
...(devInsight != null && { devInsight }),
|
|
96
|
+
error: errorSummary,
|
|
97
|
+
codeContext: codeContext ?? { snippets: [] },
|
|
98
|
+
...(cached != null && { cached }),
|
|
99
|
+
...(rateLimited != null && { rateLimited }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Dispatch a payload to all resolved channels, swallowing individual channel errors.
|
|
105
|
+
* @param {import('./base.js').ErrorChannel[]} channels
|
|
106
|
+
* @param {import('./base.js').ChannelPayload} payload
|
|
107
|
+
* @returns {Promise<void>}
|
|
108
|
+
*/
|
|
109
|
+
export async function dispatchToChannels(channels, payload) {
|
|
110
|
+
await Promise.allSettled(channels.map((ch) => ch.dispatch(payload)));
|
|
111
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log channel: appends a full JSONL entry to a log file for each LLM error result.
|
|
3
|
+
* Each line is a self-contained JSON object with all fields for post-mortem debugging.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendFile } from 'node:fs/promises';
|
|
7
|
+
import { ErrorChannel } from './base.js';
|
|
8
|
+
|
|
9
|
+
export class LogChannel extends ErrorChannel {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} logFile - Absolute or relative path to the JSONL log file.
|
|
12
|
+
*/
|
|
13
|
+
constructor(logFile) {
|
|
14
|
+
super();
|
|
15
|
+
this.logFile = logFile;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async dispatch(payload) {
|
|
19
|
+
const line = JSON.stringify(payload) + '\n';
|
|
20
|
+
try {
|
|
21
|
+
await appendFile(this.logFile, line, 'utf-8');
|
|
22
|
+
} catch {
|
|
23
|
+
// Silently ignore write failures (e.g. permissions, disk full) so logging never
|
|
24
|
+
// crashes the process or blocks error handling.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory TTL cache for LLM error inference results.
|
|
3
|
+
* Key: file:line:errorMessage -- deduplicates repeated errors at the same throw site.
|
|
4
|
+
* Shared singleton across the process; configured from errors.llm.cacheTTL.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SWEEP_INTERVAL_MS = 5 * 60 * 1000; // prune expired entries every 5 minutes
|
|
8
|
+
|
|
9
|
+
class LLMErrorCache {
|
|
10
|
+
/**
|
|
11
|
+
* @param {number} ttl - Time-to-live in milliseconds for each cached result.
|
|
12
|
+
*/
|
|
13
|
+
constructor(ttl) {
|
|
14
|
+
this.ttl = ttl > 0 ? ttl : 3_600_000;
|
|
15
|
+
/** @type {Map<string, { statusCode: number, message: string, devInsight?: string, cachedAt: number }>} */
|
|
16
|
+
this._store = new Map();
|
|
17
|
+
this._sweepTimer =
|
|
18
|
+
setInterval(() => this._sweep(), SWEEP_INTERVAL_MS).unref?.() ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a cache key from code context and error.
|
|
23
|
+
* Uses the first (throw-site) snippet's file and line + error text.
|
|
24
|
+
* @param {{ snippets?: Array<{ file: string, line: number }> }} codeContext
|
|
25
|
+
* @param {Error|string|undefined} error
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
buildKey(codeContext, error) {
|
|
29
|
+
const snippet = codeContext?.snippets?.[0];
|
|
30
|
+
const location = snippet ? `${snippet.file}:${snippet.line}` : 'unknown';
|
|
31
|
+
let errText = '';
|
|
32
|
+
if (error != null && typeof error.message === 'string') {
|
|
33
|
+
errText = error.message ?? '';
|
|
34
|
+
} else if (error != null) {
|
|
35
|
+
errText = String(error);
|
|
36
|
+
}
|
|
37
|
+
return `${location}:${errText}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get a cached result. Returns null if missing or expired (and prunes the entry).
|
|
42
|
+
* @param {string} key
|
|
43
|
+
* @returns {{ statusCode: number, message: string, devInsight?: string } | null}
|
|
44
|
+
*/
|
|
45
|
+
get(key) {
|
|
46
|
+
const entry = this._store.get(key);
|
|
47
|
+
if (!entry) return null;
|
|
48
|
+
if (Date.now() - entry.cachedAt > this.ttl) {
|
|
49
|
+
this._store.delete(key);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const { cachedAt: _removed, ...result } = entry;
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Store a result in the cache.
|
|
58
|
+
* @param {string} key
|
|
59
|
+
* @param {{ statusCode: number, message: string, devInsight?: string }} result
|
|
60
|
+
*/
|
|
61
|
+
set(key, result) {
|
|
62
|
+
this._store.set(key, { ...result, cachedAt: Date.now() });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove all expired entries (called periodically by the sweep timer).
|
|
67
|
+
*/
|
|
68
|
+
_sweep() {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const [key, entry] of this._store) {
|
|
71
|
+
if (now - entry.cachedAt > this.ttl) {
|
|
72
|
+
this._store.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Number of entries currently in the cache (including potentially stale ones not yet swept).
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
get size() {
|
|
82
|
+
return this._store.size;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @type {LLMErrorCache|null} */
|
|
87
|
+
let _instance = null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get (or create) the singleton cache.
|
|
91
|
+
* Re-initializes if ttl changes.
|
|
92
|
+
* @param {number} ttl
|
|
93
|
+
* @returns {LLMErrorCache}
|
|
94
|
+
*/
|
|
95
|
+
export function getCache(ttl) {
|
|
96
|
+
if (!_instance || _instance.ttl !== ttl) {
|
|
97
|
+
_instance = new LLMErrorCache(ttl);
|
|
98
|
+
}
|
|
99
|
+
return _instance;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { LLMErrorCache };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { LLMErrorCache, getCache } from './llm-cache.js';
|
|
3
|
+
|
|
4
|
+
describe('LLMErrorCache', () => {
|
|
5
|
+
describe('constructor', () => {
|
|
6
|
+
it('uses provided ttl', () => {
|
|
7
|
+
const cache = new LLMErrorCache(5000);
|
|
8
|
+
expect(cache.ttl).toBe(5000);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('defaults to 3600000 for invalid ttl', () => {
|
|
12
|
+
expect(new LLMErrorCache(0).ttl).toBe(3_600_000);
|
|
13
|
+
expect(new LLMErrorCache(-1).ttl).toBe(3_600_000);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('buildKey()', () => {
|
|
18
|
+
it('builds key from first snippet file, line, and error message', () => {
|
|
19
|
+
const cache = new LLMErrorCache(1000);
|
|
20
|
+
const codeContext = {
|
|
21
|
+
snippets: [{ file: '/app/routes/users.js', line: 42 }],
|
|
22
|
+
};
|
|
23
|
+
const error = new Error('User not found');
|
|
24
|
+
const key = cache.buildKey(codeContext, error);
|
|
25
|
+
expect(key).toBe('/app/routes/users.js:42:User not found');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles string error', () => {
|
|
29
|
+
const cache = new LLMErrorCache(1000);
|
|
30
|
+
const codeContext = { snippets: [{ file: '/app/handler.js', line: 10 }] };
|
|
31
|
+
const key = cache.buildKey(codeContext, 'Validation failed');
|
|
32
|
+
expect(key).toBe('/app/handler.js:10:Validation failed');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles no error (empty string suffix)', () => {
|
|
36
|
+
const cache = new LLMErrorCache(1000);
|
|
37
|
+
const codeContext = { snippets: [{ file: '/app/handler.js', line: 5 }] };
|
|
38
|
+
const key = cache.buildKey(codeContext, undefined);
|
|
39
|
+
expect(key).toBe('/app/handler.js:5:');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('uses "unknown" when codeContext has no snippets', () => {
|
|
43
|
+
const cache = new LLMErrorCache(1000);
|
|
44
|
+
const key = cache.buildKey({ snippets: [] }, new Error('oops'));
|
|
45
|
+
expect(key).toBe('unknown:oops');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses "unknown" when codeContext is missing', () => {
|
|
49
|
+
const cache = new LLMErrorCache(1000);
|
|
50
|
+
const key = cache.buildKey(null, new Error('oops'));
|
|
51
|
+
expect(key).toBe('unknown:oops');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('set() and get()', () => {
|
|
56
|
+
it('stores and retrieves a result', () => {
|
|
57
|
+
const cache = new LLMErrorCache(10_000);
|
|
58
|
+
cache.set('key1', { statusCode: 404, message: 'Not found' });
|
|
59
|
+
const result = cache.get('key1');
|
|
60
|
+
expect(result).toEqual({ statusCode: 404, message: 'Not found' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null for missing keys', () => {
|
|
64
|
+
const cache = new LLMErrorCache(10_000);
|
|
65
|
+
expect(cache.get('nonexistent')).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not return cachedAt in the result', () => {
|
|
69
|
+
const cache = new LLMErrorCache(10_000);
|
|
70
|
+
cache.set('key1', { statusCode: 500, message: 'Error' });
|
|
71
|
+
const result = cache.get('key1');
|
|
72
|
+
expect(result).not.toHaveProperty('cachedAt');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('includes devInsight when stored', () => {
|
|
76
|
+
const cache = new LLMErrorCache(10_000);
|
|
77
|
+
cache.set('key1', {
|
|
78
|
+
statusCode: 404,
|
|
79
|
+
message: 'Not found',
|
|
80
|
+
devInsight: 'Check the ID param.',
|
|
81
|
+
});
|
|
82
|
+
const result = cache.get('key1');
|
|
83
|
+
expect(result.devInsight).toBe('Check the ID param.');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('TTL expiry', () => {
|
|
88
|
+
it('returns null for expired entries', () => {
|
|
89
|
+
vi.useFakeTimers();
|
|
90
|
+
|
|
91
|
+
const cache = new LLMErrorCache(1000);
|
|
92
|
+
cache.set('key1', { statusCode: 404, message: 'Not found' });
|
|
93
|
+
expect(cache.get('key1')).not.toBeNull();
|
|
94
|
+
|
|
95
|
+
vi.advanceTimersByTime(1001);
|
|
96
|
+
|
|
97
|
+
expect(cache.get('key1')).toBeNull();
|
|
98
|
+
|
|
99
|
+
vi.useRealTimers();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('removes expired entry from the store on access', () => {
|
|
103
|
+
vi.useFakeTimers();
|
|
104
|
+
|
|
105
|
+
const cache = new LLMErrorCache(500);
|
|
106
|
+
cache.set('key1', { statusCode: 500, message: 'Error' });
|
|
107
|
+
expect(cache.size).toBe(1);
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(600);
|
|
110
|
+
cache.get('key1');
|
|
111
|
+
|
|
112
|
+
expect(cache.size).toBe(0);
|
|
113
|
+
|
|
114
|
+
vi.useRealTimers();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('non-expired entries remain accessible', () => {
|
|
118
|
+
vi.useFakeTimers();
|
|
119
|
+
|
|
120
|
+
const cache = new LLMErrorCache(5000);
|
|
121
|
+
cache.set('key1', { statusCode: 200, message: 'OK' });
|
|
122
|
+
|
|
123
|
+
vi.advanceTimersByTime(4999);
|
|
124
|
+
|
|
125
|
+
expect(cache.get('key1')).not.toBeNull();
|
|
126
|
+
|
|
127
|
+
vi.useRealTimers();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('size', () => {
|
|
132
|
+
it('tracks the number of entries', () => {
|
|
133
|
+
const cache = new LLMErrorCache(10_000);
|
|
134
|
+
expect(cache.size).toBe(0);
|
|
135
|
+
cache.set('a', { statusCode: 200, message: 'OK' });
|
|
136
|
+
cache.set('b', { statusCode: 404, message: 'Not found' });
|
|
137
|
+
expect(cache.size).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('getCache (singleton)', () => {
|
|
143
|
+
it('returns a LLMErrorCache instance', () => {
|
|
144
|
+
const cache = getCache(3600000);
|
|
145
|
+
expect(cache).toBeInstanceOf(LLMErrorCache);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns same instance for same ttl', () => {
|
|
149
|
+
const a = getCache(3600000);
|
|
150
|
+
const b = getCache(3600000);
|
|
151
|
+
expect(a).toBe(b);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('creates a new instance when ttl changes', () => {
|
|
155
|
+
const a = getCache(1000);
|
|
156
|
+
const b = getCache(2000);
|
|
157
|
+
expect(a).not.toBe(b);
|
|
158
|
+
expect(b.ttl).toBe(2000);
|
|
159
|
+
});
|
|
160
|
+
});
|