first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
# `IMessageWatcher`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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_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<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.