livetap 0.1.5 → 0.2.1

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/src/cli/status.ts CHANGED
@@ -2,11 +2,16 @@
2
2
  * livetap status — Show daemon, connections, and watchers.
3
3
  */
4
4
 
5
- import { isDaemonRunning, daemonJson } from './daemon-client.js'
5
+ import { isDaemonRunning, daemonJson, readPid } from './daemon-client.js'
6
6
 
7
7
  export async function run(args: string[]) {
8
8
  if (!(await isDaemonRunning())) {
9
- console.log('livetap is not running. Use "livetap start" to begin.')
9
+ const pid = readPid()
10
+ if (pid) {
11
+ console.log(`livetap daemon is not responding (stale PID ${pid}). Try "livetap start".`)
12
+ } else {
13
+ console.log('livetap is not running. Use "livetap start" or "livetap setup" to begin.')
14
+ }
10
15
  return
11
16
  }
12
17
 
@@ -19,8 +24,7 @@ export async function run(args: string[]) {
19
24
  }
20
25
 
21
26
  const uptime = formatUptime(data.uptime)
22
- console.log(`livetap daemon running on :${data.port} (uptime ${uptime})`)
23
- console.log(`Redis: localhost:${data.redisPort}\n`)
27
+ console.log(`livetap daemon running on :${data.port} (uptime ${uptime})\n`)
24
28
 
25
29
  const conns = data.connections || []
26
30
  if (conns.length === 0) {
@@ -34,6 +38,20 @@ export async function run(args: string[]) {
34
38
  }
35
39
  console.log()
36
40
  }
41
+
42
+ // Fetch watcher count
43
+ try {
44
+ const watchers = await daemonJson('/watchers')
45
+ if (watchers.length > 0) {
46
+ const running = watchers.filter((w: any) => w.status === 'running').length
47
+ console.log(`Watchers (${watchers.length}, ${running} running)`)
48
+ for (const w of watchers) {
49
+ const expr = w.conditions?.map((c: any) => `${c.field} ${c.op} ${c.value}`).join(w.match === 'all' ? ' AND ' : ' OR ') ?? ''
50
+ console.log(` ${w.id} ${w.status.padEnd(8)} ${expr.slice(0, 50)}`)
51
+ }
52
+ console.log()
53
+ }
54
+ } catch { /* no watchers endpoint or error */ }
37
55
  }
38
56
 
