# `IMessageWatcher` _(no submodules)_ ## Purpose Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIMessage`, and emits them on both a legacy `on/off` listener API and the typed `EventBus` exposed as the public `bus` field. The watcher is the canonical ingress for any surface that wants to consume inbound macOS Messages traffic — adapter, router, or direct subscriber. ## Features - Zod-validated config boundary (`pollIntervalMs > 0`, `sinceDate: Date`, etc.) via `parseWatcherConfig` — invalid input throws `IMessageInvalidConfigError` at construction time. - Injectable `SqliteReader` (defaults to the production `bunSqliteReader`) so tests use a real `bun:sqlite` chat.db fixture without mocks. - `start({ signal })` schedules polls every `pollIntervalMs`; the signal stops the loop and emits a `stopped` event. - `poll({ signal })` runs one tick on demand; pre-aborted signals throw `IMessagePollAbortedError`. Errors from explicit polls bubble as `IMessageDbQueryFailedError`; errors from the internal scheduler emit `error` on the bus and continue ticking (mirrors upstream behaviour so a transient SQLite error doesn't tear down the loop). - Monotonic `lastRowId` cursor — only emits rows with `ROWID > lastRowId`; advances even for filtered rows so the cursor doesn't re-process them next tick. `getLastRowId()` / `setLastRowId(n)` expose the cursor for consumers that want to persist resume state. - Filters: `includeFromMe: false` drops outgoing messages; non-empty `chatFilter` restricts to specific `chat_identifier`s. - Four OTel instruments — `imessage.messages_emitted_total`, `imessage.attachments_emitted_total`, `imessage.poll_errors_total`, `imessage.poll_duration_ms` — wired into every emit / error / tick. ## Usage: Basic ```ts import { IMessageWatcher } from "@suaveplan/imessage-adapter"; const watcher = new IMessageWatcher({ pollIntervalMs: 5000 }); const off = watcher.on("message", (msg) => console.log(msg.text)); await watcher.start(); // ... off(); await watcher.stop(); ``` ## Usage: Advanced ```ts import { IMessageWatcher, createChatDbFixture, createBunSqliteReader, } from "@suaveplan/imessage-adapter"; const reader = createBunSqliteReader(); const fixture = createChatDbFixture(reader, "/tmp/chat.db"); // ... populate fixture const watcher = new IMessageWatcher({ dbPath: "/tmp/chat.db", sqliteReader: reader, includeFromMe: true, chatFilter: ["+15551234567"], }); const controller = new AbortController(); const parsed = await watcher.poll({ signal: controller.signal }); ``` ## API Reference - `new IMessageWatcher(config?: IMessageWatcherConfig)` — Zod-validates at construction. - `bus: EventBus` — typed event surface (`message`, `error`, `started`, `stopped`). - `on(event, listener) => () => void` — legacy alias for `bus.on`. - `off(event, listener): void` — legacy alias for `bus.off`. - `start(opts?: { signal?: AbortSignal }): Promise` — throws `IMessageWatcherAlreadyRunningError` on double-start. - `stop(): Promise` — throws `IMessageWatcherNotRunningError` when called while not running. - `poll(opts?: { signal?: AbortSignal }): Promise`. - `getLastRowId(): number` / `setLastRowId(rowId: number): void`. - `isRunning(): boolean`. ## Implementation Notes The legacy `on/off` API delegates straight to the typed `EventBus`, which keeps the two surfaces in lockstep — there is no separate listener registry. The cursor advances inside the row loop *before* filters apply, so filtered rows are not re-fetched next tick. The scheduler-vs-explicit-poll error policy is asymmetric on purpose: a caller invoking `poll({ signal })` directly wants the error; the internal scheduler keeps ticking so a transient DB lock doesn't tear down the consumer.