Files
2026-05-27 23:29:53 +01:00

16 KiB

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"