first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user