9.0 KiB
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
┌─────────────────────────────────────────────────────────────────┐
│ 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
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:
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:
- Opens the database at
pathvia the injected reader (write mode). - Creates the
message,handle,chat,chat_message_join,attachment,message_attachment_jointables with the chat.db-compatible columns the watcher reads. - Returns helper
insertMessage,insertHandle,insertChat,insertAttachmentfunctions 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 throwIMessagePollAbortedError; the watcher emitserroron the bus and the scheduler keeps running for any subsequent ticks unlessstop()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.