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,120 @@
# @suaveplan/imessage-adapter - macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.
macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter`
implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes
Apple-epoch / nanosecond timestamps, resolves handle + chat +
attachment joins, and emits `ParsedIMessage` and `InboundMessage`
envelopes. Server-tier (Bun); the `bun:sqlite` driver is loaded
lazily inside the production reader so the package type-checks +
lints on every host.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
- [imessage-adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
- [fixtures](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
- [chat-db-fixture](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
- [otel](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
- [metrics](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
- [parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
- [imessage-parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
- [sqlite](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
- [bun-sqlite-reader](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
- [watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
- [imessage-watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
<!-- end:auto-docs -->
## Highlights
- **`IMessageWatcher`** — Zod-validated config boundary, AbortSignal
propagation, injectable `SqliteReader`, typed `EventBus` surface
alongside the upstream `on/off` listener API, monotonic
`lastRowId` cursor (settable + readable for resume), filters for
`includeFromMe` and `chatFilter`.
- **`IMessageAdapter`** — wraps the watcher and projects each
`ParsedIMessage` into a channel-agnostic `InboundMessage` with
normalised phone numbers and chat metadata.
- **`IMessageParser`** — pure-function parser for chat.db row shapes;
Apple-epoch decoding, attachment fallbacks, phone normalisation.
- **`createChatDbFixture`** — real chat.db-shaped SQLite database
builder used by tests so every test path runs against the same SQL
the production watcher executes — no mocks.
- **`bunSqliteReader` / `createBunSqliteReader`** — production
`SqliteReader` backed by Bun's `bun:sqlite`. Errors are wrapped in
`IMessageDbOpenFailedError`.
## Install
```bash
bun add @suaveplan/imessage-adapter
```
## Usage
```ts
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath: "~/Library/Messages/chat.db",
pollIntervalMs: 5000,
includeFromMe: false,
});
for (const inbound of await adapter.poll()) {
console.log(inbound.sourceAddress, inbound.content);
adapter.markProcessed(inbound.id);
}
await adapter.close();
```
## Error registry (`IMESSAGE.*`)
| Code | Class | When |
|---|---|---|
| `IMESSAGE.DB_OPEN_FAILED` | `IMessageDbOpenFailedError` | `bun:sqlite` cannot open the chat.db path |
| `IMESSAGE.DB_QUERY_FAILED` | `IMessageDbQueryFailedError` | A poll-time SQL query throws |
| `IMESSAGE.WATCHER_NOT_RUNNING` | `IMessageWatcherNotRunningError` | `stop()` called while not running |
| `IMESSAGE.WATCHER_ALREADY_RUNNING` | `IMessageWatcherAlreadyRunningError` | `start()` called twice |
| `IMESSAGE.ADAPTER_NOT_INITIALIZED` | `IMessageAdapterNotInitializedError` | `poll()` before `initialize()` |
| `IMESSAGE.INVALID_CONFIG` | `IMessageInvalidConfigError` | Zod boundary rejected config; `context.issues` carries the path + reason |
| `IMESSAGE.POLL_ABORTED` | `IMessagePollAbortedError` | `AbortSignal` fired |
| `IMESSAGE.POLL_FAILED` | `IMessagePollFailedError` | Reserved for explicit `poll()` wrapping |
| `IMESSAGE.ATTACHMENT_PARSE_FAILED` | `IMessageAttachmentParseError` | Attachment row could not be parsed |
| `IMESSAGE.APPLE_DATE_PARSE_FAILED` | `IMessageAppleDateParseError` | Timestamp is NaN / non-finite |
## OpenTelemetry instruments
| Instrument | Kind | Attributes |
|---|---|---|
| `imessage.messages_emitted_total` | Counter | `chat_type`, `from_me` |
| `imessage.attachments_emitted_total` | Counter | `mime_family` |
| `imessage.poll_errors_total` | Counter | `code` |
| `imessage.poll_duration_ms` | Histogram | `outcome` (`ok`, `error`, `aborted`) |
Call `refreshIMessageMeters()` after `metrics.setGlobalMeterProvider`
to bind subsequent records to the chosen provider.
## Subpath exports
- `@suaveplan/imessage-adapter/error-codes`
- `@suaveplan/imessage-adapter/schemas`
- `@suaveplan/imessage-adapter/parser`
- `@suaveplan/imessage-adapter/watcher`
- `@suaveplan/imessage-adapter/adapter`
- `@suaveplan/imessage-adapter/sqlite/bun-sqlite-reader`
- `@suaveplan/imessage-adapter/otel`
- `@suaveplan/imessage-adapter/fixtures`
## Tier
Server-tier (Bun on macOS). The `bun:sqlite` import is reached via
`createRequire(import.meta.url)` so the static module graph never
attempts to resolve the specifier on a non-Bun runner.

View File

@@ -0,0 +1,90 @@
# `IMessageAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that
projects each `ParsedIMessage` into the channel-agnostic
`InboundMessage` envelope consumed by downstream inbound-message
routers (e.g. a future `@suaveplan/api-plugin-messaging`). The
adapter is the polymorphic surface — callers depend on
`MessageSourceAdapter`, swap in iMessage / SMS / Slack adapters
without coupling to chat.db schema details.
## Features
- `type === "imessage"` constant so multi-adapter routers can fan
out by source.
- `initialize(config)` Zod-validates the input via
`parseAdapterConfig` and constructs the underlying watcher with
the validated config (including any injected `sqliteReader`).
- `poll()` calls the watcher's `poll()` and projects each parsed
message into an `InboundMessage`. Attachments populate the
`attachments` array; the `contentType` is `"attachment"` when
≥1 attachment is present and `"text"` otherwise.
- `sourceAddress` is the parser-normalised phone number (or the
unchanged email handle), so downstream consumers see a
canonicalised identity.
- `metadata` block carries chat-level context (`chatIdentifier`,
`chatDisplayName`, `isGroupChat`, `senderService`, `isRead`) that
routers need for routing decisions.
- `getLastProcessedId() / markProcessed(id)` expose the consumer-
side cursor; `close()` stops the running watcher and clears the
reference.
- `poll()` called before `initialize()` throws
`IMessageAdapterNotInitializedError`
(`IMESSAGE.ADAPTER_NOT_INITIALIZED`).
## Usage: Basic
```ts
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({ pollIntervalMs: 5000 });
const inbound = await adapter.poll();
await adapter.close();
```
## Usage: Advanced
```ts
import {
IMessageAdapter,
createBunSqliteReader,
} from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath: "/tmp/chat.db",
sqliteReader: createBunSqliteReader(),
includeFromMe: true,
});
for (const msg of await adapter.poll()) {
if (msg.metadata?.["isGroupChat"]) {
adapter.markProcessed(msg.id);
}
}
```
## API Reference
- `type: "imessage"` — read-only adapter discriminator.
- `initialize(config: Record<string, unknown>): Promise<void>`.
- `poll(): Promise<readonly InboundMessage[]>`.
- `getLastProcessedId(): string | null`.
- `markProcessed(id: string): void`.
- `close(): Promise<void>` — idempotent.
## Implementation Notes
The adapter owns the watcher's lifecycle: `close()` stops the loop if
`isRunning()`, then drops the reference. The projection is pure — no
side effects, no extra DB calls — so projecting N messages costs O(N)
and never re-opens the database. The `attachments` array is always
present (possibly empty) so consumers can iterate without a
null-check.

