# 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 │ │ 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` 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.