Initial Commit
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user