Initial Commit
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: imessage-adapter (imessage-adapter)
|
||||
|
||||
**Package**: `@suaveplan/imessage-adapter`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED 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"`
|
||||
Reference in New Issue
Block a user