@comprehend/telemetry-node 0.1.2 → 0.1.4
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/.claude/settings.local.json +2 -2
- package/.idea/telemetry-node.iml +1 -0
- package/DEVELOPMENT.md +69 -0
- package/README.md +127 -0
- package/dist/ComprehendDevSpanProcessor.js +1 -0
- package/dist/ComprehendDevSpanProcessor.test.d.ts +1 -0
- package/dist/ComprehendDevSpanProcessor.test.js +674 -0
- package/dist/WebSocketConnection.test.d.ts +1 -0
- package/dist/WebSocketConnection.test.js +406 -0
- package/dist/sql-analyzer.js +11 -2
- package/dist/sql-analyzer.test.js +12 -0
- package/jest.config.js +1 -0
- package/package.json +2 -2
- package/src/ComprehendDevSpanProcessor.test.ts +822 -0
- package/src/ComprehendDevSpanProcessor.ts +1 -0
- package/src/WebSocketConnection.test.ts +535 -0
- package/src/sql-analyzer.test.ts +16 -0
- package/src/sql-analyzer.ts +11 -2
|
@@ -144,6 +144,7 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
|
|
|
144
144
|
...(environment ? { environment } : {})
|
|
145
145
|
};
|
|
146
146
|
this.ingestMessage(message);
|
|
147
|
+
return newService;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
private processHTTPRoute(service: ObservedService, route: string, method: string, span: ReadableSpan): void {
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { WebSocketConnection } from './WebSocketConnection';
|
|
2
|
+
import {
|
|
3
|
+
InitMessage,
|
|
4
|
+
NewObservedServiceMessage,
|
|
5
|
+
NewObservedHttpRouteMessage,
|
|
6
|
+
ObservationMessage,
|
|
7
|
+
HttpServerObservation,
|
|
8
|
+
ObservationOutputMessage
|
|
9
|
+
} from './wire-protocol';
|
|
10
|
+
|
|
11
|
+
// Mock the ws WebSocket library
|
|
12
|
+
jest.mock('ws');
|
|
13
|
+
|
|
14
|
+
import WebSocket from 'ws';
|
|
15
|
+
|
|
16
|
+
const MockedWebSocket = WebSocket as jest.MockedClass<typeof WebSocket>;
|
|
17
|
+
|
|
18
|
+
// Mock WebSocket instance
|
|
19
|
+
interface MockWebSocketInstance {
|
|
20
|
+
on: jest.Mock;
|
|
21
|
+
send: jest.Mock;
|
|
22
|
+
close: jest.Mock;
|
|
23
|
+
readyState: number;
|
|
24
|
+
OPEN: number;
|
|
25
|
+
CLOSED: number;
|
|
26
|
+
// Event handlers that tests can trigger
|
|
27
|
+
_triggerOpen: () => void;
|
|
28
|
+
_triggerMessage: (data: string) => void;
|
|
29
|
+
_triggerClose: (code: number, reason: string) => void;
|
|
30
|
+
_triggerError: (error: Error) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('WebSocketConnection', () => {
|
|
34
|
+
let mockSocket: MockWebSocketInstance;
|
|
35
|
+
let connection: WebSocketConnection;
|
|
36
|
+
let logMessages: string[];
|
|
37
|
+
let mockLogger: jest.Mock;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
// Reset all mocks
|
|
41
|
+
jest.clearAllMocks();
|
|
42
|
+
jest.clearAllTimers();
|
|
43
|
+
jest.useFakeTimers();
|
|
44
|
+
|
|
45
|
+
logMessages = [];
|
|
46
|
+
mockLogger = jest.fn((message: string) => {
|
|
47
|
+
logMessages.push(message);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Create mock WebSocket instance with event handling
|
|
51
|
+
mockSocket = {
|
|
52
|
+
on: jest.fn(),
|
|
53
|
+
send: jest.fn(),
|
|
54
|
+
close: jest.fn(),
|
|
55
|
+
readyState: WebSocket.CLOSED,
|
|
56
|
+
OPEN: WebSocket.OPEN,
|
|
57
|
+
CLOSED: WebSocket.CLOSED,
|
|
58
|
+
_triggerOpen: function() { /* will be set up below */ },
|
|
59
|
+
_triggerMessage: function(data: string) { /* will be set up below */ },
|
|
60
|
+
_triggerClose: function(code: number, reason: string) { /* will be set up below */ },
|
|
61
|
+
_triggerError: function(error: Error) { /* will be set up below */ }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Set up event handler storage and triggering
|
|
65
|
+
const eventHandlers: Record<string, Function> = {};
|
|
66
|
+
mockSocket.on.mockImplementation((event: string, handler: Function) => {
|
|
67
|
+
eventHandlers[event] = handler;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
mockSocket._triggerOpen = () => {
|
|
71
|
+
mockSocket.readyState = WebSocket.OPEN;
|
|
72
|
+
eventHandlers['open']?.();
|
|
73
|
+
};
|
|
74
|
+
mockSocket._triggerMessage = (data: string) => {
|
|
75
|
+
const buffer = Buffer.from(data);
|
|
76
|
+
eventHandlers['message']?.(buffer);
|
|
77
|
+
};
|
|
78
|
+
mockSocket._triggerClose = (code: number, reason: string) => {
|
|
79
|
+
mockSocket.readyState = WebSocket.CLOSED;
|
|
80
|
+
eventHandlers['close']?.(code, Buffer.from(reason));
|
|
81
|
+
};
|
|
82
|
+
mockSocket._triggerError = (error: Error) => {
|
|
83
|
+
eventHandlers['error']?.(error);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Mock WebSocket constructor to return our mock instance
|
|
87
|
+
MockedWebSocket.mockImplementation(() => mockSocket as any);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
jest.useRealTimers();
|
|
92
|
+
if (connection) {
|
|
93
|
+
connection.close();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('Connection Establishment', () => {
|
|
98
|
+
it('should create WebSocket connection with correct URL', () => {
|
|
99
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
100
|
+
|
|
101
|
+
expect(MockedWebSocket).toHaveBeenCalledWith('wss://ingestion.comprehend.dev/test-org/observations');
|
|
102
|
+
expect(mockSocket.on).toHaveBeenCalledWith('open', expect.any(Function));
|
|
103
|
+
expect(mockSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
|
|
104
|
+
expect(mockSocket.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
105
|
+
expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should send init message on connection open', () => {
|
|
109
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
110
|
+
|
|
111
|
+
mockSocket._triggerOpen();
|
|
112
|
+
|
|
113
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify({
|
|
114
|
+
event: 'init',
|
|
115
|
+
protocolVersion: 1,
|
|
116
|
+
token: 'test-token'
|
|
117
|
+
}));
|
|
118
|
+
expect(logMessages).toContain('WebSocket connected. Sending init/auth message.');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should work without logger', () => {
|
|
122
|
+
connection = new WebSocketConnection('test-org', 'test-token');
|
|
123
|
+
|
|
124
|
+
mockSocket._triggerOpen();
|
|
125
|
+
|
|
126
|
+
expect(MockedWebSocket).toHaveBeenCalled();
|
|
127
|
+
expect(mockSocket.send).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Authorization Flow', () => {
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
134
|
+
mockSocket._triggerOpen();
|
|
135
|
+
jest.clearAllMocks(); // Clear the init message send
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should handle authorization acknowledgment', () => {
|
|
139
|
+
const authAck: ObservationOutputMessage = {
|
|
140
|
+
type: 'ack-authorized'
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
mockSocket._triggerMessage(JSON.stringify(authAck));
|
|
144
|
+
|
|
145
|
+
expect(logMessages).toContain('Authorization acknowledged by server.');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should replay queued messages after authorization', () => {
|
|
149
|
+
// Queue some messages before authorization
|
|
150
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
151
|
+
event: 'new-entity',
|
|
152
|
+
type: 'service',
|
|
153
|
+
hash: 'test-hash-1',
|
|
154
|
+
name: 'test-service'
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const observationMessage: ObservationMessage = {
|
|
158
|
+
event: 'observations',
|
|
159
|
+
seq: 1,
|
|
160
|
+
observations: [{
|
|
161
|
+
type: 'http-server',
|
|
162
|
+
subject: 'test-subject',
|
|
163
|
+
timestamp: [1700000000, 0],
|
|
164
|
+
path: '/test',
|
|
165
|
+
status: 200,
|
|
166
|
+
duration: [0, 100000000]
|
|
167
|
+
} as HttpServerObservation]
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
connection.sendMessage(serviceMessage);
|
|
171
|
+
connection.sendMessage(observationMessage);
|
|
172
|
+
|
|
173
|
+
// Should not send immediately (not authorized yet)
|
|
174
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
175
|
+
|
|
176
|
+
// Now authorize
|
|
177
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
178
|
+
|
|
179
|
+
// Should replay both messages
|
|
180
|
+
expect(mockSocket.send).toHaveBeenCalledTimes(2);
|
|
181
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
|
|
182
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should send messages immediately when already authorized', () => {
|
|
186
|
+
// First authorize
|
|
187
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
188
|
+
jest.clearAllMocks();
|
|
189
|
+
|
|
190
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
191
|
+
event: 'new-entity',
|
|
192
|
+
type: 'service',
|
|
193
|
+
hash: 'test-hash',
|
|
194
|
+
name: 'test-service'
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
connection.sendMessage(serviceMessage);
|
|
198
|
+
|
|
199
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('Message Acknowledgment Handling', () => {
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
206
|
+
mockSocket._triggerOpen();
|
|
207
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
208
|
+
jest.clearAllMocks();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle entity/interaction acknowledgments', () => {
|
|
212
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
213
|
+
event: 'new-entity',
|
|
214
|
+
type: 'service',
|
|
215
|
+
hash: 'service-hash',
|
|
216
|
+
name: 'test-service'
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const routeMessage: NewObservedHttpRouteMessage = {
|
|
220
|
+
event: 'new-entity',
|
|
221
|
+
type: 'http-route',
|
|
222
|
+
hash: 'route-hash',
|
|
223
|
+
parent: 'service-hash',
|
|
224
|
+
method: 'GET',
|
|
225
|
+
route: '/test'
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Send messages
|
|
229
|
+
connection.sendMessage(serviceMessage);
|
|
230
|
+
connection.sendMessage(routeMessage);
|
|
231
|
+
|
|
232
|
+
// Acknowledge the service message
|
|
233
|
+
mockSocket._triggerMessage(JSON.stringify({
|
|
234
|
+
type: 'ack-observed',
|
|
235
|
+
hash: 'service-hash'
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
// Clear previous calls before reconnection test
|
|
239
|
+
mockSocket.send.mockClear();
|
|
240
|
+
|
|
241
|
+
// If we reconnect, only the route message should be replayed
|
|
242
|
+
mockSocket._triggerClose(1000, 'test close');
|
|
243
|
+
jest.advanceTimersByTime(1000);
|
|
244
|
+
mockSocket._triggerOpen();
|
|
245
|
+
|
|
246
|
+
// Clear the init message that gets sent on reconnection
|
|
247
|
+
mockSocket.send.mockClear();
|
|
248
|
+
|
|
249
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
250
|
+
|
|
251
|
+
// Should only send the unacknowledged route message
|
|
252
|
+
expect(mockSocket.send).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(routeMessage));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should handle observation acknowledgments', () => {
|
|
257
|
+
const observationMessage1: ObservationMessage = {
|
|
258
|
+
event: 'observations',
|
|
259
|
+
seq: 1,
|
|
260
|
+
observations: []
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const observationMessage2: ObservationMessage = {
|
|
264
|
+
event: 'observations',
|
|
265
|
+
seq: 2,
|
|
266
|
+
observations: []
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Send messages
|
|
270
|
+
connection.sendMessage(observationMessage1);
|
|
271
|
+
connection.sendMessage(observationMessage2);
|
|
272
|
+
|
|
273
|
+
// Acknowledge first observation
|
|
274
|
+
mockSocket._triggerMessage(JSON.stringify({
|
|
275
|
+
type: 'ack-observations',
|
|
276
|
+
seq: 1
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
// Clear previous calls before reconnection test
|
|
280
|
+
mockSocket.send.mockClear();
|
|
281
|
+
|
|
282
|
+
// Reconnect - only second observation should be replayed
|
|
283
|
+
mockSocket._triggerClose(1000, 'test close');
|
|
284
|
+
jest.advanceTimersByTime(1000);
|
|
285
|
+
mockSocket._triggerOpen();
|
|
286
|
+
|
|
287
|
+
// Clear the init message that gets sent on reconnection
|
|
288
|
+
mockSocket.send.mockClear();
|
|
289
|
+
|
|
290
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
291
|
+
|
|
292
|
+
// Should only send the unacknowledged second observation message
|
|
293
|
+
expect(mockSocket.send).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage2));
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('Reconnection Logic', () => {
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
301
|
+
mockSocket._triggerOpen();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should reconnect automatically on close', () => {
|
|
305
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
|
|
306
|
+
|
|
307
|
+
mockSocket._triggerClose(1000, 'Normal closure');
|
|
308
|
+
|
|
309
|
+
expect(logMessages).toContain('WebSocket disconnected. Code: 1000, Reason: Normal closure');
|
|
310
|
+
|
|
311
|
+
// Should schedule reconnection
|
|
312
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
|
|
313
|
+
|
|
314
|
+
// Advance timer to trigger reconnection
|
|
315
|
+
jest.advanceTimersByTime(1000);
|
|
316
|
+
|
|
317
|
+
// Should create new WebSocket connection
|
|
318
|
+
expect(MockedWebSocket).toHaveBeenCalledTimes(2);
|
|
319
|
+
|
|
320
|
+
setTimeoutSpy.mockRestore();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should reset authorization state on close', () => {
|
|
324
|
+
// First authorize
|
|
325
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
326
|
+
|
|
327
|
+
// Send a message (should send immediately)
|
|
328
|
+
const testMessage: NewObservedServiceMessage = {
|
|
329
|
+
event: 'new-entity',
|
|
330
|
+
type: 'service',
|
|
331
|
+
hash: 'test-hash',
|
|
332
|
+
name: 'test-service'
|
|
333
|
+
};
|
|
334
|
+
connection.sendMessage(testMessage);
|
|
335
|
+
expect(mockSocket.send).toHaveBeenCalled();
|
|
336
|
+
|
|
337
|
+
jest.clearAllMocks();
|
|
338
|
+
|
|
339
|
+
// Close connection
|
|
340
|
+
mockSocket._triggerClose(1000, 'test');
|
|
341
|
+
|
|
342
|
+
// Send another message - should queue (not send immediately)
|
|
343
|
+
connection.sendMessage(testMessage);
|
|
344
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should not reconnect when explicitly closed', () => {
|
|
348
|
+
connection.close();
|
|
349
|
+
mockSocket._triggerClose(1000, 'Explicit close');
|
|
350
|
+
|
|
351
|
+
// Should not schedule reconnection
|
|
352
|
+
jest.advanceTimersByTime(5000);
|
|
353
|
+
expect(MockedWebSocket).toHaveBeenCalledTimes(1); // Only initial connection
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('Error Handling', () => {
|
|
358
|
+
beforeEach(() => {
|
|
359
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should log WebSocket errors', () => {
|
|
363
|
+
const testError = new Error('Connection failed');
|
|
364
|
+
mockSocket._triggerError(testError);
|
|
365
|
+
|
|
366
|
+
expect(logMessages).toContain('WebSocket encountered an error: Connection failed');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should handle malformed server messages gracefully', () => {
|
|
370
|
+
mockSocket._triggerOpen();
|
|
371
|
+
mockSocket._triggerMessage('{invalid json}');
|
|
372
|
+
|
|
373
|
+
// Check that an error message was logged (exact message varies by Node version)
|
|
374
|
+
const errorLogExists = logMessages.some(msg =>
|
|
375
|
+
msg.startsWith('Error parsing message from server:') &&
|
|
376
|
+
msg.includes('JSON')
|
|
377
|
+
);
|
|
378
|
+
expect(errorLogExists).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should handle non-string error objects in message parsing', () => {
|
|
382
|
+
mockSocket._triggerOpen();
|
|
383
|
+
|
|
384
|
+
// Mock JSON.parse to throw a non-Error object
|
|
385
|
+
const originalParse = JSON.parse;
|
|
386
|
+
JSON.parse = jest.fn().mockImplementation(() => {
|
|
387
|
+
throw 'string error';
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
mockSocket._triggerMessage('{}');
|
|
391
|
+
|
|
392
|
+
expect(logMessages).toContain('Error parsing message from server: string error');
|
|
393
|
+
|
|
394
|
+
// Restore original JSON.parse
|
|
395
|
+
JSON.parse = originalParse;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should not send messages when socket is not open', () => {
|
|
399
|
+
mockSocket.readyState = WebSocket.CLOSED;
|
|
400
|
+
mockSocket._triggerOpen();
|
|
401
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
402
|
+
|
|
403
|
+
// Clear the init and any previous messages
|
|
404
|
+
mockSocket.send.mockClear();
|
|
405
|
+
|
|
406
|
+
// Force socket to closed state
|
|
407
|
+
mockSocket.readyState = WebSocket.CLOSED;
|
|
408
|
+
|
|
409
|
+
const testMessage: NewObservedServiceMessage = {
|
|
410
|
+
event: 'new-entity',
|
|
411
|
+
type: 'service',
|
|
412
|
+
hash: 'test-hash',
|
|
413
|
+
name: 'test-service'
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
connection.sendMessage(testMessage);
|
|
417
|
+
|
|
418
|
+
// Should not call send when socket is closed
|
|
419
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('Connection Lifecycle', () => {
|
|
424
|
+
it('should close WebSocket connection properly', () => {
|
|
425
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
426
|
+
|
|
427
|
+
connection.close();
|
|
428
|
+
|
|
429
|
+
expect(mockSocket.close).toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should handle close when socket is null', () => {
|
|
433
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
434
|
+
|
|
435
|
+
// Simulate socket being null
|
|
436
|
+
(connection as any).socket = null;
|
|
437
|
+
|
|
438
|
+
expect(() => connection.close()).not.toThrow();
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('Message Types and Queuing', () => {
|
|
443
|
+
beforeEach(() => {
|
|
444
|
+
connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
|
|
445
|
+
mockSocket._triggerOpen();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should queue new-entity messages', () => {
|
|
449
|
+
// Clear the initial init message
|
|
450
|
+
mockSocket.send.mockClear();
|
|
451
|
+
|
|
452
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
453
|
+
event: 'new-entity',
|
|
454
|
+
type: 'service',
|
|
455
|
+
hash: 'service-hash',
|
|
456
|
+
name: 'test-service'
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
connection.sendMessage(serviceMessage);
|
|
460
|
+
|
|
461
|
+
// Should be queued, not sent immediately (not authorized)
|
|
462
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
463
|
+
|
|
464
|
+
// Authorize and check replay
|
|
465
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
466
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should queue new-interaction messages', () => {
|
|
470
|
+
// Clear the initial init message
|
|
471
|
+
mockSocket.send.mockClear();
|
|
472
|
+
|
|
473
|
+
const httpRequestMessage = {
|
|
474
|
+
event: 'new-interaction' as const,
|
|
475
|
+
type: 'http-request' as const,
|
|
476
|
+
hash: 'request-hash',
|
|
477
|
+
from: 'service-hash',
|
|
478
|
+
to: 'http-service-hash'
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
connection.sendMessage(httpRequestMessage);
|
|
482
|
+
|
|
483
|
+
// Should be queued
|
|
484
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
485
|
+
|
|
486
|
+
// Authorize and check replay
|
|
487
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
488
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(httpRequestMessage));
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should queue observation messages', () => {
|
|
492
|
+
// Clear the initial init message
|
|
493
|
+
mockSocket.send.mockClear();
|
|
494
|
+
|
|
495
|
+
const observationMessage: ObservationMessage = {
|
|
496
|
+
event: 'observations',
|
|
497
|
+
seq: 1,
|
|
498
|
+
observations: [{
|
|
499
|
+
type: 'http-server',
|
|
500
|
+
subject: 'route-hash',
|
|
501
|
+
timestamp: [1700000000, 0],
|
|
502
|
+
path: '/test',
|
|
503
|
+
status: 200,
|
|
504
|
+
duration: [0, 50000000]
|
|
505
|
+
} as HttpServerObservation]
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
connection.sendMessage(observationMessage);
|
|
509
|
+
|
|
510
|
+
// Should be queued
|
|
511
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
512
|
+
|
|
513
|
+
// Authorize and check replay
|
|
514
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
|
|
515
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should not queue init messages in unacknowledged maps', () => {
|
|
519
|
+
// This is implicit - init messages are sent via sendRaw, not sendMessage
|
|
520
|
+
// and don't go through the queuing logic
|
|
521
|
+
|
|
522
|
+
const initMessage: InitMessage = {
|
|
523
|
+
event: 'init',
|
|
524
|
+
protocolVersion: 1,
|
|
525
|
+
token: 'test-token'
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// Manually test sendRaw behavior (private method testing)
|
|
529
|
+
mockSocket.readyState = WebSocket.OPEN;
|
|
530
|
+
(connection as any).sendRaw(initMessage);
|
|
531
|
+
|
|
532
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(initMessage));
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
});
|
package/src/sql-analyzer.test.ts
CHANGED
|
@@ -580,4 +580,20 @@ describe('SQL Analyzer - bulk INSERT VALUES cardinality reduction', () => {
|
|
|
580
580
|
expect(result.presentableQuery).toEqual(`INSERT INTO comments (text, author) VALUES
|
|
581
581
|
(...)`);
|
|
582
582
|
});
|
|
583
|
+
|
|
584
|
+
it('preserves whitespace before ON CONFLICT after VALUES clause', () => {
|
|
585
|
+
const sql = `INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com') ON CONFLICT (email) DO NOTHING`;
|
|
586
|
+
const result = analyzeSQL(sql);
|
|
587
|
+
|
|
588
|
+
expect(result.tableOperations).toEqual({ users: ['INSERT'] });
|
|
589
|
+
expect(result.presentableQuery).toEqual(`INSERT INTO users (name, email) VALUES (...) ON CONFLICT (email) DO NOTHING`);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('preserves whitespace before ON CONFLICT with multiple VALUES tuples', () => {
|
|
593
|
+
const sql = `INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`;
|
|
594
|
+
const result = analyzeSQL(sql);
|
|
595
|
+
|
|
596
|
+
expect(result.tableOperations).toEqual({ users: ['INSERT'] });
|
|
597
|
+
expect(result.presentableQuery).toEqual(`INSERT INTO users (name, email) VALUES (...) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`);
|
|
598
|
+
});
|
|
583
599
|
});
|
package/src/sql-analyzer.ts
CHANGED
|
@@ -28,6 +28,7 @@ export function analyzeSQL(sql: string): SQLAnalysisResult {
|
|
|
28
28
|
let skippingValues = false;
|
|
29
29
|
let lookingForCommaOrEnd = false;
|
|
30
30
|
let valuesDepth = 0;
|
|
31
|
+
let skippedWhitespace: Token[] = [];
|
|
31
32
|
for (let token of tokenizeSQL(sql)) {
|
|
32
33
|
switch (token.type) {
|
|
33
34
|
case "whitespace":
|
|
@@ -140,22 +141,30 @@ export function analyzeSQL(sql: string): SQLAnalysisResult {
|
|
|
140
141
|
switch (token.type) {
|
|
141
142
|
case "comment":
|
|
142
143
|
case "whitespace":
|
|
143
|
-
//
|
|
144
|
+
// Collect whitespace/comments while looking for comma or end
|
|
145
|
+
skippedWhitespace.push(token);
|
|
144
146
|
break;
|
|
145
147
|
case "punct":
|
|
146
148
|
if (token.value === ",") {
|
|
147
|
-
// More tuples coming, continue skipping
|
|
149
|
+
// More tuples coming, clear skipped whitespace and continue skipping
|
|
150
|
+
skippedWhitespace = [];
|
|
148
151
|
lookingForCommaOrEnd = false;
|
|
149
152
|
skippingValues = true;
|
|
150
153
|
} else {
|
|
151
154
|
// Not a comma, so VALUES clause is done
|
|
155
|
+
// Add back the skipped whitespace, then the current token
|
|
156
|
+
presentableTokens.push(...skippedWhitespace);
|
|
152
157
|
presentableTokens.push(token);
|
|
158
|
+
skippedWhitespace = [];
|
|
153
159
|
lookingForCommaOrEnd = false;
|
|
154
160
|
}
|
|
155
161
|
break;
|
|
156
162
|
default:
|
|
157
163
|
// VALUES clause is done, resume normal processing
|
|
164
|
+
// Add back the skipped whitespace, then the current token
|
|
165
|
+
presentableTokens.push(...skippedWhitespace);
|
|
158
166
|
presentableTokens.push(token);
|
|
167
|
+
skippedWhitespace = [];
|
|
159
168
|
lookingForCommaOrEnd = false;
|
|
160
169
|
break;
|
|
161
170
|
}
|