View File

@@ -0,0 +1,87 @@
# `IMESSAGE_*` error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, fully-qualified error-code registry for
`@suaveplan/imessage-adapter`. Every error class in this module
inherits from `@suaveplan/error`'s `SuaveplanError` base and carries
one of the ten `IMESSAGE.*` codes returned by `defineCodes("IMESSAGE",
[...])`. Consumers downstream — loggers, problem+json renderers, retry
wrappers, OTel attribute writers — map on the code, never on the
message or class identity, so the codes are part of the public API.
## Features
- Ten production codes, each thrown from at least one real call site:
`DB_OPEN_FAILED`, `DB_QUERY_FAILED`, `WATCHER_NOT_RUNNING`,
`WATCHER_ALREADY_RUNNING`, `ADAPTER_NOT_INITIALIZED`,
`INVALID_CONFIG`, `POLL_ABORTED`, `POLL_FAILED`,
`ATTACHMENT_PARSE_FAILED`, `APPLE_DATE_PARSE_FAILED`.
- One typed error class per code, all extending the abstract
`IMessageError` so consumers can pattern-match via `instanceof
IMessageError` for a single catch site.
- `IMessageInvalidConfigError` accepts a `issues` payload that is
merged into `context` so callers can recover the Zod issue array
from problem+json bodies.
- Frozen `IMESSAGE_CODES` record keeps the literal-typed union
reachable through the public surface.
## Usage: Basic
```ts
import {
IMessageAdapterNotInitializedError,
} from "@suaveplan/imessage-adapter";
throw new IMessageAdapterNotInitializedError();
```
## Usage: Advanced
```ts
import {
IMessageError,
IMessageInvalidConfigError,
IMESSAGE_CODES,
} from "@suaveplan/imessage-adapter";
try {
parseWatcherConfig(input);
} catch (err) {
if (err instanceof IMessageInvalidConfigError) {
console.error(err.code, err.context["issues"]);
} else if (err instanceof IMessageError) {
switch (err.code) {
case IMESSAGE_CODES.WATCHER_ALREADY_RUNNING:
case IMESSAGE_CODES.WATCHER_NOT_RUNNING:
// recoverable
break;
default:
throw err;
}
} else {
throw err;
}
}
```
## API Reference
- `const IMESSAGE_CODES`: frozen record `{ DB_OPEN_FAILED:
"IMESSAGE.DB_OPEN_FAILED", ... }`.
- `type IMessageErrorCode`: union of every fully-qualified code.
- `abstract class IMessageError extends SuaveplanError<IMessageErrorCode>`.
- One concrete class per code, each accepting `{ cause?, context?,
traceId?, message? }`.
## Implementation Notes
The module side-effect-imports nothing; the registry is purely
declarative. The package barrel (`src/index.ts`) side-effect-imports
`./error-codes/index.js` so subpath consumers also observe the
registration. Messages default to i18n keys (`imessage.errors.*`) so
callers can supply their own resolver if they wish.

