Files
skills/docs/communication/messaging/imessage-adapter/src/watcher/imessage-watcher.md
2026-05-29 08:20:56 +01:00

3.9 KiB

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<IMessageWatcherEvents> 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_identifiers.
  • 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

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

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<IMessageWatcherEvents> — 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<void> — throws IMessageWatcherAlreadyRunningError on double-start.
  • stop(): Promise<void> — throws IMessageWatcherNotRunningError when called while not running.
  • poll(opts?: { signal?: AbortSignal }): Promise<readonly ParsedIMessage[]>.
  • 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.