204 lines
9.0 KiB
Markdown
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.
|