@vaultgradient/pipequery-cli 0.4.0 → 0.6.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/README.md +64 -0
- package/__tests__/nl-translator.test.ts +228 -0
- package/__tests__/telegram-format.test.ts +62 -0
- package/__tests__/watch-manager.test.ts +202 -0
- package/dist/index.js +930 -57
- package/package.json +3 -1
- package/src/commands/telegram.ts +65 -0
- package/src/commands/watch.ts +84 -0
- package/src/config/schema.ts +45 -0
- package/src/index.ts +43 -0
- package/src/server/app.ts +10 -1
- package/src/server/watches/manager.ts +177 -0
- package/src/telegram/bot.ts +247 -0
- package/src/telegram/format.ts +158 -0
- package/src/telegram/nl.ts +318 -0
- package/src/telegram/notifier.ts +110 -0
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Connect to any data source (REST APIs, WebSockets, files, Postgres, MySQL, SQLit
|
|
|
8
8
|
|
|
9
9
|
**Give your AI agent live data** — `pq mcp serve` exposes every configured source to any Model Context Protocol client (Claude Desktop, Claude Code, Cursor, Copilot). Your AI can now query your REST APIs, files, Postgres / MySQL / SQLite databases, Kafka streams, and WebSocket feeds directly.
|
|
10
10
|
|
|
11
|
+
**Use it from Telegram** — `pq telegram serve` runs a Telegram bot that maps the same MCP commands to chat (`/query`, `/sources`, `/describe`, `/call`). Pair with `pq watch add` to push alerts to a Telegram channel when a query result transitions (`when_non_empty`, `when_empty`, or `on_change`).
|
|
12
|
+
|
|
11
13
|
## Installation
|
|
12
14
|
|
|
13
15
|
```bash
|
|
@@ -267,6 +269,68 @@ Restart Claude Desktop; ask "what pipequery sources are configured?" to verify.
|
|
|
267
269
|
|
|
268
270
|
Same idea — consult each tool's MCP config section. The stdio command is `pq mcp serve` with `cwd` pointing at a directory containing `pipequery.yaml`.
|
|
269
271
|
|
|
272
|
+
## Use from Telegram
|
|
273
|
+
|
|
274
|
+
`pq telegram serve` runs a Telegram bot that exposes the same five verbs as MCP, mapped to slash commands.
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
PIPEQUERY_TG_BOT_TOKEN=<your-bot-token> pq telegram serve --allow-user @yourname
|
|
278
|
+
|
|
279
|
+
# Or attach to a running pq serve instance
|
|
280
|
+
pq telegram serve --bot-token <token> --attach http://localhost:3000 --allow-user @yourname
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Bot commands:
|
|
284
|
+
|
|
285
|
+
| Command | What it does |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `/sources` | List configured sources and their health |
|
|
288
|
+
| `/describe <name>` | Sample rows + inferred field names |
|
|
289
|
+
| `/endpoints` | List pre-configured endpoints |
|
|
290
|
+
| `/call <path>` | Execute a pre-configured endpoint by path |
|
|
291
|
+
| `/query <expression>` | Run an arbitrary pipequery expression |
|
|
292
|
+
| `/help` | Show usage |
|
|
293
|
+
|
|
294
|
+
Without `--allow-user`, the bot replies to anyone who finds it (a stderr warning prints on first message). For anything reachable from the public internet, set the allowlist.
|
|
295
|
+
|
|
296
|
+
### Natural-language queries
|
|
297
|
+
|
|
298
|
+
Pass `--anthropic-key` (or set `ANTHROPIC_API_KEY`) and the bot accepts plain-English questions in addition to slash commands. Anything that doesn't start with `/` is translated into a pipequery expression by `claude-haiku-4-5` and executed against the configured sources.
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
ANTHROPIC_API_KEY=sk-ant-... pq telegram serve --allow-user @yourname
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Then in Telegram: `top 5 most expensive paid orders` → bot replies with the translated expression and the result.
|
|
305
|
+
|
|
306
|
+
The translator uses prompt caching for both the system prompt (always-stable DSL grammar) and the per-tenant schema preamble (source list + inferred fields), so repeated queries are cheap. The schema preamble refreshes every minute.
|
|
307
|
+
|
|
308
|
+
## Watch — alerts to Telegram
|
|
309
|
+
|
|
310
|
+
`pq watch add` registers a query that runs on every interval; when its result transitions (becomes non-empty, becomes empty, or changes content), pipequery posts a notification to a Telegram chat / channel.
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
# Set up the bot token once
|
|
314
|
+
export PIPEQUERY_TG_BOT_TOKEN=<your-bot-token>
|
|
315
|
+
|
|
316
|
+
# BTC dropped under $80k? Tell me.
|
|
317
|
+
pq watch add btc_dip \
|
|
318
|
+
-q "crypto | where(symbol == 'BTC' && price < 80000) | first(1)" \
|
|
319
|
+
-i 30s \
|
|
320
|
+
--telegram-chat-id -1001234567890 \
|
|
321
|
+
--telegram-message "🚨 BTC dipped: \${{ .price }}"
|
|
322
|
+
|
|
323
|
+
pq watch list
|
|
324
|
+
pq watch remove btc_dip
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`fireWhen` modes:
|
|
328
|
+
- **`when_non_empty`** (default) — fire on the empty → non-empty transition. Standard alerting shape; no spam while still triggered, re-fires after going empty and back.
|
|
329
|
+
- **`when_empty`** — inverse, e.g. liveness checks that fire when nothing matches.
|
|
330
|
+
- **`on_change`** — fire whenever the result content changes. Use sparingly; can be chatty.
|
|
331
|
+
|
|
332
|
+
Message templates support `{{ .field }}` (first-row field), `{{ .count }}` (total row count), `{{ .watchName }}`, and `{{ .reason }}`. If you omit the template, the bot posts a default summary plus the rendered result.
|
|
333
|
+
|
|
270
334
|
## Docker & Remote Deployment
|
|
271
335
|
|
|
272
336
|
The `pq` server and CLI are fully decoupled. You can run the server in Docker (locally or on a remote machine) and control it from your local terminal.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NL translator tests.
|
|
3
|
+
*
|
|
4
|
+
* The Anthropic SDK is injected (not mocked at the network layer), so these
|
|
5
|
+
* tests exercise the real shape of the request we send (system blocks with
|
|
6
|
+
* cache_control breakpoints, single user message) and the parsing of the
|
|
7
|
+
* response. No live API calls.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
10
|
+
import type Anthropic from '@anthropic-ai/sdk';
|
|
11
|
+
import { createNLTranslator } from '../src/telegram/nl.js';
|
|
12
|
+
import type { Provider, ProviderSourceInfo, ProviderSourceDescription } from '../src/mcp/provider.js';
|
|
13
|
+
|
|
14
|
+
interface CapturedCall {
|
|
15
|
+
model: string;
|
|
16
|
+
system: unknown;
|
|
17
|
+
messages: unknown;
|
|
18
|
+
max_tokens: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fakeProvider(): Provider {
|
|
22
|
+
return {
|
|
23
|
+
async listSources(): Promise<ProviderSourceInfo[]> {
|
|
24
|
+
return [
|
|
25
|
+
{ name: 'orders', status: { healthy: true, rowCount: 1234, lastFetch: null } },
|
|
26
|
+
{ name: 'users', status: { healthy: true, rowCount: 50, lastFetch: null } },
|
|
27
|
+
];
|
|
28
|
+
},
|
|
29
|
+
async describeSource(name: string): Promise<ProviderSourceDescription | undefined> {
|
|
30
|
+
if (name === 'orders') {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
status: { healthy: true, rowCount: 1234, lastFetch: null },
|
|
34
|
+
sample: [{ id: 1, total: 100, status: 'paid' }],
|
|
35
|
+
fields: ['id', 'total', 'status', 'country'],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (name === 'users') {
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
status: { healthy: true, rowCount: 50, lastFetch: null },
|
|
42
|
+
sample: [{ id: 1, email: 'a@b.com' }],
|
|
43
|
+
fields: ['id', 'email', 'banned'],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
},
|
|
48
|
+
async listEndpoints() {
|
|
49
|
+
return [];
|
|
50
|
+
},
|
|
51
|
+
async callEndpoint() {
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
async runQuery() {
|
|
55
|
+
return [];
|
|
56
|
+
},
|
|
57
|
+
async dispose() {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fakeClient(replyText: string, captured: CapturedCall[]): Anthropic {
|
|
62
|
+
return {
|
|
63
|
+
messages: {
|
|
64
|
+
async create(params: {
|
|
65
|
+
model: string;
|
|
66
|
+
system: unknown;
|
|
67
|
+
messages: unknown;
|
|
68
|
+
max_tokens: number;
|
|
69
|
+
}) {
|
|
70
|
+
captured.push({
|
|
71
|
+
model: params.model,
|
|
72
|
+
system: params.system,
|
|
73
|
+
messages: params.messages,
|
|
74
|
+
max_tokens: params.max_tokens,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
id: 'msg_test',
|
|
78
|
+
type: 'message',
|
|
79
|
+
role: 'assistant',
|
|
80
|
+
content: [{ type: 'text', text: replyText }],
|
|
81
|
+
model: params.model,
|
|
82
|
+
stop_reason: 'end_turn',
|
|
83
|
+
stop_sequence: null,
|
|
84
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
} as unknown as Anthropic;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('nl translator', () => {
|
|
92
|
+
let captured: CapturedCall[];
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
captured = [];
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('parses a clean JSON reply', async () => {
|
|
98
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
99
|
+
apiKey: 'sk-test',
|
|
100
|
+
client: fakeClient(
|
|
101
|
+
'{"expression":"orders | sort(total desc) | first(5)","explanation":"Top 5 orders."}',
|
|
102
|
+
captured,
|
|
103
|
+
),
|
|
104
|
+
});
|
|
105
|
+
const r = await t.translate('top 5 orders by total');
|
|
106
|
+
expect(r.expression).toBe('orders | sort(total desc) | first(5)');
|
|
107
|
+
expect(r.explanation).toBe('Top 5 orders.');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('strips ```json fences before parsing', async () => {
|
|
111
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
112
|
+
apiKey: 'sk-test',
|
|
113
|
+
client: fakeClient(
|
|
114
|
+
'```json\n{"expression":"orders | rollup(count() as n)","explanation":"Row count."}\n```',
|
|
115
|
+
captured,
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
const r = await t.translate('how many orders?');
|
|
119
|
+
expect(r.expression).toBe('orders | rollup(count() as n)');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('extracts the first JSON object even with leading prose', async () => {
|
|
123
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
124
|
+
apiKey: 'sk-test',
|
|
125
|
+
client: fakeClient(
|
|
126
|
+
'Here you go!\n{"expression":"users | first(10)","explanation":"First 10 users."}',
|
|
127
|
+
captured,
|
|
128
|
+
),
|
|
129
|
+
});
|
|
130
|
+
const r = await t.translate('show me users');
|
|
131
|
+
expect(r.expression).toBe('users | first(10)');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects responses without an expression field', async () => {
|
|
135
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
136
|
+
apiKey: 'sk-test',
|
|
137
|
+
client: fakeClient('{"foo":"bar"}', captured),
|
|
138
|
+
});
|
|
139
|
+
await expect(t.translate('whatever')).rejects.toThrow(/expression/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects non-JSON replies', async () => {
|
|
143
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
144
|
+
apiKey: 'sk-test',
|
|
145
|
+
client: fakeClient('I cannot help with that.', captured),
|
|
146
|
+
});
|
|
147
|
+
await expect(t.translate('whatever')).rejects.toThrow(/non-JSON/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('sends two cache breakpoints — system prompt and schema preamble', async () => {
|
|
151
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
152
|
+
apiKey: 'sk-test',
|
|
153
|
+
client: fakeClient(
|
|
154
|
+
'{"expression":"orders | first(5)","explanation":"x"}',
|
|
155
|
+
captured,
|
|
156
|
+
),
|
|
157
|
+
});
|
|
158
|
+
await t.translate('q');
|
|
159
|
+
expect(captured).toHaveLength(1);
|
|
160
|
+
const system = captured[0].system as Array<{ type: string; text: string; cache_control?: unknown }>;
|
|
161
|
+
expect(system).toHaveLength(2);
|
|
162
|
+
// Both system blocks must carry an ephemeral cache_control breakpoint —
|
|
163
|
+
// that's the whole point of putting the prompt and schema separately.
|
|
164
|
+
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
|
|
165
|
+
expect(system[1].cache_control).toEqual({ type: 'ephemeral' });
|
|
166
|
+
// First block is the static prompt, second is the per-tenant schema.
|
|
167
|
+
expect(system[0].text).toContain('PipeQuery');
|
|
168
|
+
expect(system[1].text).toContain('orders');
|
|
169
|
+
expect(system[1].text).toContain('users');
|
|
170
|
+
expect(system[1].text).toContain('fields:');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('uses claude-haiku-4-5 by default', async () => {
|
|
174
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
175
|
+
apiKey: 'sk-test',
|
|
176
|
+
client: fakeClient(
|
|
177
|
+
'{"expression":"orders | first(1)","explanation":"x"}',
|
|
178
|
+
captured,
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
await t.translate('q');
|
|
182
|
+
expect(captured[0].model).toBe('claude-haiku-4-5');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('caches the schema preamble across calls within TTL', async () => {
|
|
186
|
+
let describeCalls = 0;
|
|
187
|
+
const provider: Provider = {
|
|
188
|
+
...fakeProvider(),
|
|
189
|
+
async describeSource(name: string) {
|
|
190
|
+
describeCalls++;
|
|
191
|
+
return {
|
|
192
|
+
name,
|
|
193
|
+
status: { healthy: true, rowCount: 1, lastFetch: null },
|
|
194
|
+
sample: [],
|
|
195
|
+
fields: ['x'],
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const t = createNLTranslator(provider, {
|
|
200
|
+
apiKey: 'sk-test',
|
|
201
|
+
client: fakeClient(
|
|
202
|
+
'{"expression":"orders | first(1)","explanation":"x"}',
|
|
203
|
+
captured,
|
|
204
|
+
),
|
|
205
|
+
});
|
|
206
|
+
await t.translate('q1');
|
|
207
|
+
const first = describeCalls;
|
|
208
|
+
await t.translate('q2');
|
|
209
|
+
expect(describeCalls).toBe(first); // cached, no second describe round-trip
|
|
210
|
+
|
|
211
|
+
t.invalidateSchema();
|
|
212
|
+
await t.translate('q3');
|
|
213
|
+
expect(describeCalls).toBeGreaterThan(first);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('allows the model to signal "not answerable" via empty expression', async () => {
|
|
217
|
+
const t = createNLTranslator(fakeProvider(), {
|
|
218
|
+
apiKey: 'sk-test',
|
|
219
|
+
client: fakeClient(
|
|
220
|
+
'{"expression":"","explanation":"PipeQuery is read-only and cannot drop tables."}',
|
|
221
|
+
captured,
|
|
222
|
+
),
|
|
223
|
+
});
|
|
224
|
+
const r = await t.translate('drop the orders table');
|
|
225
|
+
expect(r.expression).toBe('');
|
|
226
|
+
expect(r.explanation).toMatch(/read-only/);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { escapeHtml, formatError, formatResult } from '../src/telegram/format.js';
|
|
3
|
+
|
|
4
|
+
describe('telegram format', () => {
|
|
5
|
+
it('renders an empty array as a soft "(no rows)" message', () => {
|
|
6
|
+
expect(formatResult([])).toBe('<i>(no rows)</i>');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('renders an object array as a Markdown table inside a <pre>', () => {
|
|
10
|
+
const out = formatResult([
|
|
11
|
+
{ name: 'Alice', amount: 120.5, status: 'paid' },
|
|
12
|
+
{ name: 'Bob', amount: 45.0, status: 'pending' },
|
|
13
|
+
]);
|
|
14
|
+
expect(out).toContain('<pre>');
|
|
15
|
+
expect(out).toContain('name');
|
|
16
|
+
expect(out).toContain('Alice');
|
|
17
|
+
expect(out).toContain('paid');
|
|
18
|
+
expect(out).toContain('2 rows');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('truncates with footer when more than maxRows', () => {
|
|
22
|
+
const rows = Array.from({ length: 100 }, (_, i) => ({ id: i, name: `row${i}` }));
|
|
23
|
+
const out = formatResult(rows);
|
|
24
|
+
expect(out).toContain('showing 30 of 100 rows');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('handles scalar arrays via JSON fallback', () => {
|
|
28
|
+
const out = formatResult([1, 2, 3, 'foo']);
|
|
29
|
+
expect(out).toContain('<pre>');
|
|
30
|
+
expect(out).toContain('foo');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('handles single objects via JSON fallback', () => {
|
|
34
|
+
const out = formatResult({ total: 1377, n: 3 });
|
|
35
|
+
expect(out).toContain('1377');
|
|
36
|
+
expect(out).toContain('"n": 3');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('escapes HTML in cell values so user data cannot inject markup', () => {
|
|
40
|
+
const out = formatResult([{ payload: '<script>alert(1)</script>' }]);
|
|
41
|
+
expect(out).not.toContain('<script>');
|
|
42
|
+
expect(out).toContain('<script>');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('formatError wraps in <pre> with the ❌ prefix', () => {
|
|
46
|
+
const out = formatError(new Error('boom'));
|
|
47
|
+
expect(out).toMatch(/^❌/);
|
|
48
|
+
expect(out).toContain('<pre>');
|
|
49
|
+
expect(out).toContain('boom');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('escapeHtml handles ampersand, lt, gt', () => {
|
|
53
|
+
expect(escapeHtml('a & b < c > d')).toBe('a & b < c > d');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('truncates very wide cells with an ellipsis', () => {
|
|
57
|
+
const long = 'x'.repeat(100);
|
|
58
|
+
const out = formatResult([{ huge: long }]);
|
|
59
|
+
// Default max col width is 24; the rendered cell should not contain the full 100 chars.
|
|
60
|
+
expect(out).toContain('xxxxxxxxxxxxxxxxxxxxxxx…');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch manager fire-condition tests.
|
|
3
|
+
*
|
|
4
|
+
* Doesn't touch Telegram — we replace the notifier with an in-memory recorder
|
|
5
|
+
* and drive the manager's tick() directly via a tiny private-call escape
|
|
6
|
+
* hatch. The point is to verify the *transition* logic (when_non_empty,
|
|
7
|
+
* when_empty, on_change) and the idempotency model.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { SourceManager } from '../src/server/sources/manager.js';
|
|
11
|
+
import { WatchManager } from '../src/server/watches/manager.js';
|
|
12
|
+
import type { Notifier, NotifyPayload } from '../src/telegram/notifier.js';
|
|
13
|
+
|
|
14
|
+
class RecordingNotifier implements Notifier {
|
|
15
|
+
calls: NotifyPayload[] = [];
|
|
16
|
+
async notify(payload: NotifyPayload): Promise<void> {
|
|
17
|
+
this.calls.push(payload);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a SourceManager seeded with a single static source whose data we can
|
|
23
|
+
* mutate between ticks to drive transitions.
|
|
24
|
+
*/
|
|
25
|
+
async function buildManagerWithSource(initial: unknown[]): Promise<{
|
|
26
|
+
sm: SourceManager;
|
|
27
|
+
setRows: (rows: unknown[]) => void;
|
|
28
|
+
}> {
|
|
29
|
+
const sm = new SourceManager(process.cwd());
|
|
30
|
+
let current = initial;
|
|
31
|
+
// Inject a fake adapter directly via the public addSource path. The static
|
|
32
|
+
// adapter would also work but it's easier to introspect a custom one.
|
|
33
|
+
await sm.addSource('events', { type: 'static', data: initial });
|
|
34
|
+
|
|
35
|
+
// Static adapter snapshots data at construction; for these tests we want
|
|
36
|
+
// mutability, so reach in. This is test-only and lets us simulate "the
|
|
37
|
+
// source data changed between ticks".
|
|
38
|
+
const setRows = (rows: unknown[]) => {
|
|
39
|
+
current = rows;
|
|
40
|
+
// SourceManager.getContext() reads from adapter.getData() each call —
|
|
41
|
+
// patch the adapter to return the current array each time.
|
|
42
|
+
const adapter = (sm as unknown as { sources: Map<string, { adapter: { getData: () => unknown[] } }> }).sources.get('events')!.adapter;
|
|
43
|
+
adapter.getData = () => current;
|
|
44
|
+
};
|
|
45
|
+
setRows(initial);
|
|
46
|
+
return { sm, setRows };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('WatchManager', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.useFakeTimers();
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.useRealTimers();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('when_non_empty fires once on the empty → non-empty transition', async () => {
|
|
58
|
+
const { sm, setRows } = await buildManagerWithSource([]);
|
|
59
|
+
const wm = new WatchManager(sm);
|
|
60
|
+
const recorder = new RecordingNotifier();
|
|
61
|
+
// Seed via internal start() mirror — register a watch by reaching into
|
|
62
|
+
// private state so we can attach our recorder. (In production code this
|
|
63
|
+
// happens through buildNotifiers + WatchConfig.notify; bypassing here.)
|
|
64
|
+
(wm as unknown as {
|
|
65
|
+
watches: Map<string, unknown>;
|
|
66
|
+
startOne: (n: string, c: unknown) => void;
|
|
67
|
+
}).watches.set('w', {
|
|
68
|
+
config: { query: 'events | where(severity == "high")', fireWhen: 'when_non_empty' },
|
|
69
|
+
notifiers: [recorder],
|
|
70
|
+
intervalMs: 100,
|
|
71
|
+
timer: null,
|
|
72
|
+
lastWasEmpty: null,
|
|
73
|
+
lastHash: null,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const tick = (wm as unknown as { tick: (name: string) => Promise<void> }).tick.bind(wm);
|
|
77
|
+
|
|
78
|
+
// First tick: result is empty (no high-severity events). Should NOT fire
|
|
79
|
+
// (transitions require a previous state).
|
|
80
|
+
await tick('w');
|
|
81
|
+
expect(recorder.calls).toHaveLength(0);
|
|
82
|
+
|
|
83
|
+
// Second tick still empty — no fire.
|
|
84
|
+
await tick('w');
|
|
85
|
+
expect(recorder.calls).toHaveLength(0);
|
|
86
|
+
|
|
87
|
+
// Mutate the source so the query now returns rows.
|
|
88
|
+
setRows([{ severity: 'high', msg: 'disk full' }]);
|
|
89
|
+
await tick('w');
|
|
90
|
+
expect(recorder.calls).toHaveLength(1);
|
|
91
|
+
expect(recorder.calls[0].reason).toMatch(/non-empty/);
|
|
92
|
+
|
|
93
|
+
// Subsequent ticks while still non-empty: no re-fire.
|
|
94
|
+
await tick('w');
|
|
95
|
+
await tick('w');
|
|
96
|
+
expect(recorder.calls).toHaveLength(1);
|
|
97
|
+
|
|
98
|
+
// Goes empty again — no fire on this transition (not the configured shape).
|
|
99
|
+
setRows([]);
|
|
100
|
+
await tick('w');
|
|
101
|
+
expect(recorder.calls).toHaveLength(1);
|
|
102
|
+
|
|
103
|
+
// And back to non-empty: re-fires.
|
|
104
|
+
setRows([{ severity: 'high', msg: 'oom' }]);
|
|
105
|
+
await tick('w');
|
|
106
|
+
expect(recorder.calls).toHaveLength(2);
|
|
107
|
+
|
|
108
|
+
wm.dispose();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('when_empty fires on the inverse transition', async () => {
|
|
112
|
+
const { sm, setRows } = await buildManagerWithSource([{ severity: 'high', msg: 'oom' }]);
|
|
113
|
+
const wm = new WatchManager(sm);
|
|
114
|
+
const recorder = new RecordingNotifier();
|
|
115
|
+
(wm as unknown as { watches: Map<string, unknown> }).watches.set('w', {
|
|
116
|
+
config: { query: 'events | where(severity == "high")', fireWhen: 'when_empty' },
|
|
117
|
+
notifiers: [recorder],
|
|
118
|
+
intervalMs: 100,
|
|
119
|
+
timer: null,
|
|
120
|
+
lastWasEmpty: null,
|
|
121
|
+
lastHash: null,
|
|
122
|
+
});
|
|
123
|
+
const tick = (wm as unknown as { tick: (name: string) => Promise<void> }).tick.bind(wm);
|
|
124
|
+
|
|
125
|
+
await tick('w'); // first tick, non-empty, no fire
|
|
126
|
+
expect(recorder.calls).toHaveLength(0);
|
|
127
|
+
|
|
128
|
+
setRows([]);
|
|
129
|
+
await tick('w'); // transition to empty — fires
|
|
130
|
+
expect(recorder.calls).toHaveLength(1);
|
|
131
|
+
expect(recorder.calls[0].reason).toMatch(/empty/);
|
|
132
|
+
|
|
133
|
+
await tick('w'); // still empty — no re-fire
|
|
134
|
+
expect(recorder.calls).toHaveLength(1);
|
|
135
|
+
|
|
136
|
+
wm.dispose();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('on_change fires whenever the result hash differs', async () => {
|
|
140
|
+
const { sm, setRows } = await buildManagerWithSource([{ id: 1 }]);
|
|
141
|
+
const wm = new WatchManager(sm);
|
|
142
|
+
const recorder = new RecordingNotifier();
|
|
143
|
+
(wm as unknown as { watches: Map<string, unknown> }).watches.set('w', {
|
|
144
|
+
config: { query: 'events | first(10)', fireWhen: 'on_change' },
|
|
145
|
+
notifiers: [recorder],
|
|
146
|
+
intervalMs: 100,
|
|
147
|
+
timer: null,
|
|
148
|
+
lastWasEmpty: null,
|
|
149
|
+
lastHash: null,
|
|
150
|
+
});
|
|
151
|
+
const tick = (wm as unknown as { tick: (name: string) => Promise<void> }).tick.bind(wm);
|
|
152
|
+
|
|
153
|
+
await tick('w'); // first tick — no fire (no previous hash)
|
|
154
|
+
expect(recorder.calls).toHaveLength(0);
|
|
155
|
+
|
|
156
|
+
await tick('w'); // same data — no fire
|
|
157
|
+
expect(recorder.calls).toHaveLength(0);
|
|
158
|
+
|
|
159
|
+
setRows([{ id: 2 }]);
|
|
160
|
+
await tick('w'); // different — fires
|
|
161
|
+
expect(recorder.calls).toHaveLength(1);
|
|
162
|
+
|
|
163
|
+
setRows([{ id: 2 }, { id: 3 }]);
|
|
164
|
+
await tick('w'); // different again — fires again
|
|
165
|
+
expect(recorder.calls).toHaveLength(2);
|
|
166
|
+
|
|
167
|
+
setRows([{ id: 2 }, { id: 3 }]);
|
|
168
|
+
await tick('w'); // identical to last — no fire
|
|
169
|
+
expect(recorder.calls).toHaveLength(2);
|
|
170
|
+
|
|
171
|
+
wm.dispose();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('a notifier that throws does not stop the watch advancing state', async () => {
|
|
175
|
+
const { sm, setRows } = await buildManagerWithSource([]);
|
|
176
|
+
const wm = new WatchManager(sm);
|
|
177
|
+
const throwing: Notifier = { notify: async () => { throw new Error('telegram down'); } };
|
|
178
|
+
(wm as unknown as { watches: Map<string, unknown> }).watches.set('w', {
|
|
179
|
+
config: { query: 'events | first(10)', fireWhen: 'when_non_empty' },
|
|
180
|
+
notifiers: [throwing],
|
|
181
|
+
intervalMs: 100,
|
|
182
|
+
timer: null,
|
|
183
|
+
lastWasEmpty: null,
|
|
184
|
+
lastHash: null,
|
|
185
|
+
});
|
|
186
|
+
const tick = (wm as unknown as { tick: (name: string) => Promise<void> }).tick.bind(wm);
|
|
187
|
+
|
|
188
|
+
await tick('w'); // empty
|
|
189
|
+
setRows([{ id: 1 }]);
|
|
190
|
+
// tick() awaits the notifier; since the notifier swallows the throw via
|
|
191
|
+
// the Promise.all(...catch) in WatchManager, the test should resolve.
|
|
192
|
+
await expect(tick('w')).resolves.toBeUndefined();
|
|
193
|
+
|
|
194
|
+
// State did advance (lastWasEmpty is now false), so a subsequent
|
|
195
|
+
// non-empty tick must NOT re-fire.
|
|
196
|
+
setRows([{ id: 1 }, { id: 2 }]);
|
|
197
|
+
await tick('w');
|
|
198
|
+
// Nothing to assert about the throwing notifier; the success criterion
|
|
199
|
+
// is simply that we got here.
|
|
200
|
+
wm.dispose();
|
|
201
|
+
});
|
|
202
|
+
});
|