Files
suaveplan/openspec/archive/2026-05-25-enterprise-imessage-adapter/design.md
2026-05-27 23:29:53 +01:00

204 lines
9.0 KiB
Markdown

# Design: `enterprise-imessage-adapter`
Non-normative architecture, algorithm, and file-layout notes for the
genesis port of `@suaveplan/imessage-adapter`. Every normative
statement lives in `./specs/imessage-adapter/spec.md`; this document
is purely the "why and how".
## Architecture
```text
┌─────────────────────────────────────────────────────────────────┐
│ IMessageAdapter │
│ (MessageSourceAdapter implementation; type = "imessage") │
│ │
│ initialize(config) → poll() → InboundMessage[] │
│ getLastProcessedId() / markProcessed() / close() │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ IMessageWatcher │
│ │
│ start({signal}) → schedules polls every pollIntervalMs │
│ stop() │
│ poll({signal}) → ParsedIMessage[] │
│ │
│ bus: EventBus<IMessageWatcherEvents> │
│ on/off/emit (legacy listener API → bus) │
│ getLastRowId() / setLastRowId() │
└──────────┬───────────────────────────┬──────────────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────────────────┐
│ SqliteReader (seam) │ │ IMessageParser │
│ │ │ │
│ open(path) → Handle │ │ parseMessage(row,handle,chat, │
│ prepare(sql) │ │ attachments) │
│ stmt.all(params) │ │ parseAppleDate(n) │
│ stmt.get(params) │ │ parseAttachment(row) │
│ close() │ │ isGroupChat(id) │
│ │ │ normalizePhoneNumber(s) │
│ Production: bun:sqlite│ │ │
│ Tests: bun:sqlite │ │ │
│ on temp file │ │ │
└────────────────────────┘ └────────────────────────────────────┘
```
## File layout
```text
packages/communication/messaging/imessage-adapter/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts # package barrel (named exports only)
error-codes/
error-codes.ts # defineCodes("IMESSAGE", [...]) + typed errors
error-codes.md
error-codes.test.ts
index.ts # named re-exports
schemas/
schemas.ts # Zod schemas (config, row, parsed)
schemas.md
schemas.test.ts
index.ts
types/
imessage.types.ts # row + parsed + config interfaces (.types — no .md)
adapter.types.ts # InboundMessage + MessageSourceAdapter (.types — no .md)
seams.types.ts # SqliteReader, SqliteHandle, SqliteStatement (.types — no .md)
sqlite/
bun-sqlite-reader.ts # production SqliteReader backed by bun:sqlite
bun-sqlite-reader.md
bun-sqlite-reader.test.ts # against a real temp chat.db fixture
index.ts
parser/
imessage-parser.ts # parseMessage, parseAppleDate, parseAttachment, isGroupChat, normalizePhoneNumber
imessage-parser.md
imessage-parser.test.ts
index.ts
watcher/
imessage-watcher.ts # IMessageWatcher (poll loop, EventBus, AbortSignal)
imessage-watcher.md
imessage-watcher.test.ts # real SQLite fixture; full poll loop
index.ts
adapter/
imessage-adapter.ts # IMessageAdapter (MessageSourceAdapter impl)
imessage-adapter.md
imessage-adapter.test.ts
index.ts
otel/
metrics.ts # counters + histograms (refreshable for tests)
metrics.md
metrics.test.ts
index.ts
fixtures/
chat-db-fixture.ts # builds a real temp chat.db with bun:sqlite for tests
chat-db-fixture.md
chat-db-fixture.test.ts
index.ts
tests/
setup/
polyfills.ts # OTel in-memory meter wiring (mirrors slack-client)
```
## SqliteReader seam
The upstream package reaches `bun:sqlite` through `require("bun:sqlite")
as any`. Genesis replaces this with a structural seam:
```ts
export interface SqliteStatement {
all(...params: unknown[]): unknown[];
get(...params: unknown[]): unknown | undefined;
}
export interface SqliteHandle {
prepare(sql: string): SqliteStatement;
close(): void;
}
export interface SqliteReader {
open(path: string, options: { readonly: boolean }): SqliteHandle;
}
```
Production wiring: `bunSqliteReader` lazily imports `bun:sqlite` via
`import("bun:sqlite")` inside `open()` and wraps the resulting
`Database` in a `SqliteHandle`. The lazy import means tests on non-Bun
runtimes never trip the import; the watcher accepts an injected reader
so test runs feed it a reader backed by an in-process bun:sqlite
database populated from a chat.db-shaped fixture. There is no
`vi.mock`, no stub, no fake — both production and test paths use
`bun:sqlite` directly.
## chat.db fixture
`src/fixtures/chat-db-fixture.ts` exports `createChatDbFixture(reader,
path)` which:
1. Opens the database at `path` via the injected reader (write mode).
2. Creates the `message`, `handle`, `chat`, `chat_message_join`,
`attachment`, `message_attachment_join` tables with the
chat.db-compatible columns the watcher reads.
3. Returns helper `insertMessage`, `insertHandle`, `insertChat`,
`insertAttachment` functions tests use to build scenarios.
This is a real SQLite database on a real temp file — no shortcuts, no
in-memory faking of the query interface. The fixture is itself
covered by `chat-db-fixture.test.ts` so the coverage gate stays at
100/100/100/100.
## EventBus bridge
`IMessageWatcher` carries a `bus: EventBus<IMessageWatcherEvents>`
field as the canonical event surface. The legacy `on(event, listener)`
and `off(event, listener)` methods delegate to the bus's `on()` /
returned `Unsubscribe` machinery so callers using the old surface
still work, but `getBus()` returns the underlying `EventBus` for new
consumers that want interceptors, idempotency, observability adapters,
and bounded queuing.
## OTel metric refresh
Following the slack-client pattern, `metrics.ts` exports
`refreshIMessageMeters()` that re-resolves every instrument against
the current global `MeterProvider`. Tests wire the in-memory provider
in `tests/setup/polyfills.ts` before any source module loads, then call
`refreshIMessageMeters()` so subsequent `recordMessageEmitted` etc.
calls land on the test exporter.
## AbortSignal
`start({ signal })` and `poll({ signal })` both accept a signal.
- A pre-aborted signal short-circuits before any DB work and rejects
with `IMessagePollAbortedError`.
- Aborting mid-scheduler clears the poll timer and resolves `stop()`
immediately.
- Aborting mid-poll causes the in-flight `poll()` to throw
`IMessagePollAbortedError`; the watcher emits `error` on the bus and
the scheduler keeps running for any subsequent ticks unless `stop()`
is called.
## Tier
Server-tier (macOS-only at runtime; consumes `bun:sqlite`). The
`bun:sqlite` import lives behind a dynamic `import()` so the package
type-checks and unit-tests on any platform that has `bun:sqlite`
available (Bun on macOS / Linux / Windows). Non-Bun environments are
out of scope for this package — the upstream is `bun:sqlite`-only and
genesis does not introduce a fallback engine.