View File

@@ -0,0 +1,115 @@
# `createChatDbFixture`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds a real `chat.db`-shaped SQLite database on a temp path using
the structural `SqliteReader` seam. This is the foundation of the
test strategy: every watcher / adapter test runs against a real
`bun:sqlite` database whose schema mirrors Apple's Messages app,
populated via the helpers returned by this factory. There are no
mocks anywhere in the call chain — the SQL the watcher executes in
production runs unchanged against the fixture.
## Features
- DDL covers the six tables the watcher reads:
`message`, `handle`, `chat`, `chat_message_join`, `attachment`,
`message_attachment_join` — with the exact column names + types
Apple's chat.db uses.
- Helpers return the inserted ROWID via a `SELECT ROWID ORDER BY
ROWID DESC LIMIT 1` so tests can wire foreign-key joins
deterministically without depending on `last_insert_rowid()`
semantics across reader implementations.
- `insertMessage(row, chatId)` writes both the message and its
`chat_message_join` row so the watcher's `JOIN cmj` query finds
the chat without extra wiring.
- `insertAttachment(row, messageId)` writes both the attachment and
its `message_attachment_join` row.
- `close()` closes the handle so the temp file can be deleted by the
caller's `afterEach`.
## Usage: Basic
```ts
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createBunSqliteReader,
createChatDbFixture,
} from "@suaveplan/imessage-adapter";
const reader = createBunSqliteReader();
const fixture = createChatDbFixture(reader, join(tmpdir(), "chat.db"));
const chatId = fixture.insertChat({
guid: "iMessage;+;+15551234567",
chat_identifier: "+15551234567",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
});
fixture.close();
```
## Usage: Advanced
```ts
const handleId = fixture.insertHandle({
id: "+15551234567",
country: "us",
service: "iMessage",
uncanonicalized_id: null,
});
const messageId = fixture.insertMessage({
guid: "m-1",
text: "Hello",
handle_id: handleId,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 1,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
}, chatId);
fixture.insertAttachment({
guid: "a-1",
filename: "/tmp/photo.jpg",
mime_type: "image/jpeg",
total_bytes: 1024,
transfer_name: "photo.jpg",
}, messageId);
```
## API Reference
- `createChatDbFixture(reader, path): ChatDbFixture` — DDLs the schema
and returns the helpers.
- `ChatDbFixture.handle: SqliteHandle` — exposed for advanced custom
inserts.
- `insertHandle(row): number` — returns the new ROWID.
- `insertChat(row): number` — returns the new ROWID.
- `insertMessage(row, chatId?): number` — wires the
`chat_message_join` row when `chatId` is supplied.
- `insertAttachment(row, messageId): number` — wires the
`message_attachment_join` row.
- `close(): void` — closes the handle.
## Implementation Notes
The factory is server-tier-only because it requires a real SQLite
implementation, but it accepts any `SqliteReader` so a future
in-process SQLite-WASM reader could also drive the fixture. The
schema uses `AUTOINCREMENT` so insertion order maps to monotonically
increasing ROWIDs, which the watcher's `ROWID > lastRowId` cursor
relies on for incremental polling.

