# Capability Spec Delta: imessage-adapter (imessage-adapter) **Package**: `@suaveplan/imessage-adapter` **Status**: SHIPPED > Every requirement below carries a `**Verified by:**` line at authoring time. ## Requirements ### Requirement: Public package surface The package SHALL export, from its root barrel `src/index.ts`, every upstream symbol: `IMessageAdapter`, `IMessageWatcher`, `IMessageParser`, the `InboundMessage` / `MessageSourceAdapter` interfaces, the `IMessageWatcherEvents` event map, the four row-shape interfaces (`IMessageRow`, `HandleRow`, `ChatRow`, `AttachmentRow`), the parsed shapes (`ParsedIMessage`, `ParsedAttachment`), and the two config shapes (`IMessageAdapterConfig`, `IMessageWatcherConfig`). It SHALL additionally export the genesis-only additions: the `IMESSAGE_CODES` registry + `IMessageErrorCode` type, every typed error class, every Zod schema, the `SqliteReader` seam, the `bunSqliteReader` factory, and the four OTel record functions. #### Scenario: every upstream symbol is re-exported - **WHEN** a consumer imports from `@suaveplan/imessage-adapter` - **THEN** the imported module exposes `IMessageAdapter`, `IMessageWatcher`, `IMessageParser`, `InboundMessage` (type), `MessageSourceAdapter` (type), `IMessageWatcherEvents` (type), all four row types, both parsed types, and both config types **Verified by:** `src/adapter/imessage-adapter.test.ts::"re-exports the full upstream surface"` --- ### Requirement: Stable error-code registry The package SHALL register exactly ten error codes via `defineCodes("IMESSAGE", [...])`: `DB_OPEN_FAILED`, `DB_QUERY_FAILED`, `WATCHER_NOT_RUNNING`, `WATCHER_ALREADY_RUNNING`, `ADAPTER_NOT_INITIALIZED`, `INVALID_CONFIG`, `POLL_ABORTED`, `POLL_FAILED`, `ATTACHMENT_PARSE_FAILED`, `APPLE_DATE_PARSE_FAILED`. Every code SHALL be reachable from at least one production call site and SHALL be asserted by a test that triggers the real call site (no synthetic `throw new IMessageError(code)` in test code). #### Scenario: every code is reachable from a real call site - **WHEN** the package is fully built - **THEN** for each of the ten codes there exists at least one test that triggers the production call site whose `throw` carries that code **Verified by:** `src/error-codes/error-codes.test.ts::"every IMESSAGE code is reachable from a production call site"` --- ### Requirement: Zod boundary on configuration The package SHALL validate every `IMessageWatcherConfig` and `IMessageAdapterConfig` passed through public constructors / methods with a Zod schema. Invalid input SHALL throw an `IMessageInvalidConfigError` carrying the Zod issues as `context.issues`. #### Scenario: invalid pollIntervalMs is rejected at the boundary - **WHEN** `new IMessageWatcher({ pollIntervalMs: -1 })` is called - **THEN** the constructor throws `IMessageInvalidConfigError` whose `code` is `IMESSAGE.INVALID_CONFIG` and whose `context.issues` is a non-empty array **Verified by:** `src/schemas/schemas.test.ts::"rejects negative pollIntervalMs"` AND `src/watcher/imessage-watcher.test.ts::"constructor rejects invalid config via Zod"` --- ### Requirement: Apple date decoding `IMessageParser.parseAppleDate(n)` SHALL convert the iMessage SQLite `date` column (seconds OR nanoseconds since the 2001-01-01 Apple epoch) to a JavaScript `Date` representing the corresponding UNIX instant. A `0` input SHALL return the UNIX epoch (`new Date(0)`). Inputs above 10^9 SHALL be treated as nanoseconds and divided by 1_000_000_000 before being added to the Apple-epoch offset. #### Scenario: seconds-mode timestamp decodes to a 2023+ date - **WHEN** `parseAppleDate(700_000_000)` is called - **THEN** the returned `Date` has `getFullYear() >= 2023` #### Scenario: nanoseconds-mode timestamp decodes to the same date - **WHEN** `parseAppleDate(700_000_000 * 1_000_000_000)` is called - **THEN** the returned `Date` has `getFullYear() >= 2023` #### Scenario: zero returns the UNIX epoch - **WHEN** `parseAppleDate(0)` is called - **THEN** the returned `Date` has `getTime() === 0` **Verified by:** `src/parser/imessage-parser.test.ts::"parseAppleDate decodes seconds"` AND `src/parser/imessage-parser.test.ts::"parseAppleDate decodes nanoseconds"` AND `src/parser/imessage-parser.test.ts::"parseAppleDate returns epoch for zero"` --- ### Requirement: Handle / chat / attachment resolution `IMessageParser.parseMessage(row, handle, chat, attachments)` SHALL return a `ParsedIMessage` whose `senderHandle` equals `handle?.id` when handle is non-null and `"unknown"` otherwise, whose `chatIdentifier` equals `chat?.chat_identifier ?? ""`, whose `isGroupChat` is true iff `chat?.chat_identifier` starts with `"chat"`, whose `isFromMe`, `isRead` are boolean projections of the matching `0|1` row columns, whose `text` is `row.text ?? ""`, and whose `attachments` are the supplied attachment rows mapped via `parseAttachment`. #### Scenario: null handle resolves to "unknown" - **WHEN** `parseMessage(row, null, chat)` is called - **THEN** the result's `senderHandle` equals `"unknown"` #### Scenario: chat identifier starting with "chat" is flagged group - **WHEN** `parseMessage(row, handle, { chat_identifier: "chat123", ...})` is called - **THEN** the result's `isGroupChat` is `true` #### Scenario: null text projects to empty string - **WHEN** `parseMessage({ text: null, ... }, handle, chat)` is called - **THEN** the result's `text` equals `""` **Verified by:** `src/parser/imessage-parser.test.ts::"parseMessage projects null handle to unknown"` AND `src/parser/imessage-parser.test.ts::"parseMessage flags group chats"` AND `src/parser/imessage-parser.test.ts::"parseMessage projects null text to empty string"` --- ### Requirement: Phone normalisation `IMessageParser.normalizePhoneNumber(handle)` SHALL strip every non-digit character. If the resulting digit-only string has length 10 it SHALL be prefixed with `"+1"`. If it has length 11 and starts with `"1"` it SHALL be prefixed with `"+"`. Otherwise the original input SHALL be returned unchanged. #### Scenario: 10-digit US number is canonicalised - **WHEN** `normalizePhoneNumber("5551234567")` is called - **THEN** the result is `"+15551234567"` #### Scenario: formatted 10-digit number is canonicalised - **WHEN** `normalizePhoneNumber("(555) 123-4567")` is called - **THEN** the result is `"+15551234567"` #### Scenario: email handle passes through unchanged - **WHEN** `normalizePhoneNumber("user@icloud.com")` is called - **THEN** the result is `"user@icloud.com"` **Verified by:** `src/parser/imessage-parser.test.ts::"normalizePhoneNumber handles 10-digit"` AND `src/parser/imessage-parser.test.ts::"normalizePhoneNumber handles formatted"` AND `src/parser/imessage-parser.test.ts::"normalizePhoneNumber leaves emails untouched"` --- ### Requirement: Injectable SQLite reader seam The package SHALL expose a structural `SqliteReader` interface and the `IMessageWatcher` SHALL accept an injected `sqliteReader` in its config. When `sqliteReader` is omitted the watcher SHALL lazily resolve `bunSqliteReader` (which dynamically imports `bun:sqlite`). There SHALL be no `require("bun:sqlite") as any` cast anywhere in the source. #### Scenario: tests inject a SqliteReader against a real temp chat.db - **WHEN** the watcher is constructed with `{ sqliteReader }` pointing at a real temp SQLite database populated by the chat-db fixture - **THEN** `poll()` returns the parsed messages whose underlying rows were inserted by the fixture, with no `vi.mock` or stubbed module **Verified by:** `src/watcher/imessage-watcher.test.ts::"poll yields fixture-inserted messages via injected SqliteReader"` --- ### Requirement: Polling loop honours rowid + filters `IMessageWatcher.poll()` SHALL query the SQLite `message` table for rows with `ROWID > lastRowId` (descending floor exclusive), join-resolve the handle, chat, and attachment rows, parse each row via `IMessageParser`, advance `lastRowId` to the highest emitted `ROWID`, apply the `includeFromMe` and `chatFilter` config filters, emit a `message` event per surviving row on the EventBus, and return the surviving parsed messages. On any thrown error the watcher SHALL emit an `error` event and return an empty array. #### Scenario: poll advances lastRowId monotonically - **WHEN** poll runs against a fixture with rows at ROWID 1..3 - **THEN** after the call `getLastRowId()` returns `3` #### Scenario: includeFromMe filter drops outgoing messages - **WHEN** poll runs with `includeFromMe: false` against a fixture containing one inbound and one outbound message - **THEN** the returned array contains only the inbound message #### Scenario: chatFilter narrows by chat_identifier - **WHEN** poll runs with `chatFilter: ["chat-A"]` against a fixture with messages in `chat-A` and `chat-B` - **THEN** the returned array contains only `chat-A` messages **Verified by:** `src/watcher/imessage-watcher.test.ts::"poll advances lastRowId"` AND `src/watcher/imessage-watcher.test.ts::"includeFromMe=false drops outgoing"` AND `src/watcher/imessage-watcher.test.ts::"chatFilter restricts emitted messages"` --- ### Requirement: AbortSignal propagation `IMessageWatcher.start({ signal })` and `IMessageWatcher.poll({ signal })` SHALL accept a `signal: AbortSignal`. A pre-aborted signal SHALL short-circuit `poll` to throw `IMessagePollAbortedError` (`IMESSAGE.POLL_ABORTED`). Aborting mid-schedule SHALL clear the poll timer and resolve `stop()` immediately. #### Scenario: pre-aborted signal short-circuits poll - **WHEN** `poll({ signal })` is called with a pre-aborted signal - **THEN** the call rejects with `IMessagePollAbortedError` whose code is `IMESSAGE.POLL_ABORTED` #### Scenario: aborting mid-schedule stops the loop - **WHEN** `start({ signal })` has been called and signal is aborted - **THEN** subsequent ticks do not schedule further polls and a `stopped` event fires on the bus **Verified by:** `src/watcher/imessage-watcher.test.ts::"poll rejects on pre-aborted signal"` AND `src/watcher/imessage-watcher.test.ts::"start aborts when signal fires"` --- ### Requirement: EventBus bridge `IMessageWatcher` SHALL expose `bus: EventBus` as a public read-only property AND retain the legacy `on(event, listener)` / `off(event, listener)` methods, which SHALL delegate to the bus. The four event topics SHALL be `message` / `error` / `started` / `stopped`. #### Scenario: legacy on() delivers events via the bus - **WHEN** a listener is registered with `watcher.on("message", fn)` - **THEN** invoking the bus's emit with a `message` envelope causes `fn` to be invoked exactly once with the payload **Verified by:** `src/watcher/imessage-watcher.test.ts::"legacy on() delegates to the bus"` --- ### Requirement: Adapter — MessageSourceAdapter contract `IMessageAdapter` SHALL implement `MessageSourceAdapter` with `type === "imessage"`, a `Promise`-returning `initialize(config)`, a `Promise`-returning `poll()`, synchronous `getLastProcessedId()` / `markProcessed(id)`, and a `Promise` `close()`. `poll()` called before `initialize` SHALL throw `IMessageAdapterNotInitializedError` (`IMESSAGE.ADAPTER_NOT_INITIALIZED`). #### Scenario: poll-before-initialize throws the registered code - **WHEN** `new IMessageAdapter().poll()` is called - **THEN** the rejection carries `code === "IMESSAGE.ADAPTER_NOT_INITIALIZED"` #### Scenario: markProcessed sets the last-processed-id slot - **WHEN** `markProcessed("123")` is called and `getLastProcessedId()` is read - **THEN** the returned value is `"123"` **Verified by:** `src/adapter/imessage-adapter.test.ts::"poll-before-initialize throws ADAPTER_NOT_INITIALIZED"` AND `src/adapter/imessage-adapter.test.ts::"markProcessed updates the slot"` --- ### Requirement: InboundMessage projection `IMessageAdapter.poll()` SHALL project each `ParsedIMessage` into an `InboundMessage` with `sourceChannel: "imessage"`, `sourceAddress` equal to the phone-normalised sender handle, `content` equal to the parsed text, `contentType: "attachment"` when the parsed message has ≥1 attachment and `"text"` otherwise, `receivedAt` equal to `parsed.sentAt`, `rawPayload` equal to the parsed message, and a `metadata` block carrying `chatIdentifier`, `chatDisplayName`, `isGroupChat`, `senderService`, and `isRead`. #### Scenario: text message projects without attachments - **WHEN** the adapter polls a text-only parsed message with sender `5551234567` - **THEN** the returned InboundMessage has `sourceAddress === "+15551234567"`, `contentType === "text"`, and `attachments === []` **Verified by:** `src/adapter/imessage-adapter.test.ts::"poll projects ParsedIMessage to InboundMessage"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL emit four OTel instruments via `@opentelemetry/api`: the counter `imessage.messages_emitted_total` on every message emitted by the watcher, the counter `imessage.poll_errors_total` on every poll-loop error, the counter `imessage.attachments_emitted_total` on every attachment emitted, and the histogram `imessage.poll_duration_ms` on every completed poll. The package SHALL export `refreshIMessageMeters()` that re-resolves every instrument against the current global `MeterProvider` so tests can capture emissions via an in-memory exporter. #### Scenario: a successful poll emits both counter and histogram - **WHEN** a poll completes and emits two messages - **THEN** the in-memory exporter records `imessage.messages_emitted_total` incremented by 2 AND `imessage.poll_duration_ms` records exactly one observation **Verified by:** `src/otel/metrics.test.ts::"recordMessageEmitted increments the counter"` AND `src/watcher/imessage-watcher.test.ts::"poll emits messages_emitted_total + poll_duration_ms"` --- ### Requirement: Real-fixture testing (no mocks) Every test in the package SHALL exercise real production code paths: parser tests use real row objects, watcher tests use a real `bun:sqlite` database on a real temp file populated by `createChatDbFixture`, adapter tests use a real watcher instance. The package SHALL NOT use `vi.mock`, `jest.mock`, or `bun:test`'s module-mocking primitives anywhere in `src/` or `tests/`. #### Scenario: no module-level mocks exist anywhere in tree - **WHEN** the source is grepped for `vi.mock|jest.mock|mock.module` - **THEN** the match count is zero **Verified by:** `src/watcher/imessage-watcher.test.ts::"watcher tests run against a real bun:sqlite fixture"` --- ### Requirement: Test-runner discipline All `*.test.ts` files SHALL import `describe`, `it`, `expect`, `beforeEach`, `afterEach`, `vi` (when needed) from `@suaveplan/testing/runner` — never directly from `vitest` or `bun:test`. The package SHALL NOT appear on the CI test-runner-imports allowlist. #### Scenario: grep finds no direct vitest or bun:test imports - **WHEN** the source is grepped for `from ['"](vitest|bun:test)['"]` - **THEN** the match count is zero **Verified by:** `src/adapter/imessage-adapter.test.ts::"imports from @suaveplan/testing/runner"` --- ### Requirement: Documentation coverage Every `.ts` file in `src/` other than barrel `index.ts`, `*.test.ts`, and `*.types.ts` SHALL have a sibling `.md` file of at least 200 words containing the six required sections (Purpose ≥2 sentences, Features, Usage: Basic, Usage: Advanced, API Reference, Implementation Notes). The package SHALL also carry a root `README.md`. #### Scenario: docs:check reports zero missing docs - **WHEN** `bun run docs:check` runs against the package - **THEN** it exits 0 with no missing-doc warnings **Verified by:** `src/adapter/imessage-adapter.test.ts::"package carries a sibling .md for every implementation file"` --- ### Requirement: Subpath exports per feature module The package `exports` map SHALL surface every feature folder as a subpath export (`./error-codes`, `./schemas`, `./parser`, `./watcher`, `./adapter`, `./sqlite/bun-sqlite-reader`, `./otel`, `./fixtures`) so tree-shaking consumers can pull a single module without dragging the whole package. #### Scenario: subpath import resolves the targeted module - **WHEN** a consumer imports `@suaveplan/imessage-adapter/parser` - **THEN** the resolved module exports `IMessageParser` and nothing outside the parser feature **Verified by:** `src/adapter/imessage-adapter.test.ts::"every feature folder is reachable as a subpath export"`