39
57
  function formatUptime(seconds: number): string {
package/src/cli/stop.ts CHANGED
@@ -2,45 +2,32 @@
2
2
  * livetap stop — Stop the daemon.
3
3
  */
4
4
 
5
- import { resolve } from 'path'
6
- import { homedir } from 'os'
7
- import { existsSync, readFileSync, unlinkSync } from 'fs'
8
- import { isDaemonRunning, getDaemonUrl } from './daemon-client.js'
9
-
10
- const STATE_PATH = resolve(homedir(), '.livetap', 'state.json')
5
+ import { isDaemonRunning, readPid, isPidAlive, cleanPidFile, getDaemonPort } from './daemon-client.js'
11
6
 
12
7
  export async function run(_args: string[]) {
13
- if (!(await isDaemonRunning())) {
14
- // Try reading PID from state file
15
- if (existsSync(STATE_PATH)) {
16
- try {
17
- const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'))
18
- try { process.kill(state.pid, 'SIGTERM') } catch { /* already dead */ }
19
- unlinkSync(STATE_PATH)
20
- console.log('livetap daemon stopped (cleaned up stale state)')
21
- } catch {
22
- unlinkSync(STATE_PATH)
23
- }
24
- } else {
25
- console.log('livetap is not running.')
26
- }
8
+ const pid = readPid()
9
+ const running = await isDaemonRunning()
10
+
11
+ if (!running && !pid) {
12
+ console.log('livetap is not running.')
27
13
  return
28
14
  }
29
15
 
30
- // Read state for PID
31
- let pid: number | undefined
32
- if (existsSync(STATE_PATH)) {
33
- try {
34
- const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'))
35
- pid = state.pid
36
- } catch { /* malformed */ }
16
+ if (!running && pid) {
17
+ // PID file exists but daemon isn't responding
18
+ if (isPidAlive(pid)) {
19
+ // Process exists but not responding on port — might be something else
20
+ console.log(`Warning: PID ${pid} exists but daemon is not responding on :${getDaemonPort()}`)
21
+ try { process.kill(pid, 'SIGTERM') } catch { /* ok */ }
22
+ }
23
+ cleanPidFile()
24
+ console.log('livetap daemon stopped (cleaned up stale state)')
25
+ return
37
26
  }
38
27
 
39
- // Send SIGTERM
28
+ // Daemon is running — send SIGTERM via PID or wait for port to close
40
29
  if (pid) {
41
- try {
42
- process.kill(pid, 'SIGTERM')
43
- } catch { /* already gone */ }
30
+ try { process.kill(pid, 'SIGTERM') } catch { /* already gone */ }
44
31
  }
45
32
 
46
33
  // Wait for shutdown
@@ -55,10 +42,6 @@ export async function run(_args: string[]) {
55
42
  if (pid) try { process.kill(pid, 'SIGKILL') } catch { /* ok */ }
56
43
  }
57
44
 
58
- // Clean up state
59
- if (existsSync(STATE_PATH)) {
60
- try { unlinkSync(STATE_PATH) } catch { /* ok */ }
61
- }
62
-
45
+ cleanPidFile()
63
46
  console.log('livetap daemon stopped')
64
47
  }
@@ -10,15 +10,18 @@
10
10
 
11
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
12
12
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
13
+ import { readFileSync } from 'fs'
13
14
  import { registerTools } from './tools.js'
14
15
  import { generateInstructions } from '../shared/catalog-generators.js'
15
16
 
17
+ const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf-8'))
18
+
16
19
  const DAEMON_PORT = parseInt(process.env.LIVETAP_PORT || '8788')
17
20
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`
18
21
 
19
22
  const INSTRUCTIONS = generateInstructions()
20
23
 
21
- async function waitForDaemon(maxWaitMs = 10_000): Promise<boolean> {
24
+ async function waitForDaemon(maxWaitMs = 30_000): Promise<boolean> {
22
25
  const deadline = Date.now() + maxWaitMs
23
26
  while (Date.now() < deadline) {
24
27
  try {
@@ -37,13 +40,15 @@ async function autoStartDaemon(): Promise<boolean> {
37
40
  if (res.ok) return true
38
41
  } catch { /* not running */ }
39
42
 
40
- // Auto-start the daemon
43
+ // Auto-start via CLI (single code path — resolves paths correctly from any CWD)
44
+ const startScript = new URL('../../bin/livetap.ts', import.meta.url).pathname
41
45
  console.error('[livetap-mcp] Daemon not running, auto-starting...')
42
- Bun.spawn(['bun', 'src/server/index.ts'], {
46
+ const proc = Bun.spawn(['bun', startScript, 'start'], {
43
47
  env: { ...process.env, LIVETAP_PORT: String(DAEMON_PORT) },
44
48
  stdout: 'ignore',
45
49
  stderr: 'ignore',
46
50
  })
51
+ proc.unref()
47
52
 
48
53
  return waitForDaemon()
49
54
  }
@@ -51,7 +56,11 @@ async function autoStartDaemon(): Promise<boolean> {
51
56
  async function connectToSSE(mcp: Server) {
52
57
  try {
53
58
  const res = await fetch(`${DAEMON_URL}/events`)
54
- if (!res.ok || !res.body) return
59
+ if (!res.ok || !res.body) {
60
+ // Daemon not ready — retry
61
+ setTimeout(() => connectToSSE(mcp), 5000)
62
+ return
63
+ }
55
64
 
56
65
  const reader = res.body.getReader()
57
66
  const decoder = new TextDecoder()
@@ -85,9 +94,10 @@ async function connectToSSE(mcp: Server) {
85
94
  }
86
95
  }
87
96
  }
97
+ // Clean disconnect (daemon died or restarted) — retry
98
+ setTimeout(() => connectToSSE(mcp), 5000)
88
99
  } catch {
89
- // SSE connection faileddaemon may not support /events yet (Phase 3)
90
- // Silently retry after delay
100
+ // SSE connection errorretry after delay
91
101
  setTimeout(() => connectToSSE(mcp), 5000)
92
102
  }
93
103
  }
@@ -95,11 +105,11 @@ async function connectToSSE(mcp: Server) {
95
105
  async function main() {
96
106
  const daemonReady = await autoStartDaemon()
97
107
  if (!daemonReady) {
98
- console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools may fail.')
108
+ console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools will auto-retry on first call.')
99
109
  }
100
110
 
101
111
  const mcp = new Server(
102
- { name: 'LiveTap', version: '0.1.4' },
112
+ { name: 'LiveTap', version: PKG.version },
103
113
  {
104
114
  capabilities: {
105
115
  experimental: { 'claude/channel': {} },
@@ -118,4 +128,12 @@ async function main() {
118
128
  connectToSSE(mcp)
119
129
  }
120
130
 
131
+ // Prevent unhandled errors from crashing the proxy process
132
+ process.on('unhandledRejection', (err) => {
133
+ console.error('[livetap-mcp] unhandled rejection:', err)
134
+ })
135
+ process.on('uncaughtException', (err) => {
136
+ console.error('[livetap-mcp] uncaught exception:', err)
137
+ })
138
+
121
139
  main()
package/src/mcp/tools.ts CHANGED
@@ -3,173 +3,14 @@
3
3
  * Registers tools on an MCP Server that proxy to the daemon HTTP API.
4
4
  */
5
5
 
6
- import { z } from 'zod'
7
6
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
8
7
  import {
9
8
  ListToolsRequestSchema,
10
9
  CallToolRequestSchema,
11
10
  } from '@modelcontextprotocol/sdk/types.js'
12
11
 
13
- export const TOOLS = [
14
- {
15
- name: 'create_connection',
16
- description: 'Create a data connection. MQTT: connects to a broker and subscribes to topics. WebSocket: connects to a remote WS URL. Webhook: creates an HTTP ingest endpoint.',
17
- inputSchema: {
18
- type: 'object' as const,
19
- properties: {
20
- type: { type: 'string', enum: ['mqtt', 'webhook', 'websocket', 'file'], description: 'Source type', default: 'mqtt' },
21
- name: { type: 'string', description: 'Display name for the connection' },
22
- broker: { type: 'string', description: 'MQTT broker hostname (required for mqtt)' },
23
- port: { type: 'number', description: 'Broker port (default 1883 for mqtt)', default: 1883 },
24
- tls: { type: 'boolean', description: 'Use TLS (default false)', default: false },
25
- username: { type: 'string', description: 'MQTT username', default: '' },
26
- password: { type: 'string', description: 'MQTT password', default: '' },
27
- topics: { type: 'array', items: { type: 'string' }, description: 'MQTT topic filters (required for mqtt)' },
28
- url: { type: 'string', description: 'WebSocket URL (required for websocket)' },
29
- headers: { type: 'object', description: 'WebSocket auth headers' },
30
- handshake: { type: 'string', description: 'Message to send after WS connect (e.g. subscription JSON)' },
31
- path: { type: 'string', description: 'Absolute file path to tail (required for file type, e.g. "/var/log/app.log")' },
32
- },
33
- },
34
- },
35
- {
36
- name: 'list_connections',
37
- description: 'List all active connections with their status, message rate, and buffered count.',
38
- inputSchema: { type: 'object' as const, properties: {} },
39
- },
40
- {
41
- name: 'get_connection',
42
- description: 'Get detailed status of a specific connection.',
43
- inputSchema: {
44
- type: 'object' as const,
45
- properties: {
46
- connectionId: { type: 'string', description: 'The connection ID (e.g. "conn_a1b2c3d4")' },
47
- },
48
- required: ['connectionId'],
49
- },
50
- },
51
- {
52
- name: 'destroy_connection',
53
- description: 'Destroy a connection — stops the source subscriber, cleans up the Redis stream.',
54
- inputSchema: {
55
- type: 'object' as const,
56
- properties: {
57
- connectionId: { type: 'string', description: 'The connection ID to destroy' },
58
- },
59
- required: ['connectionId'],
60
- },
61
- },
62
- {
63
- name: 'read_stream',
64
- description: "Read recent entries from a connection's live stream. Use this to inspect what data is flowing through a connection and understand the payload structure before creating watchers.",
65
- inputSchema: {
66
- type: 'object' as const,
67
- properties: {
68
- connectionId: { type: 'string', description: 'The connection ID to read from' },
69
- backfillSeconds: { type: 'number', description: 'Include entries from the last N seconds (default 60)', default: 60 },
70
- maxEntries: { type: 'number', description: 'Max entries to return (default 10)', default: 10 },
71
- },
72
- required: ['connectionId'],
73
- },
74
- },
75
- {
76
- name: 'create_watcher',
77
- description: 'Create an expression-based watcher on a connection. The watcher evaluates structured conditions against each stream entry and fires an alert when conditions match. ALWAYS use read_stream first to understand the data shape and field paths.',
78
- inputSchema: {
79
- type: 'object' as const,
80
- properties: {
81
- connectionId: { type: 'string', description: 'The connection ID to watch' },
82
- conditions: {
83
- type: 'array',
84
- description: 'Array of conditions: [{field: "dot.path", op: ">", value: 50}]. Supported ops: >, <, >=, <=, ==, !=, contains, matches (regex)',
85
- items: {
86
- type: 'object',
87
- properties: {
88
- field: { type: 'string', description: 'Dot-path into the JSON payload (e.g. "sensors.temperature.value")' },
89
- op: { type: 'string', enum: ['>', '<', '>=', '<=', '==', '!=', 'contains', 'matches'], description: 'Comparison operator. "matches" takes a regex pattern string.' },
90
- value: { description: 'Value to compare against (number, string, or boolean)' },
91
- },
92
- required: ['field', 'op', 'value'],
93
- },
94
- },
95
- match: { type: 'string', enum: ['all', 'any'], description: 'How to combine conditions: "all" (AND) or "any" (OR). Default: "all"', default: 'all' },
96
- action: { description: '"channel_alert" (default), or {webhook: "url"}, or {shell: "command"}', default: 'channel_alert' },
97
- cooldown: { type: 'number', description: 'Seconds between repeated alerts. 0 for every match, 60 default. Use 0 for rare events (webhooks), 30-60 for sensors, 300+ for high-frequency streams.', default: 60 },
98
- },
99
- required: ['connectionId', 'conditions'],
100
- },
101
- },
102
- {
103
- name: 'list_watchers',
104
- description: 'List watchers. Optionally filter by connectionId. If omitted, lists all watchers.',
105
- inputSchema: {
106
- type: 'object' as const,
107
- properties: {
108
- connectionId: { type: 'string', description: 'Optional: filter by connection ID' },
109
- },
110
- },
111
- },
112
- {
113
- name: 'get_watcher',
114
- description: 'Get details of a specific watcher including conditions, status, match count, and config.',
115
- inputSchema: {
116
- type: 'object' as const,
117
- properties: {
118
- watcherId: { type: 'string', description: 'The watcher ID' },
119
- },
120
- required: ['watcherId'],
121
- },
122
- },
123
- {
124
- name: 'get_watcher_logs',
125
- description: 'Get evaluation logs from a watcher — shows MATCH, SUPPRESSED, FIELD_NOT_FOUND, and CHECKPOINT events.',
126
- inputSchema: {
127
- type: 'object' as const,
128
- properties: {
129
- watcherId: { type: 'string', description: 'The watcher ID' },
130
- lines: { type: 'number', description: 'Number of log lines to return (default 50)', default: 50 },
131
- },
132
- required: ['watcherId'],
133
- },
134
- },
135
- {
136
- name: 'update_watcher',
137
- description: "Update a watcher's conditions, match mode, action, or cooldown. The watcher restarts with the new config.",
138
- inputSchema: {
139
- type: 'object' as const,
140
- properties: {
141
- watcherId: { type: 'string', description: 'The watcher ID' },
142
- conditions: { type: 'array', description: 'New conditions array', items: { type: 'object' } },
143
- match: { type: 'string', enum: ['all', 'any'] },
144
- action: { description: 'New action' },
145
- cooldown: { type: 'number', description: 'New cooldown in seconds' },
146
- },
147
- required: ['watcherId'],
148
- },
149
- },
150
- {
151
- name: 'delete_watcher',
152
- description: 'Stop and remove a watcher.',
153
- inputSchema: {
154
- type: 'object' as const,
155
- properties: {
156
- watcherId: { type: 'string', description: 'The watcher ID to delete' },
157
- },
158
- required: ['watcherId'],
159
- },
160
- },
161
- {
162
- name: 'restart_watcher',
163
- description: 'Restart a stopped watcher.',
164
- inputSchema: {
165
- type: 'object' as const,
166
- properties: {
167
- watcherId: { type: 'string', description: 'The watcher ID to restart' },
168
- },
169
- required: ['watcherId'],
170
- },
171
- },
172
- ]
12
+ export { TOOLS } from '../shared/canonical/tools.js'
13
+ import { TOOLS } from '../shared/canonical/tools.js'
173
14
 
174
15
  function text(content: string) {
175
16
  return { content: [{ type: 'text' as const, text: content }] }
@@ -179,9 +20,35 @@ function error(content: string) {
179
20
  return { content: [{ type: 'text' as const, text: content }], isError: true }
180
21
  }
181
22
 
23
+ /**
24
+ * Try to start the daemon via CLI. Returns true if daemon becomes healthy.
25
+ */
26
+ async function tryStartDaemon(daemonUrl: string): Promise<boolean> {
27
+ const startScript = new URL('../../bin/livetap.ts', import.meta.url).pathname
28
+ const port = new URL(daemonUrl).port || '8788'
29
+ const proc = Bun.spawn(['bun', startScript, 'start'], {
30
+ env: { ...process.env, LIVETAP_PORT: port },
31
+ stdout: 'ignore',
32
+ stderr: 'ignore',
33
+ })
34
+ proc.unref()
35
+
36
+ // Wait up to 15s for daemon to be ready
37
+ const deadline = Date.now() + 15_000
38
+ while (Date.now() < deadline) {
39
+ try {
40
+ const res = await fetch(`${daemonUrl}/status`)
41
+ if (res.ok) return true
42
+ } catch { /* not ready */ }
43
+ await new Promise((r) => setTimeout(r, 500))
44
+ }
45
+ return false
46
+ }
47
+
182
48
  /**
183
49
  * Register all livetap MCP tools on the given server.
184
50
  * Tools proxy to the daemon HTTP API at the given base URL.
51
+ * Includes auto-restart + retry on connection failure.
185
52
  */
186
53
  export function registerTools(server: Server, daemonUrl: string) {
187
54
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -304,11 +171,40 @@ export function registerTools(server: Server, daemonUrl: string) {
304
171
  return text(await res.text())
305
172
  }
306
173
 
174
+ case 'status': {
175
+ const res = await fetch(`${daemonUrl}/status`)
176
+ if (!res.ok) return error('Daemon not reachable')
177
+ const status = await res.json()
178
+ // Also fetch watcher count
179
+ let watcherCount = 0
180
+ try {
181
+ const wr = await fetch(`${daemonUrl}/watchers`)
182
+ if (wr.ok) {
183
+ const watchers = await wr.json()
184
+ watcherCount = watchers.length
185
+ }
186
+ } catch { /* ok */ }
187
+ return text(JSON.stringify({ ...status, watcherCount }, null, 2))
188
+ }
189
+
307
190
  default:
308
191
  return error(`Unknown tool: ${name}`)
309
192
  }
310
193
  } catch (err) {
311
- return error(`livetap daemon error: ${(err as Error).message}. Is the daemon running?`)
194
+ // Connection failed try to auto-start daemon and retry
195
+ const msg = (err as Error).message
196
+ if (msg.includes('Unable to connect') || msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) {
197
+ const started = await tryStartDaemon(daemonUrl)
198
+ if (!started) {
199
+ return error('livetap daemon could not be started. Run "livetap start" manually.')
200
+ }
201
+ // Retry: simple fetch to /status to confirm, then tell agent to retry
202
+ return text(JSON.stringify({
203
+ note: 'Daemon was restarted. Please retry your request.',
204
+ status: 'daemon_restarted',
205
+ }))
206
+ }
207
+ return error(`livetap daemon error: ${msg}`)
312
208
  }
313
209
  })
314
210
  }
@@ -2,7 +2,7 @@
2
2
  * Connection Manager — creates, tracks, and destroys data source connections.
3
3
  */
4
4
 
5
- import type Redis from 'ioredis'
5
+ import type { StreamStore } from './stream-store.js'
6
6
  import type { ConnectionConfig, ConnectionRecord, ConnectionStatus, Subscriber } from './types.js'
7
7
  import { MqttSubscriber } from './connections/mqtt.js'
8
8
  import { WebhookIngestor } from './connections/webhook.js'
@@ -18,12 +18,10 @@ function generateId(): string {
18
18
 
19
19
  export class ConnectionManager {
20
20
  private connections = new Map<string, ConnectionRecord>()
21
- private redis: Redis
22
- private redisUrl: string
21
+ private store: StreamStore
23
22
 
24
- constructor(redis: Redis, redisUrl: string) {
25
- this.redis = redis
26
- this.redisUrl = redisUrl
23
+ constructor(store: StreamStore) {
24
+ this.store = store
27
25
  }
28
26
 
29
27
  async create(config: ConnectionConfig, name?: string): Promise<ConnectionRecord> {
@@ -51,27 +49,27 @@ export class ConnectionManager {
51
49
  record.subscriber = new MqttSubscriber({
52
50
  config,
53
51
  streamKey,
54
- redis: this.redis,
52
+ store: this.store,
55
53
  onMessage,
56
54
  })
57
55
  } else if (config.type === 'webhook') {
58
56
  record.subscriber = new WebhookIngestor({
59
57
  streamKey,
60
- redis: this.redis,
58
+ store: this.store,
61
59
  onMessage,
62
60
  })
63
61
  } else if (config.type === 'websocket') {
64
62
  record.subscriber = new WsSubscriber({
65
63
  config,
66
64
  streamKey,
67
- redis: this.redis,
65
+ store: this.store,
68
66
  onMessage,
69
67
  })
70
68
  } else if (config.type === 'file') {
71
69
  record.subscriber = new FileSubscriber({
72
70
  config,
73
71
  streamKey,
74
- redis: this.redis,
72
+ store: this.store,
75
73
  onMessage,
76
74
  })
77
75
  } else {
@@ -88,10 +86,8 @@ export class ConnectionManager {
88
86
  }, 5000)
89
87
 
90
88
  // Buffered count tracking (every 5s)
91
- record.bufferedInterval = setInterval(async () => {
92
- try {
93
- record.bufferedCount = await this.redis.xlen(streamKey)
94
- } catch { /* ignore */ }
89
+ record.bufferedInterval = setInterval(() => {
90
+ record.bufferedCount = this.store.len(streamKey)
95
91
  }, 5000)
96
92
 
97
93
  this.connections.set(id, record)
@@ -119,7 +115,7 @@ export class ConnectionManager {
119
115
  if (record.bufferedInterval) clearInterval(record.bufferedInterval)
120
116
 
121
117
  await record.subscriber.stop()
122
- await this.redis.del(record.streamKey)
118
+ this.store.del(record.streamKey)
123
119
  this.connections.delete(id)
124
120
  return true
125
121
  }
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * File Tailing Subscriber — watches a file for new lines (tail -f behavior)
3
- * and writes each line to a Redis Stream with rolling retention.
3
+ * and writes each line to a stream with rolling retention.
4
4
  * Auto-detects JSON vs plain text per line.
5
5
  */
6
6
 
7
7
  import { watch, type FSWatcher } from 'fs'
8
8
  import { open, stat, type FileHandle } from 'fs/promises'
9
- import type Redis from 'ioredis'
9
+ import type { StreamStore } from '../stream-store.js'
10
10
  import type { FileConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
11
11
 
12
12
  export interface FileSubscriberOpts {
13
13
  config: FileConnectionConfig
14
14
  streamKey: string
15
- redis: Redis
15
+ store: StreamStore
16
16
  retentionMs?: number
17
17
  onMessage?: () => void
18
18
  }
@@ -20,7 +20,7 @@ export interface FileSubscriberOpts {
20
20
  export class FileSubscriber implements Subscriber {
21
21
  private config: FileConnectionConfig
22
22
  private streamKey: string
23
- private redis: Redis
23
+ private store: StreamStore
24
24
  private retentionMs: number
25
25
  private onMessage: (() => void) | undefined
26
26
  private state: ConnectionStatus['runtimeState'] = 'disconnected'
@@ -35,7 +35,7 @@ export class FileSubscriber implements Subscriber {
35
35
  constructor(opts: FileSubscriberOpts) {
36
36
  this.config = opts.config
37
37
  this.streamKey = opts.streamKey
38
- this.redis = opts.redis
38
+ this.store = opts.store
39
39
  this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
40
40
  this.onMessage = opts.onMessage
41
41
  }
@@ -98,9 +98,8 @@ export class FileSubscriber implements Subscriber {
98
98
  fields = { payload: line, format: 'text' }
99
99
  }
100
100
 
101
- await this.redis.xadd(this.streamKey, '*', ...Object.entries(fields).flat())
102
- const minId = Date.now() - this.retentionMs
103
- await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
101
+ this.store.append(this.streamKey, fields)
102
+ this.store.trim(this.streamKey, Date.now() - this.retentionMs)
104
103
  this.onMessage?.()
105
104
  }
106
105
  } catch (err) {
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * MQTT Subscriber — connects to an MQTT broker and writes messages
3
- * to a Redis Stream with rolling retention.
3
+ * to a stream with rolling retention.
4
4
  */
5
5
 
6
6
  import mqtt from 'mqtt'
7
- import type Redis from 'ioredis'
7
+ import type { StreamStore } from '../stream-store.js'
8
8
  import type { MqttConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
9
9
 
10
10
  export interface MqttSubscriberOpts {
11
11
  config: MqttConnectionConfig
12
12
  streamKey: string
13
- redis: Redis
13
+ store: StreamStore
14
14
  retentionMs?: number
15
15
  onMessage?: () => void
16
16
  }
@@ -19,7 +19,7 @@ export class MqttSubscriber implements Subscriber {
19
19
  private client: mqtt.MqttClient | null = null
20
20
  private config: MqttConnectionConfig
21
21
  private streamKey: string
22
- private redis: Redis
22
+ private store: StreamStore
23
23
  private retentionMs: number
24
24
  private onMessage: (() => void) | undefined
25
25
  private state: ConnectionStatus['runtimeState'] = 'disconnected'
@@ -28,7 +28,7 @@ export class MqttSubscriber implements Subscriber {
28
28
  constructor(opts: MqttSubscriberOpts) {
29
29
  this.config = opts.config
30
30
  this.streamKey = opts.streamKey
31
- this.redis = opts.redis
31
+ this.store = opts.store
32
32
  this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
33
33
  this.onMessage = opts.onMessage
34
34
  }
@@ -77,14 +77,13 @@ export class MqttSubscriber implements Subscriber {
77
77
  this.state = 'reconnecting'
78
78
  })
79
79
 
80
- this.client!.on('message', async (topic, payload) => {
80
+ this.client!.on('message', (topic, payload) => {
81
81
  try {
82
- await this.redis.xadd(this.streamKey, '*', 'topic', topic, 'payload', payload.toString())
83
- const minId = Date.now() - this.retentionMs
84
- await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
82
+ this.store.append(this.streamKey, { topic, payload: payload.toString() })
83
+ this.store.trim(this.streamKey, Date.now() - this.retentionMs)
85
84
  this.onMessage?.()
86
85
  } catch {
87
- // Redis write failure — log but don't crash
86
+ // Write failure — log but don't crash
88
87
  }
89
88
  })
90
89
  })