View File

@@ -0,0 +1,68 @@
# OpenTelemetry instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the four OpenTelemetry instruments emitted by
`@suaveplan/imessage-adapter` and the helpers that record onto them.
Instruments are resolved against the package-named `Meter`
(`@suaveplan/imessage-adapter` / `0.1.0`) so consumers can filter to
just this package in their dashboards. `refreshIMessageMeters()`
re-binds every instrument against the current global `MeterProvider`,
which tests use to redirect emissions to an in-memory exporter.
## Features
- Counter `imessage.messages_emitted_total` with `{chat_type,
from_me}` attributes — incremented once per parsed message yielded
by `IMessageWatcher.poll()`.
- Counter `imessage.attachments_emitted_total` with `{mime_family}` —
incremented once per parsed attachment.
- Counter `imessage.poll_errors_total` with `{code}` — incremented on
every poll-loop error, attributed by `IMESSAGE.*` code.
- Histogram `imessage.poll_duration_ms` with `{outcome}` (`ok` /
`error` / `aborted`) — records the wall-clock duration of every
completed (or cancelled) poll tick in milliseconds.
## Usage: Basic
```ts
import {
recordMessageEmitted,
recordPollDuration,
} from "@suaveplan/imessage-adapter/otel";
recordMessageEmitted("direct", false);
recordPollDuration(42, "ok");
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { refreshIMessageMeters } from "@suaveplan/imessage-adapter";
metrics.setGlobalMeterProvider(meterProvider);
refreshIMessageMeters(); // re-resolve so subsequent records land on this provider
```
## API Reference
- `recordMessageEmitted(chatType: "direct" | "group", fromMe: boolean): void`
- `recordAttachmentEmitted(mimeFamily: string): void`
- `recordPollError(code: string): void`
- `recordPollDuration(durationMs: number, outcome: "ok" | "error" | "aborted"): void`
- `refreshIMessageMeters(): void` — re-bind every instrument against the
current global `MeterProvider`.
## Implementation Notes
The instruments are stored in mutable module-level bindings so
`refreshIMessageMeters()` can swap them in-place; this is the
canonical pattern used by `slack-client` and the other
messaging clients. The `mime_family` attribute is computed by the
caller (typically the watcher) as the first segment of the MIME type
so cardinality stays bounded.

View File

