Files
2026-05-27 23:29:53 +01:00

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:

  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.