395 lines
16 KiB
Markdown
395 lines
16 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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<IMessageWatcherEvents>`
|
|
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<void>`-returning `initialize(config)`,
|
|
a `Promise<InboundMessage[]>`-returning `poll()`, synchronous
|
|
`getLastProcessedId()` / `markProcessed(id)`, and a `Promise<void>`
|
|
`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"`
|