@@ -0,0 +1,73 @@
# `IMessageParser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure-function parser that turns Apple's chat.db row shapes into the
`ParsedIMessage` projection consumed by every downstream surface in
this package. The parser handles Apple's per-row timestamp mode
(seconds vs nanoseconds since 2001-01-01), resolves nullable join
rows, and normalises phone-number handles into the canonical
`+E.164` form.
## Features
- `parseMessage(row, handle, chat, attachments)` — composes the row +
optional join rows into a `ParsedIMessage`. Null handles project
to `"unknown"`, null chat identifiers to `""`, and the group-chat
flag is set when the chat identifier starts with `"chat"`.
- `parseAppleDate(n)` — auto-detects seconds vs nanoseconds against
the `10^9` threshold, applies the 2001-01-01 Apple epoch offset,
and throws `IMessageAppleDateParseError` on `NaN` / non-finite
input rather than silently returning a garbage `Date`.
- `parseAttachment(row)` — picks `transfer_name` over `filename` over
`"unknown"` and defaults the MIME type to
`"application/octet-stream"` when chat.db omits it.
- `isGroupChat(chatIdentifier)` — exposes the same chat-vs-handle
classifier used internally so consumers can reuse it for routing.
- `normalizePhoneNumber(handle)` — strips non-digit characters and
prefixes 10-digit US numbers with `+1`, 11-digit US numbers with
`+`; email handles pass through unchanged.
## Usage: Basic
```ts
import { IMessageParser } from "@suaveplan/imessage-adapter/parser";
const parser = new IMessageParser();
const sent = parser.parseAppleDate(700_000_000);
```
## Usage: Advanced
```ts
import { IMessageParser } from "@suaveplan/imessage-adapter";
const parser = new IMessageParser();
const parsed = parser.parseMessage(messageRow, handleRow, chatRow, attachments);
if (parsed.isGroupChat) {
route(parsed, parser.normalizePhoneNumber(parsed.senderHandle));
}
```
## API Reference
- `parseMessage(row, handle, chat, attachments?): ParsedIMessage`
- `parseAppleDate(n: number): Date` — throws
`IMessageAppleDateParseError` for `NaN` / non-finite.
- `parseAttachment(row): ParsedAttachment`
- `isGroupChat(chatIdentifier: string): boolean`
- `normalizePhoneNumber(handle: string): string`
## Implementation Notes
The parser is intentionally side-effect-free and instance-based (no
static methods) so consumers can subclass it to override individual
steps — for example, to swap in a locale-aware phone normaliser. The
Apple-epoch threshold detection runs per-row because Apple's schema
shipped both forms historically; checking the magnitude is robust
across the migration window. Errors are routed through the
`IMESSAGE.*` registry so consumers can map by code.

View File

@@ -0,0 +1,80 @@
# Config schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod boundary schemas for `IMessageWatcherConfig` and
`IMessageAdapterConfig`, plus the `parseWatcherConfig` /
`parseAdapterConfig` helpers that callers use to validate untrusted
input before constructing a watcher or adapter. Both schemas are
strict — extra keys are rejected — so consumers cannot silently
smuggle additional fields past the boundary.
## Features
- Strict object validation (`.strict()`) — unknown properties are
rejected at the boundary, preventing prototype-pollution-style
smuggling.
- `pollIntervalMs` constrained to positive integers; `sinceDate`
required to be a `Date` instance (no millisecond-since-epoch
numbers); `chatFilter` constrained to string arrays.
- Custom `SqliteReaderSchema` confirms the optional injected
`sqliteReader` exposes a callable `open()` member.
- Failures throw `IMessageInvalidConfigError`
(`IMESSAGE.INVALID_CONFIG`) carrying a serialisable `issues` array
in `context` so problem+json responders can surface the Zod path +
reason without re-running the schema.
## Usage: Basic
```ts
import { parseWatcherConfig } from "@suaveplan/imessage-adapter/schemas";
const config = parseWatcherConfig({ pollIntervalMs: 5000 });
```
## Usage: Advanced
```ts
import {
parseWatcherConfig,
IMessageInvalidConfigError,
} from "@suaveplan/imessage-adapter";
try {
parseWatcherConfig({ pollIntervalMs: -1, oops: true });
} catch (err) {
if (err instanceof IMessageInvalidConfigError) {
return Response.json(
{ issues: err.context["issues"] },
{ status: 400 },
);
}
throw err;
}
```
## API Reference
- `IMessageWatcherConfigSchema: z.ZodObject` — validates watcher config.
- `IMessageAdapterConfigSchema` — alias for the watcher schema.
- `SqliteReaderSchema: z.ZodType<unknown>` — structural check for
`SqliteReader`.
- `parseWatcherConfig(input: unknown): IMessageWatcherConfig` — throws
`IMessageInvalidConfigError` on failure.
- `parseAdapterConfig(input: unknown): IMessageAdapterConfig` — throws
`IMessageInvalidConfigError` on failure.
## Implementation Notes
The schemas use `z.custom` for the `SqliteReader` slot because the
reader is a structural type — there is no Zod-expressible shape that
captures every legal implementation. The check confirms the presence
of a function-typed `open` member, which is enough to surface
mistakes (passing a stray object or `null`) without inspecting the
reader's behaviour. Issue arrays are projected through `toIssues`
which strips Zod's internal symbols so the resulting record is safe
to JSON-serialise.

