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
throwcarries 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
IMessageInvalidConfigErrorwhosecodeisIMESSAGE.INVALID_CONFIGand whosecontext.issuesis 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
DatehasgetFullYear() >= 2023
Scenario: nanoseconds-mode timestamp decodes to the same date
- WHEN
parseAppleDate(700_000_000 * 1_000_000_000)is called - THEN the returned
DatehasgetFullYear() >= 2023
Scenario: zero returns the UNIX epoch
- WHEN
parseAppleDate(0)is called - THEN the returned
DatehasgetTime() === 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
senderHandleequals"unknown"
Scenario: chat identifier starting with "chat" is flagged group
- WHEN
parseMessage(row, handle, { chat_identifier: "chat123", ...})is called - THEN the result's
isGroupChatistrue
Scenario: null text projects to empty string
- WHEN
parseMessage({ text: null, ... }, handle, chat)is called - THEN the result's
textequals""
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 novi.mockor 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()returns3
Scenario: includeFromMe filter drops outgoing messages
- WHEN poll runs with
includeFromMe: falseagainst 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 inchat-Aandchat-B - THEN the returned array contains only
chat-Amessages
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
IMessagePollAbortedErrorwhose code isIMESSAGE.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
stoppedevent 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
messageenvelope causesfnto 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 andgetLastProcessedId()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", andattachments === []
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_totalincremented by 2 ANDimessage.poll_duration_msrecords 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:checkruns 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
IMessageParserand nothing outside the parser feature
Verified by: src/adapter/imessage-adapter.test.ts::"every feature folder is reachable as a subpath export"