Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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"`