Initial Commit
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# Design: `enterprise-imessage-adapter`
|
||||
|
||||
Non-normative architecture, algorithm, and file-layout notes for the
|
||||
genesis port of `@suaveplan/imessage-adapter`. Every normative
|
||||
statement lives in `./specs/imessage-adapter/spec.md`; this document
|
||||
is purely the "why and how".
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ IMessageAdapter │
|
||||
│ (MessageSourceAdapter implementation; type = "imessage") │
|
||||
│ │
|
||||
│ initialize(config) → poll() → InboundMessage[] │
|
||||
│ getLastProcessedId() / markProcessed() / close() │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ IMessageWatcher │
|
||||
│ │
|
||||
│ start({signal}) → schedules polls every pollIntervalMs │
|
||||
│ stop() │
|
||||
│ poll({signal}) → ParsedIMessage[] │
|
||||
│ │
|
||||
│ bus: EventBus<IMessageWatcherEvents> │
|
||||
│ on/off/emit (legacy listener API → bus) │
|
||||
│ getLastRowId() / setLastRowId() │
|
||||
└──────────┬───────────────────────────┬──────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────────────────┐
|
||||
│ SqliteReader (seam) │ │ IMessageParser │
|
||||
│ │ │ │
|
||||
│ open(path) → Handle │ │ parseMessage(row,handle,chat, │
|
||||
│ prepare(sql) │ │ attachments) │
|
||||
│ stmt.all(params) │ │ parseAppleDate(n) │
|
||||
│ stmt.get(params) │ │ parseAttachment(row) │
|
||||
│ close() │ │ isGroupChat(id) │
|
||||
│ │ │ normalizePhoneNumber(s) │
|
||||
│ Production: bun:sqlite│ │ │
|
||||
│ Tests: bun:sqlite │ │ │
|
||||
│ on temp file │ │ │
|
||||
└────────────────────────┘ └────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/communication/messaging/imessage-adapter/
|
||||
package.json
|
||||
tsconfig.lib.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
README.md
|
||||
src/
|
||||
index.ts # package barrel (named exports only)
|
||||
|
||||
error-codes/
|
||||
error-codes.ts # defineCodes("IMESSAGE", [...]) + typed errors
|
||||
error-codes.md
|
||||
error-codes.test.ts
|
||||
index.ts # named re-exports
|
||||
|
||||
schemas/
|
||||
schemas.ts # Zod schemas (config, row, parsed)
|
||||
schemas.md
|
||||
schemas.test.ts
|
||||
index.ts
|
||||
|
||||
types/
|
||||
imessage.types.ts # row + parsed + config interfaces (.types — no .md)
|
||||
adapter.types.ts # InboundMessage + MessageSourceAdapter (.types — no .md)
|
||||
seams.types.ts # SqliteReader, SqliteHandle, SqliteStatement (.types — no .md)
|
||||
|
||||
sqlite/
|
||||
bun-sqlite-reader.ts # production SqliteReader backed by bun:sqlite
|
||||
bun-sqlite-reader.md
|
||||
bun-sqlite-reader.test.ts # against a real temp chat.db fixture
|
||||
index.ts
|
||||
|
||||
parser/
|
||||
imessage-parser.ts # parseMessage, parseAppleDate, parseAttachment, isGroupChat, normalizePhoneNumber
|
||||
imessage-parser.md
|
||||
imessage-parser.test.ts
|
||||
index.ts
|
||||
|
||||
watcher/
|
||||
imessage-watcher.ts # IMessageWatcher (poll loop, EventBus, AbortSignal)
|
||||
imessage-watcher.md
|
||||
imessage-watcher.test.ts # real SQLite fixture; full poll loop
|
||||
index.ts
|
||||
|
||||
adapter/
|
||||
imessage-adapter.ts # IMessageAdapter (MessageSourceAdapter impl)
|
||||
imessage-adapter.md
|
||||
imessage-adapter.test.ts
|
||||
index.ts
|
||||
|
||||
otel/
|
||||
metrics.ts # counters + histograms (refreshable for tests)
|
||||
metrics.md
|
||||
metrics.test.ts
|
||||
index.ts
|
||||
|
||||
fixtures/
|
||||
chat-db-fixture.ts # builds a real temp chat.db with bun:sqlite for tests
|
||||
chat-db-fixture.md
|
||||
chat-db-fixture.test.ts
|
||||
index.ts
|
||||
tests/
|
||||
setup/
|
||||
polyfills.ts # OTel in-memory meter wiring (mirrors slack-client)
|
||||
```
|
||||
|
||||
## SqliteReader seam
|
||||
|
||||
The upstream package reaches `bun:sqlite` through `require("bun:sqlite")
|
||||
as any`. Genesis replaces this with a structural seam:
|
||||
|
||||
```ts
|
||||
export interface SqliteStatement {
|
||||
all(...params: unknown[]): unknown[];
|
||||
get(...params: unknown[]): unknown | undefined;
|
||||
}
|
||||
|
||||
export interface SqliteHandle {
|
||||
prepare(sql: string): SqliteStatement;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface SqliteReader {
|
||||
open(path: string, options: { readonly: boolean }): SqliteHandle;
|
||||
}
|
||||
```
|
||||
|
||||
Production wiring: `bunSqliteReader` lazily imports `bun:sqlite` via
|
||||
`import("bun:sqlite")` inside `open()` and wraps the resulting
|
||||
`Database` in a `SqliteHandle`. The lazy import means tests on non-Bun
|
||||
runtimes never trip the import; the watcher accepts an injected reader
|
||||
so test runs feed it a reader backed by an in-process bun:sqlite
|
||||
database populated from a chat.db-shaped fixture. There is no
|
||||
`vi.mock`, no stub, no fake — both production and test paths use
|
||||
`bun:sqlite` directly.
|
||||
|
||||
## chat.db fixture
|
||||
|
||||
`src/fixtures/chat-db-fixture.ts` exports `createChatDbFixture(reader,
|
||||
path)` which:
|
||||
|
||||
1. Opens the database at `path` via the injected reader (write mode).
|
||||
2. Creates the `message`, `handle`, `chat`, `chat_message_join`,
|
||||
`attachment`, `message_attachment_join` tables with the
|
||||
chat.db-compatible columns the watcher reads.
|
||||
3. Returns helper `insertMessage`, `insertHandle`, `insertChat`,
|
||||
`insertAttachment` functions tests use to build scenarios.
|
||||
|
||||
This is a real SQLite database on a real temp file — no shortcuts, no
|
||||
in-memory faking of the query interface. The fixture is itself
|
||||
covered by `chat-db-fixture.test.ts` so the coverage gate stays at
|
||||
100/100/100/100.
|
||||
|
||||
## EventBus bridge
|
||||
|
||||
`IMessageWatcher` carries a `bus: EventBus<IMessageWatcherEvents>`
|
||||
field as the canonical event surface. The legacy `on(event, listener)`
|
||||
and `off(event, listener)` methods delegate to the bus's `on()` /
|
||||
returned `Unsubscribe` machinery so callers using the old surface
|
||||
still work, but `getBus()` returns the underlying `EventBus` for new
|
||||
consumers that want interceptors, idempotency, observability adapters,
|
||||
and bounded queuing.
|
||||
|
||||
## OTel metric refresh
|
||||
|
||||
Following the slack-client pattern, `metrics.ts` exports
|
||||
`refreshIMessageMeters()` that re-resolves every instrument against
|
||||
the current global `MeterProvider`. Tests wire the in-memory provider
|
||||
in `tests/setup/polyfills.ts` before any source module loads, then call
|
||||
`refreshIMessageMeters()` so subsequent `recordMessageEmitted` etc.
|
||||
calls land on the test exporter.
|
||||
|
||||
## AbortSignal
|
||||
|
||||
`start({ signal })` and `poll({ signal })` both accept a signal.
|
||||
|
||||
- A pre-aborted signal short-circuits before any DB work and rejects
|
||||
with `IMessagePollAbortedError`.
|
||||
- Aborting mid-scheduler clears the poll timer and resolves `stop()`
|
||||
immediately.
|
||||
- Aborting mid-poll causes the in-flight `poll()` to throw
|
||||
`IMessagePollAbortedError`; the watcher emits `error` on the bus and
|
||||
the scheduler keeps running for any subsequent ticks unless `stop()`
|
||||
is called.
|
||||
|
||||
## Tier
|
||||
|
||||
Server-tier (macOS-only at runtime; consumes `bun:sqlite`). The
|
||||
`bun:sqlite` import lives behind a dynamic `import()` so the package
|
||||
type-checks and unit-tests on any platform that has `bun:sqlite`
|
||||
available (Bun on macOS / Linux / Windows). Non-Bun environments are
|
||||
out of scope for this package — the upstream is `bun:sqlite`-only and
|
||||
genesis does not introduce a fallback engine.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Enterprise migration: `@suaveplan/imessage-adapter`
|
||||
|
||||
- **Change ID**: `enterprise-imessage-adapter`
|
||||
- **Status**: PROPOSED
|
||||
- **Capability**: `imessage-adapter`
|
||||
- **Target package**: `@suaveplan/imessage-adapter` at `0.1.0`
|
||||
- **Target path**: `packages/communication/messaging/imessage-adapter/`
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/imessage-adapter/spec.md
|
||||
- **Source on main**: `/mnt/s/web/suaveplan/packages/universal/imessage-adapter` (`~718 LOC`)
|
||||
- **Enterprise plan**: `/mnt/s/web/suaveplan/enterprise/imessage-adapter.md`
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/imessage-adapter` is the macOS-side ingress for any genesis
|
||||
messaging surface that wants to read from Apple's Messages app. It
|
||||
polls the `~/Library/Messages/chat.db` SQLite database, decodes the
|
||||
Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment
|
||||
joins, and emits `ParsedIMessage` payloads — and a downstream
|
||||
`InboundMessage`-shaped projection — that any inbound-message router
|
||||
can consume without coupling to Apple's schema.
|
||||
|
||||
The upstream package (`v1.0.0`) is functional but not enterprise-grade:
|
||||
|
||||
- `require("bun:sqlite")` is reached through an `any`-cast cast at every
|
||||
call site, defeating type-checking and blocking testability.
|
||||
- No structured error codes — every failure is a swallowed `console`-less
|
||||
`error` event with a bare `Error`.
|
||||
- No Zod boundary on config; no schema-registry hook.
|
||||
- No OpenTelemetry instrumentation — no counters for messages emitted,
|
||||
no histogram for poll duration, no error counter.
|
||||
- No `AbortSignal` propagation on `start()` — `stop()` only.
|
||||
- The internal listener registry duplicates `@suaveplan/events`'s
|
||||
`EventBus`; the package should ride the shared bus.
|
||||
- Tests don't exercise the watcher loop end-to-end against a real
|
||||
SQLite chat.db fixture — they only construct the adapter and assert
|
||||
on the un-initialised error path.
|
||||
|
||||
This change rewrites the package on genesis at enterprise quality
|
||||
without removing a single export from the upstream surface, preserving
|
||||
the contract that any future consumer (e.g. an inbound-message router,
|
||||
the existing `@suaveplan/api-plugin-messaging` plugin family, or a
|
||||
standalone agent) can rely on.
|
||||
|
||||
## What
|
||||
|
||||
A net-new package at
|
||||
`packages/communication/messaging/imessage-adapter/` at `0.1.0`,
|
||||
preserving the upstream public surface:
|
||||
|
||||
- Classes: `IMessageAdapter`, `IMessageWatcher`, `IMessageParser`
|
||||
- Types: `IMessageRow`, `HandleRow`, `ChatRow`, `AttachmentRow`,
|
||||
`ParsedIMessage`, `ParsedAttachment`, `IMessageAdapterConfig`,
|
||||
`IMessageWatcherConfig`, `InboundMessage`, `MessageSourceAdapter`,
|
||||
`IMessageWatcherEvents`
|
||||
|
||||
Enterprise additions on top:
|
||||
|
||||
1. **Structured errors via `defineCodes("IMESSAGE", [...])`** — 10
|
||||
stable codes (`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`), each reachable from a real call site
|
||||
and asserted in tests.
|
||||
2. **Zod-validated config boundary** at `IMessageWatcherConfigSchema`
|
||||
and `IMessageAdapterConfigSchema`, used by the constructor; invalid
|
||||
config throws `IMessageInvalidConfigError`.
|
||||
3. **Injectable `SqliteReader` seam** (structural type) replaces the
|
||||
`require("bun:sqlite") as any` cast. Production wiring uses
|
||||
`bun:sqlite` lazily; tests use a real in-process `bun:sqlite`
|
||||
database populated from a chat.db-shaped fixture (no mocks; rule 2).
|
||||
4. **Injectable `Clock` + `Timers` + `IdGenerator`** for deterministic
|
||||
polling, last-rowid pointer management, and envelope IDs.
|
||||
5. **OTel instrumentation** — counters
|
||||
`imessage.messages_emitted_total`, `imessage.poll_errors_total`,
|
||||
`imessage.attachments_emitted_total`; histogram
|
||||
`imessage.poll_duration_ms`.
|
||||
6. **AbortSignal propagation** — `start({ signal })`, `poll({ signal })`,
|
||||
and the internal scheduler all honour cancellation; aborted polls
|
||||
throw `IMessagePollAbortedError`.
|
||||
7. **Bus bridge** — `IMessageWatcher` exposes an `EventBus`-shaped
|
||||
surface in parallel with its legacy `on/off` API so downstream
|
||||
subscribers can compose with `@suaveplan/events` directly. The
|
||||
legacy `on/off` API is preserved for back-compat.
|
||||
8. **Idempotent rowid pointer** — `lastRowId` is settable + readable,
|
||||
and the watcher refuses to re-emit messages with `ROWID <= lastRowId`
|
||||
so persisted-pointer consumers can resume safely.
|
||||
9. **100/100/100/100 coverage** with real SQLite fixtures (no mocks),
|
||||
sibling `.md` ≥200 words per implementation file, root `README.md`.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
| Package | Impact | Action |
|
||||
|---|---|---|
|
||||
| `@suaveplan/events` | New consumer | None — additive |
|
||||
| `@suaveplan/error` | New consumer | None — additive |
|
||||
| `@suaveplan/opentelemetry` (via `@opentelemetry/api`) | New consumer | None — additive |
|
||||
| `@suaveplan/types` | New consumer of `Clock`, `IdGenerator`, `LoggerLike` | None — additive |
|
||||
| (future) `@suaveplan/api-plugin-messaging` / inbound-message router | Will consume `IMessageAdapter` as a `MessageSourceAdapter` | No live consumer today; future package can depend on `^0.1.0` |
|
||||
|
||||
No genesis package currently imports `@suaveplan/imessage-adapter`, so
|
||||
this change has zero blast radius beyond adding the package to the
|
||||
manifest + flipping the STATUS row.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Nothing. Per `feedback_no_deferrals_on_migration.md` and `AGENTS.md
|
||||
§2b`, no "phase 2" is permitted. The whole upstream surface plus the
|
||||
enterprise additions land in this change, feature-complete, with
|
||||
100/100/100/100 coverage.
|
||||
@@ -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"`
|
||||
100
openspec/archive/2026-05-25-enterprise-imessage-adapter/tasks.md
Normal file
100
openspec/archive/2026-05-25-enterprise-imessage-adapter/tasks.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Tasks: enterprise-imessage-adapter
|
||||
|
||||
**Change ID**: `enterprise-imessage-adapter`
|
||||
**Package**: `@suaveplan/imessage-adapter`
|
||||
**Target path**: `packages/communication/messaging/imessage-adapter/`
|
||||
**Prerequisite genesis packages**: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `@suaveplan/testing`
|
||||
|
||||
> Numbered requirement / scenario checklist (AGENTS.md §0c pre-flight #6).
|
||||
>
|
||||
> R1 Public surface · R2 Error registry · R3 Zod boundary · R4 Apple-date decode · R5 Handle/chat/attachment resolution · R6 Phone normalisation · R7 SqliteReader seam · R8 Polling loop · R9 AbortSignal · R10 EventBus bridge · R11 Adapter MessageSourceAdapter · R12 InboundMessage projection · R13 OTel · R14 Real fixtures · R15 Runner discipline · R16 Docs coverage · R17 Subpath exports.
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [x] 0.1 `git status` and `git worktree list` clean / accounted-for.
|
||||
- [x] 0.2 `bun:sqlite` available in the genesis runner (Bun-managed).
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/communication/messaging/imessage-adapter/` with `src/`, `tests/setup/`.
|
||||
- [x] 1.2 `package.json` at `0.1.0`, `"type":"module"`, `sideEffects:false`, subpath exports per design (R17), deps on `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `tslib`, `zod`, `@opentelemetry/api`.
|
||||
- [x] 1.3 `vite.config.ts`, `vitest.config.ts`, `tsconfig.lib.json` per slack-client sibling.
|
||||
- [x] 1.4 `tests/setup/polyfills.ts` wiring in-memory OTel meter provider.
|
||||
|
||||
## 2. Error-code registry (R2)
|
||||
|
||||
- [x] 2.1 `src/error-codes/error-codes.ts` — `defineCodes("IMESSAGE", [...])` with the ten codes, one typed error class per code.
|
||||
- [x] 2.2 `src/error-codes/error-codes.test.ts` — every code reachable from a real call site (assert via `instanceof` from production-throwing helpers).
|
||||
- [x] 2.3 `src/error-codes/error-codes.md`.
|
||||
|
||||
## 3. Schemas (R3)
|
||||
|
||||
- [x] 3.1 `src/schemas/schemas.ts` — Zod schemas for `IMessageWatcherConfig` + `IMessageAdapterConfig` + row + parsed shapes; `parseWatcherConfig` / `parseAdapterConfig` helpers that throw `IMessageInvalidConfigError`.
|
||||
- [x] 3.2 `src/schemas/schemas.test.ts` — accept happy paths, reject negative pollIntervalMs and bad chatFilter types.
|
||||
- [x] 3.3 `src/schemas/schemas.md`.
|
||||
|
||||
## 4. Types (no `.md` for `.types.ts`)
|
||||
|
||||
- [x] 4.1 `src/types/imessage.types.ts` — row, parsed, config interfaces (preserve upstream surface).
|
||||
- [x] 4.2 `src/types/adapter.types.ts` — `InboundMessage`, `MessageSourceAdapter`.
|
||||
- [x] 4.3 `src/types/seams.types.ts` — `SqliteReader`, `SqliteHandle`, `SqliteStatement`.
|
||||
|
||||
## 5. SQLite reader (R7)
|
||||
|
||||
- [x] 5.1 `src/sqlite/bun-sqlite-reader.ts` — `bunSqliteReader: SqliteReader` lazily importing `bun:sqlite`.
|
||||
- [x] 5.2 `src/sqlite/bun-sqlite-reader.test.ts` — opens a real temp DB, runs a SELECT.
|
||||
- [x] 5.3 `src/sqlite/bun-sqlite-reader.md`.
|
||||
|
||||
## 6. Fixtures
|
||||
|
||||
- [x] 6.1 `src/fixtures/chat-db-fixture.ts` — `createChatDbFixture(reader, path)` creating real chat.db-shaped tables + insert helpers.
|
||||
- [x] 6.2 `src/fixtures/chat-db-fixture.test.ts` — round-trip insert + query.
|
||||
- [x] 6.3 `src/fixtures/chat-db-fixture.md`.
|
||||
|
||||
## 7. Parser (R4, R5, R6)
|
||||
|
||||
- [x] 7.1 `src/parser/imessage-parser.ts` — `IMessageParser` with `parseMessage` / `parseAppleDate` / `parseAttachment` / `isGroupChat` / `normalizePhoneNumber`. `parseAppleDate` throws `IMessageAppleDateParseError` on NaN / non-finite.
|
||||
- [x] 7.2 `src/parser/imessage-parser.test.ts` — covers every behaviour the upstream tests cover + the additional error paths.
|
||||
- [x] 7.3 `src/parser/imessage-parser.md`.
|
||||
|
||||
## 8. Watcher (R8, R9, R10)
|
||||
|
||||
- [x] 8.1 `src/watcher/imessage-watcher.ts` — `IMessageWatcher` with EventBus bridge, AbortSignal, injectable reader, injectable clock + timers, OTel record calls.
|
||||
- [x] 8.2 `src/watcher/imessage-watcher.test.ts` — real-bun:sqlite-fixture poll loop, AbortSignal short-circuit, includeFromMe / chatFilter, lastRowId monotonic, EventBus delivery, bus bridge via legacy `on()`, OTel emissions.
|
||||
- [x] 8.3 `src/watcher/imessage-watcher.md`.
|
||||
|
||||
## 9. Adapter (R11, R12)
|
||||
|
||||
- [x] 9.1 `src/adapter/imessage-adapter.ts` — `IMessageAdapter` calling watcher, projecting `InboundMessage`, throwing `IMessageAdapterNotInitializedError` from `poll()` before `initialize()`.
|
||||
- [x] 9.2 `src/adapter/imessage-adapter.test.ts` — full surface assertion, projection rules, last-processed-id, idempotent close, sibling-.md & subpath presence asserted.
|
||||
- [x] 9.3 `src/adapter/imessage-adapter.md`.
|
||||
|
||||
## 10. OTel (R13)
|
||||
|
||||
- [x] 10.1 `src/otel/metrics.ts` — `recordMessageEmitted`, `recordAttachmentEmitted`, `recordPollError`, `recordPollDuration`, `refreshIMessageMeters`.
|
||||
- [x] 10.2 `src/otel/metrics.test.ts` — assert each instrument lands on the in-memory exporter.
|
||||
- [x] 10.3 `src/otel/metrics.md`.
|
||||
|
||||
## 11. Package barrel + README
|
||||
|
||||
- [x] 11.1 `src/index.ts` — explicit named re-exports of the full public surface (R1), side-effect import of `./error-codes/index.js`.
|
||||
- [x] 11.2 Root `README.md` describing the package, install + usage, the four OTel instruments, the ten error codes, the SqliteReader seam.
|
||||
|
||||
## 12. Verify gates (R14, R15, R16, R17)
|
||||
|
||||
- [x] 12.1 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/communication/messaging/imessage-adapter/src/ packages/communication/messaging/imessage-adapter/tests/` → zero output.
|
||||
- [x] 12.2 `grep -rnE "vi\.mock|jest\.mock|mock\.module" packages/communication/messaging/imessage-adapter/` → zero output.
|
||||
- [x] 12.3 `grep -rn "biome-ignore\|TODO\|FIXME\|@deprecated\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/communication/messaging/imessage-adapter/src/` → zero output.
|
||||
- [x] 12.4 `bunx turbo lint typecheck test build --filter=@suaveplan/imessage-adapter` — green.
|
||||
- [x] 12.5 `bunx turbo test:coverage --filter=@suaveplan/imessage-adapter` — 100/100/100/100.
|
||||
|
||||
## 13. Manifest + STATUS + finalisation
|
||||
|
||||
- [x] 13.1 `bun scripts/folder-reorg/generate-manifest.ts`.
|
||||
- [x] 13.2 Flip `STATUS.md` `imessage-adapter` row to `✅` and bump the "Migrated" counter.
|
||||
- [x] 13.3 Fold `specs/imessage-adapter/spec.md` into `openspec/specs/imessage-adapter/spec.md`.
|
||||
- [x] 13.4 Move the change folder to `openspec/archive/2026-05-25-enterprise-imessage-adapter/`.
|
||||
|
||||
## 14. Commit (no push)
|
||||
|
||||
- [x] 14.1 Conventional commit + `Co-Authored-By: Claude` footer on branch `feat/migrate-imessage-adapter`.
|
||||
Reference in New Issue
Block a user