Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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