View File

@@ -0,0 +1,76 @@
# `bunSqliteReader`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite`
driver. The reader is the single seam through which
`IMessageWatcher` reaches Apple's `chat.db` SQLite database; replacing
upstream's `require("bun:sqlite") as any` cast with a typed,
swappable factory lets tests inject a reader pointing at a real temp
chat.db without re-implementing the SQL layer.
## Features
- Lazy module resolution via `createRequire(import.meta.url)` — the
`bun:sqlite` import is not added to the static graph, so vite and
vitest do not attempt to resolve it on non-Bun runners during build
/ type-check / lint passes.
- Errors thrown from the load step or the `new Database(...)` call
are wrapped in `IMessageDbOpenFailedError` carrying the offending
path on `context.path`, so the watcher can surface a stable error
code to consumers.
- Returns a `SqliteHandle` whose `execScript` member maps to
`Database.run` (Bun's bulk-SQL entry point) and whose `prepare`
member returns a `SqliteStatement` wrapping Bun's per-statement
`all` / `get` / `run` methods structurally — production and test
code share the exact same call shape.
- Read-only by default: `open(path, { readonly: true })` opens the
database read-only as required for `~/Library/Messages/chat.db`,
while tests open with `readonly: false` to populate the fixture.
## Usage: Basic
```ts
import { bunSqliteReader } from "@suaveplan/imessage-adapter";
const handle = bunSqliteReader.open("/path/to/chat.db", { readonly: true });
const rows = handle.prepare("SELECT ROWID FROM message LIMIT 1").all();
handle.close();
```
## Usage: Advanced
```ts
import { createBunSqliteReader } from "@suaveplan/imessage-adapter";
// Inject a custom loader (e.g. for vitest under WSL2 where bun:sqlite is
// reached through a different worker bridge).
const reader = createBunSqliteReader(() => require("bun:sqlite"));
const handle = reader.open(path, { readonly: false });
handle.execScript("CREATE TABLE foo (id INTEGER PRIMARY KEY)");
```
## API Reference
- `bunSqliteReader: SqliteReader` — default reader, ready for
production wiring.
- `createBunSqliteReader(loader?: BunSqliteLoader): SqliteReader` —
factory accepting a custom loader; defaults to a
`createRequire`-based one that resolves `bun:sqlite` against the
module's own URL.
- `BunSqliteLoader = () => BunSqliteModule` — the loader contract.
## Implementation Notes
`createRequire(import.meta.url)` is the documented Node + Bun bridge
for resolving CommonJS / built-in modules from ESM without statically
declaring them. We use it because static `import "bun:sqlite"` would
make vite resolve the specifier at build time and fail on non-Bun
hosts (CI, lint). The `BunDatabase` interface is intentionally
minimal — it only declares the methods the watcher actually calls,
so changes to Bun's broader Database surface cannot bleed into the
package.

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.