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.) viaparseWatcherConfig— invalid input throwsIMessageInvalidConfigErrorat construction time. - Injectable
SqliteReader(defaults to the productionbunSqliteReader) so tests use a realbun:sqlitechat.db fixture without mocks. start({ signal })schedules polls everypollIntervalMs; the signal stops the loop and emits astoppedevent.poll({ signal })runs one tick on demand; pre-aborted signals throwIMessagePollAbortedError. Errors from explicit polls bubble asIMessageDbQueryFailedError; errors from the internal scheduler emiterroron the bus and continue ticking (mirrors upstream behaviour so a transient SQLite error doesn't tear down the loop).- Monotonic
lastRowIdcursor — only emits rows withROWID > 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: falsedrops outgoing messages; non-emptychatFilterrestricts to specificchat_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 forbus.on.off(event, listener): void— legacy alias forbus.off.start(opts?: { signal?: AbortSignal }): Promise<void>— throwsIMessageWatcherAlreadyRunningErroron double-start.stop(): Promise<void>— throwsIMessageWatcherNotRunningErrorwhen 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.