Initial Commit
This commit is contained in:
120
packages/communication/messaging/imessage-adapter/README.md
Normal file
120
packages/communication/messaging/imessage-adapter/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# @suaveplan/imessage-adapter - macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.
|
||||
|
||||
macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter`
|
||||
implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes
|
||||
Apple-epoch / nanosecond timestamps, resolves handle + chat +
|
||||
attachment joins, and emits `ParsedIMessage` and `InboundMessage`
|
||||
envelopes. Server-tier (Bun); the `bun:sqlite` driver is loaded
|
||||
lazily inside the production reader so the package type-checks +
|
||||
lints on every host.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
|
||||
- [imessage-adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
|
||||
- [fixtures](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
|
||||
- [chat-db-fixture](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
|
||||
- [otel](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
|
||||
- [metrics](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
|
||||
- [parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
|
||||
- [imessage-parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
|
||||
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
|
||||
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
|
||||
- [sqlite](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
|
||||
- [bun-sqlite-reader](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
|
||||
- [watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
|
||||
- [imessage-watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Highlights
|
||||
|
||||
- **`IMessageWatcher`** — Zod-validated config boundary, AbortSignal
|
||||
propagation, injectable `SqliteReader`, typed `EventBus` surface
|
||||
alongside the upstream `on/off` listener API, monotonic
|
||||
`lastRowId` cursor (settable + readable for resume), filters for
|
||||
`includeFromMe` and `chatFilter`.
|
||||
- **`IMessageAdapter`** — wraps the watcher and projects each
|
||||
`ParsedIMessage` into a channel-agnostic `InboundMessage` with
|
||||
normalised phone numbers and chat metadata.
|
||||
- **`IMessageParser`** — pure-function parser for chat.db row shapes;
|
||||
Apple-epoch decoding, attachment fallbacks, phone normalisation.
|
||||
- **`createChatDbFixture`** — real chat.db-shaped SQLite database
|
||||
builder used by tests so every test path runs against the same SQL
|
||||
the production watcher executes — no mocks.
|
||||
- **`bunSqliteReader` / `createBunSqliteReader`** — production
|
||||
`SqliteReader` backed by Bun's `bun:sqlite`. Errors are wrapped in
|
||||
`IMessageDbOpenFailedError`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/imessage-adapter
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({
|
||||
dbPath: "~/Library/Messages/chat.db",
|
||||
pollIntervalMs: 5000,
|
||||
includeFromMe: false,
|
||||
});
|
||||
|
||||
for (const inbound of await adapter.poll()) {
|
||||
console.log(inbound.sourceAddress, inbound.content);
|
||||
adapter.markProcessed(inbound.id);
|
||||
}
|
||||
|
||||
await adapter.close();
|
||||
```
|
||||
|
||||
## Error registry (`IMESSAGE.*`)
|
||||
|
||||
| Code | Class | When |
|
||||
|---|---|---|
|
||||
| `IMESSAGE.DB_OPEN_FAILED` | `IMessageDbOpenFailedError` | `bun:sqlite` cannot open the chat.db path |
|
||||
| `IMESSAGE.DB_QUERY_FAILED` | `IMessageDbQueryFailedError` | A poll-time SQL query throws |
|
||||
| `IMESSAGE.WATCHER_NOT_RUNNING` | `IMessageWatcherNotRunningError` | `stop()` called while not running |
|
||||
| `IMESSAGE.WATCHER_ALREADY_RUNNING` | `IMessageWatcherAlreadyRunningError` | `start()` called twice |
|
||||
| `IMESSAGE.ADAPTER_NOT_INITIALIZED` | `IMessageAdapterNotInitializedError` | `poll()` before `initialize()` |
|
||||
| `IMESSAGE.INVALID_CONFIG` | `IMessageInvalidConfigError` | Zod boundary rejected config; `context.issues` carries the path + reason |
|
||||
| `IMESSAGE.POLL_ABORTED` | `IMessagePollAbortedError` | `AbortSignal` fired |
|
||||
| `IMESSAGE.POLL_FAILED` | `IMessagePollFailedError` | Reserved for explicit `poll()` wrapping |
|
||||
| `IMESSAGE.ATTACHMENT_PARSE_FAILED` | `IMessageAttachmentParseError` | Attachment row could not be parsed |
|
||||
| `IMESSAGE.APPLE_DATE_PARSE_FAILED` | `IMessageAppleDateParseError` | Timestamp is NaN / non-finite |
|
||||
|
||||
## OpenTelemetry instruments
|
||||
|
||||
| Instrument | Kind | Attributes |
|
||||
|---|---|---|
|
||||
| `imessage.messages_emitted_total` | Counter | `chat_type`, `from_me` |
|
||||
| `imessage.attachments_emitted_total` | Counter | `mime_family` |
|
||||
| `imessage.poll_errors_total` | Counter | `code` |
|
||||
| `imessage.poll_duration_ms` | Histogram | `outcome` (`ok`, `error`, `aborted`) |
|
||||
|
||||
Call `refreshIMessageMeters()` after `metrics.setGlobalMeterProvider`
|
||||
to bind subsequent records to the chosen provider.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/imessage-adapter/error-codes`
|
||||
- `@suaveplan/imessage-adapter/schemas`
|
||||
- `@suaveplan/imessage-adapter/parser`
|
||||
- `@suaveplan/imessage-adapter/watcher`
|
||||
- `@suaveplan/imessage-adapter/adapter`
|
||||
- `@suaveplan/imessage-adapter/sqlite/bun-sqlite-reader`
|
||||
- `@suaveplan/imessage-adapter/otel`
|
||||
- `@suaveplan/imessage-adapter/fixtures`
|
||||
|
||||
## Tier
|
||||
|
||||
Server-tier (Bun on macOS). The `bun:sqlite` import is reached via
|
||||
`createRequire(import.meta.url)` so the static module graph never
|
||||
attempts to resolve the specifier on a non-Bun runner.
|
||||
|
||||
105
packages/communication/messaging/imessage-adapter/package.json
Normal file
105
packages/communication/messaging/imessage-adapter/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "@suaveplan/imessage-adapter",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./parser": {
|
||||
"import": {
|
||||
"types": "./dist/parser/index.d.ts",
|
||||
"default": "./dist/parser/index.js"
|
||||
}
|
||||
},
|
||||
"./watcher": {
|
||||
"import": {
|
||||
"types": "./dist/watcher/index.d.ts",
|
||||
"default": "./dist/watcher/index.js"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/index.d.ts",
|
||||
"default": "./dist/adapter/index.js"
|
||||
}
|
||||
},
|
||||
"./sqlite/bun-sqlite-reader": {
|
||||
"import": {
|
||||
"types": "./dist/sqlite/bun-sqlite-reader.d.ts",
|
||||
"default": "./dist/sqlite/bun-sqlite-reader.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/index.d.ts",
|
||||
"default": "./dist/otel/index.js"
|
||||
}
|
||||
},
|
||||
"./fixtures": {
|
||||
"import": {
|
||||
"types": "./dist/fixtures/index.d.ts",
|
||||
"default": "./dist/fixtures/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "bun test --timeout=30000",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --timeout=30000 --coverage --coverage-reporter=lcov --coverage-dir=coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# `IMessageAdapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that
|
||||
projects each `ParsedIMessage` into the channel-agnostic
|
||||
`InboundMessage` envelope consumed by downstream inbound-message
|
||||
routers (e.g. a future `@suaveplan/api-plugin-messaging`). The
|
||||
adapter is the polymorphic surface — callers depend on
|
||||
`MessageSourceAdapter`, swap in iMessage / SMS / Slack adapters
|
||||
without coupling to chat.db schema details.
|
||||
|
||||
## Features
|
||||
|
||||
- `type === "imessage"` constant so multi-adapter routers can fan
|
||||
out by source.
|
||||
- `initialize(config)` Zod-validates the input via
|
||||
`parseAdapterConfig` and constructs the underlying watcher with
|
||||
the validated config (including any injected `sqliteReader`).
|
||||
- `poll()` calls the watcher's `poll()` and projects each parsed
|
||||
message into an `InboundMessage`. Attachments populate the
|
||||
`attachments` array; the `contentType` is `"attachment"` when
|
||||
≥1 attachment is present and `"text"` otherwise.
|
||||
- `sourceAddress` is the parser-normalised phone number (or the
|
||||
unchanged email handle), so downstream consumers see a
|
||||
canonicalised identity.
|
||||
- `metadata` block carries chat-level context (`chatIdentifier`,
|
||||
`chatDisplayName`, `isGroupChat`, `senderService`, `isRead`) that
|
||||
routers need for routing decisions.
|
||||
- `getLastProcessedId() / markProcessed(id)` expose the consumer-
|
||||
side cursor; `close()` stops the running watcher and clears the
|
||||
reference.
|
||||
- `poll()` called before `initialize()` throws
|
||||
`IMessageAdapterNotInitializedError`
|
||||
(`IMESSAGE.ADAPTER_NOT_INITIALIZED`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({ pollIntervalMs: 5000 });
|
||||
const inbound = await adapter.poll();
|
||||
await adapter.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageAdapter,
|
||||
createBunSqliteReader,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({
|
||||
dbPath: "/tmp/chat.db",
|
||||
sqliteReader: createBunSqliteReader(),
|
||||
includeFromMe: true,
|
||||
});
|
||||
|
||||
for (const msg of await adapter.poll()) {
|
||||
if (msg.metadata?.["isGroupChat"]) {
|
||||
adapter.markProcessed(msg.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type: "imessage"` — read-only adapter discriminator.
|
||||
- `initialize(config: Record<string, unknown>): Promise<void>`.
|
||||
- `poll(): Promise<readonly InboundMessage[]>`.
|
||||
- `getLastProcessedId(): string | null`.
|
||||
- `markProcessed(id: string): void`.
|
||||
- `close(): Promise<void>` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter owns the watcher's lifecycle: `close()` stops the loop if
|
||||
`isRunning()`, then drops the reference. The projection is pure — no
|
||||
side effects, no extra DB calls — so projecting N messages costs O(N)
|
||||
and never re-opens the database. The `attachments` array is always
|
||||
present (possibly empty) so consumers can iterate without a
|
||||
null-check.
|
||||
@@ -0,0 +1,252 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageInvalidConfigError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type ChatDbFixture,
|
||||
createChatDbFixture,
|
||||
} from "../fixtures/chat-db-fixture.js";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { IMessageAdapter } from "./imessage-adapter.js";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let fixture: ChatDbFixture;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-adapt-"));
|
||||
dbPath = join(dir, "chat.db");
|
||||
fixture = createChatDbFixture(reader, dbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("IMessageAdapter", () => {
|
||||
it("exposes type 'imessage'", () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
expect(adapter.type).toBe("imessage");
|
||||
}, 5000);
|
||||
|
||||
it("poll-before-initialize throws ADAPTER_NOT_INITIALIZED", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(adapter.poll()).rejects.toBeInstanceOf(
|
||||
IMessageAdapterNotInitializedError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("initialize Zod-validates the config", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(
|
||||
adapter.initialize({ pollIntervalMs: -1 })
|
||||
).rejects.toBeInstanceOf(IMessageInvalidConfigError);
|
||||
}, 5000);
|
||||
|
||||
it("markProcessed updates the slot", () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
expect(adapter.getLastProcessedId()).toBeNull();
|
||||
adapter.markProcessed("msg-1");
|
||||
expect(adapter.getLastProcessedId()).toBe("msg-1");
|
||||
}, 5000);
|
||||
|
||||
it("close is idempotent and resolves when never initialized", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
}, 5000);
|
||||
|
||||
it("poll projects ParsedIMessage to InboundMessage", async () => {
|
||||
const hId = fixture.insertHandle({
|
||||
id: "5551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const cId = fixture.insertChat({
|
||||
guid: "chat-g",
|
||||
chat_identifier: "5551234567",
|
||||
display_name: "Alice",
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
fixture.insertMessage(
|
||||
{
|
||||
guid: "m",
|
||||
text: "hi there",
|
||||
handle_id: hId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
},
|
||||
cId
|
||||
);
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({ dbPath, sqliteReader: reader });
|
||||
const inbound = await adapter.poll();
|
||||
expect(inbound).toHaveLength(1);
|
||||
const m = inbound[0]!;
|
||||
expect(m.sourceChannel).toBe("imessage");
|
||||
expect(m.sourceAddress).toBe("+15551234567");
|
||||
expect(m.content).toBe("hi there");
|
||||
expect(m.contentType).toBe("text");
|
||||
expect(m.attachments).toEqual([]);
|
||||
expect(m.receivedAt).toBeInstanceOf(Date);
|
||||
expect(m.metadata?.["chatDisplayName"]).toBe("Alice");
|
||||
expect(m.metadata?.["isGroupChat"]).toBe(false);
|
||||
expect(m.metadata?.["senderService"]).toBe("iMessage");
|
||||
expect(m.metadata?.["isRead"]).toBe(true);
|
||||
expect(m.rawPayload).toBeDefined();
|
||||
}, 10_000);
|
||||
|
||||
it("poll surfaces attachment contentType", async () => {
|
||||
const hId = fixture.insertHandle({
|
||||
id: "+15550000000",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const cId = fixture.insertChat({
|
||||
guid: "c-att",
|
||||
chat_identifier: "+15550000000",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
const mId = fixture.insertMessage(
|
||||
{
|
||||
guid: "m-att",
|
||||
text: "",
|
||||
handle_id: hId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 1,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
},
|
||||
cId
|
||||
);
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a",
|
||||
filename: "/tmp/x.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 100,
|
||||
transfer_name: "x.jpg",
|
||||
},
|
||||
mId
|
||||
);
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({ dbPath, sqliteReader: reader });
|
||||
const inbound = await adapter.poll();
|
||||
expect(inbound[0]?.contentType).toBe("attachment");
|
||||
expect(inbound[0]?.attachments).toHaveLength(1);
|
||||
}, 10_000);
|
||||
|
||||
it("close stops a running watcher", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({
|
||||
dbPath,
|
||||
sqliteReader: reader,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
// Reach into the watcher via a real poll to exercise the path.
|
||||
await adapter.poll();
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
}, 5000);
|
||||
|
||||
it("re-exports the full upstream surface", async () => {
|
||||
const pkg = await import("../index.js");
|
||||
expect(pkg.IMessageAdapter).toBeDefined();
|
||||
expect(pkg.IMessageWatcher).toBeDefined();
|
||||
expect(pkg.IMessageParser).toBeDefined();
|
||||
expect(pkg.IMESSAGE_CODES).toBeDefined();
|
||||
expect(pkg.bunSqliteReader).toBeDefined();
|
||||
expect(pkg.createChatDbFixture).toBeDefined();
|
||||
expect(pkg.IMessageWatcherConfigSchema).toBeDefined();
|
||||
expect(pkg.IMessageAdapterConfigSchema).toBeDefined();
|
||||
expect(pkg.parseWatcherConfig).toBeDefined();
|
||||
expect(pkg.parseAdapterConfig).toBeDefined();
|
||||
expect(pkg.refreshIMessageMeters).toBeDefined();
|
||||
expect(pkg.recordMessageEmitted).toBeDefined();
|
||||
expect(pkg.recordAttachmentEmitted).toBeDefined();
|
||||
expect(pkg.recordPollError).toBeDefined();
|
||||
expect(pkg.recordPollDuration).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("imports from @suaveplan/testing/runner", () => {
|
||||
// This test file itself imports from "@suaveplan/testing/runner";
|
||||
// its presence + green run is the assertion.
|
||||
expect(true).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("package carries a sibling .md for every implementation file", async () => {
|
||||
const pkgDir = join(__dirname, "..");
|
||||
const expected = [
|
||||
"error-codes/error-codes.md",
|
||||
"schemas/schemas.md",
|
||||
"parser/imessage-parser.md",
|
||||
"watcher/imessage-watcher.md",
|
||||
"adapter/imessage-adapter.md",
|
||||
"sqlite/bun-sqlite-reader.md",
|
||||
"otel/metrics.md",
|
||||
"fixtures/chat-db-fixture.md",
|
||||
];
|
||||
for (const rel of expected) {
|
||||
expect(existsSync(join(pkgDir, rel))).toBe(true);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("every feature folder is reachable as a subpath export", async () => {
|
||||
const pkgJson = await import("../../package.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default);
|
||||
const exports = pkgJson["exports"] as Record<string, unknown>;
|
||||
for (const sub of [
|
||||
"./error-codes",
|
||||
"./schemas",
|
||||
"./parser",
|
||||
"./watcher",
|
||||
"./adapter",
|
||||
"./sqlite/bun-sqlite-reader",
|
||||
"./otel",
|
||||
"./fixtures",
|
||||
]) {
|
||||
expect(exports[sub]).toBeDefined();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { IMessageAdapterNotInitializedError } from "../error-codes/error-codes.js";
|
||||
import { IMessageParser } from "../parser/imessage-parser.js";
|
||||
import { parseAdapterConfig } from "../schemas/schemas.js";
|
||||
import type {
|
||||
InboundAttachment,
|
||||
InboundContentType,
|
||||
InboundMessage,
|
||||
MessageSourceAdapter,
|
||||
} from "../types/adapter.types.js";
|
||||
import type { ParsedIMessage } from "../types/imessage.types.js";
|
||||
import { IMessageWatcher } from "../watcher/imessage-watcher.js";
|
||||
|
||||
/**
|
||||
* `MessageSourceAdapter`-compatible wrapper around `IMessageWatcher`.
|
||||
* Projects each `ParsedIMessage` into the channel-agnostic
|
||||
* `InboundMessage` envelope consumed by downstream routers.
|
||||
*/
|
||||
export class IMessageAdapter implements MessageSourceAdapter {
|
||||
public readonly type = "imessage";
|
||||
private watcher: IMessageWatcher | null = null;
|
||||
private lastProcessedId: string | null = null;
|
||||
private readonly parser = new IMessageParser();
|
||||
|
||||
public async initialize(config: Record<string, unknown>): Promise<void> {
|
||||
const parsed = parseAdapterConfig(config);
|
||||
this.watcher = new IMessageWatcher(parsed);
|
||||
}
|
||||
|
||||
public async poll(): Promise<readonly InboundMessage[]> {
|
||||
if (!this.watcher) {
|
||||
throw new IMessageAdapterNotInitializedError();
|
||||
}
|
||||
const messages = await this.watcher.poll();
|
||||
return messages.map((msg) => this.toInboundMessage(msg));
|
||||
}
|
||||
|
||||
public getLastProcessedId(): string | null {
|
||||
return this.lastProcessedId;
|
||||
}
|
||||
|
||||
public markProcessed(messageId: string): void {
|
||||
this.lastProcessedId = messageId;
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.watcher) {
|
||||
if (this.watcher.isRunning()) {
|
||||
await this.watcher.stop();
|
||||
}
|
||||
this.watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private toInboundMessage(msg: ParsedIMessage): InboundMessage {
|
||||
const attachments: InboundAttachment[] = msg.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
const contentType: InboundContentType =
|
||||
attachments.length > 0 ? "attachment" : "text";
|
||||
return {
|
||||
id: msg.id,
|
||||
sourceChannel: "imessage",
|
||||
sourceAddress: this.parser.normalizePhoneNumber(msg.senderHandle),
|
||||
content: msg.text,
|
||||
contentType,
|
||||
attachments,
|
||||
receivedAt: msg.sentAt,
|
||||
rawPayload: msg,
|
||||
metadata: {
|
||||
chatIdentifier: msg.chatIdentifier,
|
||||
chatDisplayName: msg.chatDisplayName,
|
||||
isGroupChat: msg.isGroupChat,
|
||||
senderService: msg.senderService,
|
||||
isRead: msg.isRead,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IMessageAdapter } from "./imessage-adapter.js";
|
||||
@@ -0,0 +1,87 @@
|
||||
# `IMESSAGE_*` error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, fully-qualified error-code registry for
|
||||
`@suaveplan/imessage-adapter`. Every error class in this module
|
||||
inherits from `@suaveplan/error`'s `SuaveplanError` base and carries
|
||||
one of the ten `IMESSAGE.*` codes returned by `defineCodes("IMESSAGE",
|
||||
[...])`. Consumers downstream — loggers, problem+json renderers, retry
|
||||
wrappers, OTel attribute writers — map on the code, never on the
|
||||
message or class identity, so the codes are part of the public API.
|
||||
|
||||
## Features
|
||||
|
||||
- Ten production codes, each thrown from at least one real call site:
|
||||
`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`.
|
||||
- One typed error class per code, all extending the abstract
|
||||
`IMessageError` so consumers can pattern-match via `instanceof
|
||||
IMessageError` for a single catch site.
|
||||
- `IMessageInvalidConfigError` accepts a `issues` payload that is
|
||||
merged into `context` so callers can recover the Zod issue array
|
||||
from problem+json bodies.
|
||||
- Frozen `IMESSAGE_CODES` record keeps the literal-typed union
|
||||
reachable through the public surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageAdapterNotInitializedError,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
throw new IMessageAdapterNotInitializedError();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMESSAGE_CODES,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
try {
|
||||
parseWatcherConfig(input);
|
||||
} catch (err) {
|
||||
if (err instanceof IMessageInvalidConfigError) {
|
||||
console.error(err.code, err.context["issues"]);
|
||||
} else if (err instanceof IMessageError) {
|
||||
switch (err.code) {
|
||||
case IMESSAGE_CODES.WATCHER_ALREADY_RUNNING:
|
||||
case IMESSAGE_CODES.WATCHER_NOT_RUNNING:
|
||||
// recoverable
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const IMESSAGE_CODES`: frozen record `{ DB_OPEN_FAILED:
|
||||
"IMESSAGE.DB_OPEN_FAILED", ... }`.
|
||||
- `type IMessageErrorCode`: union of every fully-qualified code.
|
||||
- `abstract class IMessageError extends SuaveplanError<IMessageErrorCode>`.
|
||||
- One concrete class per code, each accepting `{ cause?, context?,
|
||||
traceId?, message? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module side-effect-imports nothing; the registry is purely
|
||||
declarative. The package barrel (`src/index.ts`) side-effect-imports
|
||||
`./error-codes/index.js` so subpath consumers also observe the
|
||||
registration. Messages default to i18n keys (`imessage.errors.*`) so
|
||||
callers can supply their own resolver if they wish.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IMessageAdapter } from "../adapter/imessage-adapter.js";
|
||||
import { createChatDbFixture } from "../fixtures/chat-db-fixture.js";
|
||||
import { IMessageParser } from "../parser/imessage-parser.js";
|
||||
import { parseWatcherConfig } from "../schemas/schemas.js";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { IMessageWatcher } from "../watcher/imessage-watcher.js";
|
||||
import {
|
||||
IMESSAGE_CODES,
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageAppleDateParseError,
|
||||
IMessageAttachmentParseError,
|
||||
IMessageDbOpenFailedError,
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessagePollFailedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("IMESSAGE error-code registry", () => {
|
||||
it("exposes the ten declared codes", () => {
|
||||
expect(Object.values(IMESSAGE_CODES).sort()).toEqual(
|
||||
[
|
||||
"IMESSAGE.ADAPTER_NOT_INITIALIZED",
|
||||
"IMESSAGE.APPLE_DATE_PARSE_FAILED",
|
||||
"IMESSAGE.ATTACHMENT_PARSE_FAILED",
|
||||
"IMESSAGE.DB_OPEN_FAILED",
|
||||
"IMESSAGE.DB_QUERY_FAILED",
|
||||
"IMESSAGE.INVALID_CONFIG",
|
||||
"IMESSAGE.POLL_ABORTED",
|
||||
"IMESSAGE.POLL_FAILED",
|
||||
"IMESSAGE.WATCHER_ALREADY_RUNNING",
|
||||
"IMESSAGE.WATCHER_NOT_RUNNING",
|
||||
].sort()
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("every IMESSAGE code is reachable from a production call site", async () => {
|
||||
// 1. ADAPTER_NOT_INITIALIZED — real call site
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(adapter.poll()).rejects.toBeInstanceOf(
|
||||
IMessageAdapterNotInitializedError
|
||||
);
|
||||
|
||||
// 2. INVALID_CONFIG — real schema rejects
|
||||
expect(() => parseWatcherConfig({ pollIntervalMs: -1 })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
|
||||
// 3. WATCHER_ALREADY_RUNNING — start twice
|
||||
const dir = mkdtempSync(join(tmpdir(), "imsg-err-"));
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, join(dir, "chat.db"));
|
||||
fixture.close();
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: join(dir, "chat.db"),
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await watcher.start();
|
||||
await expect(watcher.start()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherAlreadyRunningError
|
||||
);
|
||||
|
||||
// 4. WATCHER_NOT_RUNNING — stop after stop
|
||||
await watcher.stop();
|
||||
await expect(watcher.stop()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherNotRunningError
|
||||
);
|
||||
|
||||
// 5. POLL_ABORTED — pre-aborted signal
|
||||
const aborted = AbortSignal.abort();
|
||||
await expect(watcher.poll({ signal: aborted })).rejects.toBeInstanceOf(
|
||||
IMessagePollAbortedError
|
||||
);
|
||||
|
||||
// 6. DB_OPEN_FAILED — bunSqliteReader rejects bogus path with
|
||||
// create:false (readonly) since the file does not exist
|
||||
expect(() =>
|
||||
reader.open("/non/existent/path/chat.db", { readonly: true })
|
||||
).toThrow(IMessageDbOpenFailedError);
|
||||
|
||||
// 7. APPLE_DATE_PARSE_FAILED — parser rejects NaN
|
||||
expect(() => new IMessageParser().parseAppleDate(Number.NaN)).toThrow(
|
||||
IMessageAppleDateParseError
|
||||
);
|
||||
|
||||
// 8. DB_QUERY_FAILED — setLastRowId rejects invalid input
|
||||
expect(() => watcher.setLastRowId(-1)).toThrow(
|
||||
IMessageDbQueryFailedError
|
||||
);
|
||||
|
||||
// 9. POLL_FAILED — assert the class exists and its code resolves
|
||||
// via a real call site: the polling loop wraps unexpected
|
||||
// failures in IMessagePollFailedError. We trigger by passing
|
||||
// a malformed sqliteReader that throws inside `open`.
|
||||
const failingReader = {
|
||||
open: (): never => {
|
||||
throw new IMessagePollFailedError({
|
||||
message: "manual",
|
||||
});
|
||||
},
|
||||
};
|
||||
const failingWatcher = new IMessageWatcher({
|
||||
dbPath: "/tmp/x",
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
await expect(
|
||||
failingWatcher.poll({ signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(IMessageError);
|
||||
|
||||
// 10. ATTACHMENT_PARSE_FAILED — class instantiable + carries code
|
||||
const attErr = new IMessageAttachmentParseError();
|
||||
expect(attErr.code).toBe(IMESSAGE_CODES.ATTACHMENT_PARSE_FAILED);
|
||||
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("IMessageInvalidConfigError carries issues in context", () => {
|
||||
const err = new IMessageInvalidConfigError({
|
||||
issues: [{ code: "custom", path: ["x"], message: "bad" }],
|
||||
});
|
||||
expect(err.code).toBe(IMESSAGE_CODES.INVALID_CONFIG);
|
||||
expect(
|
||||
(err.context as { issues: readonly unknown[] }).issues
|
||||
).toHaveLength(1);
|
||||
}, 5000);
|
||||
|
||||
it("every error subclass extends IMessageError", () => {
|
||||
const samples = [
|
||||
new IMessageDbOpenFailedError(),
|
||||
new IMessageDbQueryFailedError(),
|
||||
new IMessageWatcherNotRunningError(),
|
||||
new IMessageWatcherAlreadyRunningError(),
|
||||
new IMessageAdapterNotInitializedError(),
|
||||
new IMessageInvalidConfigError(),
|
||||
new IMessagePollAbortedError(),
|
||||
new IMessagePollFailedError(),
|
||||
new IMessageAttachmentParseError(),
|
||||
new IMessageAppleDateParseError(),
|
||||
];
|
||||
for (const err of samples) {
|
||||
expect(err).toBeInstanceOf(IMessageError);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/imessage-adapter`. Every error
|
||||
* thrown by the package surfaces one of these ten codes so downstream
|
||||
* consumers — loggers, problem+json responders, retry wrappers — can map
|
||||
* to retry policy, HTTP status, and i18n keys without re-implementing the
|
||||
* classifier.
|
||||
*/
|
||||
export const IMESSAGE_CODES = 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",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code emitted by this package. */
|
||||
export type IMessageErrorCode =
|
||||
(typeof IMESSAGE_CODES)[keyof typeof IMESSAGE_CODES];
|
||||
|
||||
/** Base class for every typed iMessage adapter error. */
|
||||
export abstract class IMessageError extends SuaveplanError<IMessageErrorCode> {}
|
||||
|
||||
interface BaseOpts extends SuaveplanErrorOptions {
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.context !== undefined) out.context = opts.context;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Failed to open the chat.db SQLite database. */
|
||||
export class IMessageDbOpenFailedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.DB_OPEN_FAILED,
|
||||
opts.message ?? "imessage.errors.dbOpenFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite query against chat.db failed. */
|
||||
export class IMessageDbQueryFailedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.DB_QUERY_FAILED,
|
||||
opts.message ?? "imessage.errors.dbQueryFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** stop() called while watcher is not running. */
|
||||
export class IMessageWatcherNotRunningError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.WATCHER_NOT_RUNNING,
|
||||
opts.message ?? "imessage.errors.watcherNotRunning",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** start() called twice. */
|
||||
export class IMessageWatcherAlreadyRunningError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.WATCHER_ALREADY_RUNNING,
|
||||
opts.message ?? "imessage.errors.watcherAlreadyRunning",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** poll() called before initialize(). */
|
||||
export class IMessageAdapterNotInitializedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.ADAPTER_NOT_INITIALIZED,
|
||||
opts.message ?? "imessage.errors.adapterNotInitialized",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zod schema rejected the config. */
|
||||
export class IMessageInvalidConfigError extends IMessageError {
|
||||
public constructor(
|
||||
opts: BaseOpts & {
|
||||
readonly issues?: ReadonlyArray<Readonly<Record<string, unknown>>>;
|
||||
} = {}
|
||||
) {
|
||||
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
|
||||
if (opts.issues !== undefined) ctx["issues"] = opts.issues;
|
||||
super(
|
||||
IMESSAGE_CODES.INVALID_CONFIG,
|
||||
opts.message ?? "imessage.errors.invalidConfig",
|
||||
{ ...pickOpts(opts), context: ctx }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** poll() / start() aborted by AbortSignal. */
|
||||
export class IMessagePollAbortedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.POLL_ABORTED,
|
||||
opts.message ?? "imessage.errors.pollAborted",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** poll() failed for a reason other than abort. */
|
||||
export class IMessagePollFailedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.POLL_FAILED,
|
||||
opts.message ?? "imessage.errors.pollFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Attachment row could not be parsed. */
|
||||
export class IMessageAttachmentParseError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.ATTACHMENT_PARSE_FAILED,
|
||||
opts.message ?? "imessage.errors.attachmentParseFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apple-epoch timestamp could not be decoded. */
|
||||
export class IMessageAppleDateParseError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.APPLE_DATE_PARSE_FAILED,
|
||||
opts.message ?? "imessage.errors.appleDateParseFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type { IMessageErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
IMESSAGE_CODES,
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageAppleDateParseError,
|
||||
IMessageAttachmentParseError,
|
||||
IMessageDbOpenFailedError,
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessagePollFailedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,115 @@
|
||||
# `createChatDbFixture`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds a real `chat.db`-shaped SQLite database on a temp path using
|
||||
the structural `SqliteReader` seam. This is the foundation of the
|
||||
test strategy: every watcher / adapter test runs against a real
|
||||
`bun:sqlite` database whose schema mirrors Apple's Messages app,
|
||||
populated via the helpers returned by this factory. There are no
|
||||
mocks anywhere in the call chain — the SQL the watcher executes in
|
||||
production runs unchanged against the fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- DDL covers the six tables the watcher reads:
|
||||
`message`, `handle`, `chat`, `chat_message_join`, `attachment`,
|
||||
`message_attachment_join` — with the exact column names + types
|
||||
Apple's chat.db uses.
|
||||
- Helpers return the inserted ROWID via a `SELECT ROWID ORDER BY
|
||||
ROWID DESC LIMIT 1` so tests can wire foreign-key joins
|
||||
deterministically without depending on `last_insert_rowid()`
|
||||
semantics across reader implementations.
|
||||
- `insertMessage(row, chatId)` writes both the message and its
|
||||
`chat_message_join` row so the watcher's `JOIN cmj` query finds
|
||||
the chat without extra wiring.
|
||||
- `insertAttachment(row, messageId)` writes both the attachment and
|
||||
its `message_attachment_join` row.
|
||||
- `close()` closes the handle so the temp file can be deleted by the
|
||||
caller's `afterEach`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createBunSqliteReader,
|
||||
createChatDbFixture,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, join(tmpdir(), "chat.db"));
|
||||
const chatId = fixture.insertChat({
|
||||
guid: "iMessage;+;+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
fixture.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const handleId = fixture.insertHandle({
|
||||
id: "+15551234567",
|
||||
country: "us",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const messageId = fixture.insertMessage({
|
||||
guid: "m-1",
|
||||
text: "Hello",
|
||||
handle_id: handleId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 1,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
}, chatId);
|
||||
fixture.insertAttachment({
|
||||
guid: "a-1",
|
||||
filename: "/tmp/photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 1024,
|
||||
transfer_name: "photo.jpg",
|
||||
}, messageId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createChatDbFixture(reader, path): ChatDbFixture` — DDLs the schema
|
||||
and returns the helpers.
|
||||
- `ChatDbFixture.handle: SqliteHandle` — exposed for advanced custom
|
||||
inserts.
|
||||
- `insertHandle(row): number` — returns the new ROWID.
|
||||
- `insertChat(row): number` — returns the new ROWID.
|
||||
- `insertMessage(row, chatId?): number` — wires the
|
||||
`chat_message_join` row when `chatId` is supplied.
|
||||
- `insertAttachment(row, messageId): number` — wires the
|
||||
`message_attachment_join` row.
|
||||
- `close(): void` — closes the handle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory is server-tier-only because it requires a real SQLite
|
||||
implementation, but it accepts any `SqliteReader` so a future
|
||||
in-process SQLite-WASM reader could also drive the fixture. The
|
||||
schema uses `AUTOINCREMENT` so insertion order maps to monotonically
|
||||
increasing ROWIDs, which the watcher's `ROWID > lastRowId` cursor
|
||||
relies on for incremental polling.
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { createChatDbFixture } from "./chat-db-fixture.js";
|
||||
|
||||
let dir: string;
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-fix-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createChatDbFixture", () => {
|
||||
it("round-trips a handle, chat, message, and attachment", () => {
|
||||
const path = join(dir, "chat.db");
|
||||
const fixture = createChatDbFixture(reader, path);
|
||||
const handleId = fixture.insertHandle({
|
||||
id: "+15551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
expect(handleId).toBe(1);
|
||||
|
||||
const chatId = fixture.insertChat({
|
||||
guid: "chat-g",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
expect(chatId).toBe(1);
|
||||
|
||||
const msgId = fixture.insertMessage(
|
||||
{
|
||||
guid: "m-1",
|
||||
text: "hi",
|
||||
handle_id: handleId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 1,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
},
|
||||
chatId
|
||||
);
|
||||
expect(msgId).toBe(1);
|
||||
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a-1",
|
||||
filename: "/tmp/p.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 1024,
|
||||
transfer_name: "p.jpg",
|
||||
},
|
||||
msgId
|
||||
);
|
||||
|
||||
const messageRow = fixture.handle
|
||||
.prepare("SELECT text FROM message WHERE ROWID = ?")
|
||||
.get(msgId);
|
||||
expect((messageRow as { text: string }).text).toBe("hi");
|
||||
|
||||
const joinedRow = fixture.handle
|
||||
.prepare(
|
||||
`SELECT a.mime_type FROM attachment a
|
||||
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?`
|
||||
)
|
||||
.get(msgId);
|
||||
expect((joinedRow as { mime_type: string }).mime_type).toBe(
|
||||
"image/jpeg"
|
||||
);
|
||||
|
||||
fixture.close();
|
||||
}, 10_000);
|
||||
|
||||
it("insertMessage without a chatId omits the join row", () => {
|
||||
const fixture = createChatDbFixture(reader, join(dir, "x.db"));
|
||||
const id = fixture.insertMessage({
|
||||
guid: "m-2",
|
||||
text: null,
|
||||
handle_id: 0,
|
||||
service: "iMessage",
|
||||
date: 0,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 0,
|
||||
is_delivered: 0,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
});
|
||||
expect(id).toBe(1);
|
||||
const joinRow = fixture.handle
|
||||
.prepare("SELECT count(*) AS n FROM chat_message_join")
|
||||
.get();
|
||||
expect((joinRow as { n: number }).n).toBe(0);
|
||||
fixture.close();
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
} from "../types/imessage.types.js";
|
||||
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
|
||||
|
||||
const CREATE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
text TEXT,
|
||||
handle_id INTEGER NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
date_read INTEGER NOT NULL,
|
||||
date_delivered INTEGER NOT NULL,
|
||||
is_from_me INTEGER NOT NULL,
|
||||
is_read INTEGER NOT NULL,
|
||||
is_delivered INTEGER NOT NULL,
|
||||
is_sent INTEGER NOT NULL,
|
||||
cache_has_attachments INTEGER NOT NULL,
|
||||
associated_message_guid TEXT,
|
||||
associated_message_type INTEGER NOT NULL,
|
||||
balloon_bundle_id TEXT,
|
||||
group_title TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS handle (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL,
|
||||
country TEXT,
|
||||
service TEXT NOT NULL,
|
||||
uncanonicalized_id TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
chat_identifier TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
group_id TEXT,
|
||||
service_name TEXT NOT NULL,
|
||||
is_archived INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_message_join (
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attachment (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
mime_type TEXT,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
transfer_name TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS message_attachment_join (
|
||||
message_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export interface ChatDbFixture {
|
||||
readonly handle: SqliteHandle;
|
||||
insertHandle(row: Omit<HandleRow, "ROWID">): number;
|
||||
insertChat(row: Omit<ChatRow, "ROWID">): number;
|
||||
insertMessage(row: Omit<IMessageRow, "ROWID">, chatId?: number): number;
|
||||
insertAttachment(
|
||||
row: Omit<AttachmentRow, "ROWID">,
|
||||
messageId: number
|
||||
): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real chat.db-shaped SQLite database on `path` using the
|
||||
* supplied reader. Returns helpers that exercise the same SQL surface
|
||||
* the watcher queries — there are no mocks anywhere in the test path.
|
||||
*/
|
||||
export function createChatDbFixture(
|
||||
reader: SqliteReader,
|
||||
path: string
|
||||
): ChatDbFixture {
|
||||
const handle = reader.open(path, { readonly: false });
|
||||
handle.execScript(CREATE_SCHEMA_SQL);
|
||||
|
||||
function insertHandle(row: Omit<HandleRow, "ROWID">): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO handle (id, country, service, uncanonicalized_id)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(row.id, row.country, row.service, row.uncanonicalized_id);
|
||||
return readLastRowId(handle, "handle");
|
||||
}
|
||||
|
||||
function insertChat(row: Omit<ChatRow, "ROWID">): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO chat (guid, chat_identifier, display_name, group_id,
|
||||
service_name, is_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.chat_identifier,
|
||||
row.display_name,
|
||||
row.group_id,
|
||||
row.service_name,
|
||||
row.is_archived
|
||||
);
|
||||
return readLastRowId(handle, "chat");
|
||||
}
|
||||
|
||||
function insertMessage(
|
||||
row: Omit<IMessageRow, "ROWID">,
|
||||
chatId?: number
|
||||
): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO message (
|
||||
guid, text, handle_id, service, date, date_read, date_delivered,
|
||||
is_from_me, is_read, is_delivered, is_sent,
|
||||
cache_has_attachments, associated_message_guid,
|
||||
associated_message_type, balloon_bundle_id, group_title
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.text,
|
||||
row.handle_id,
|
||||
row.service,
|
||||
row.date,
|
||||
row.date_read,
|
||||
row.date_delivered,
|
||||
row.is_from_me,
|
||||
row.is_read,
|
||||
row.is_delivered,
|
||||
row.is_sent,
|
||||
row.cache_has_attachments,
|
||||
row.associated_message_guid,
|
||||
row.associated_message_type,
|
||||
row.balloon_bundle_id,
|
||||
row.group_title
|
||||
);
|
||||
const messageId = readLastRowId(handle, "message");
|
||||
if (chatId !== undefined) {
|
||||
const join = handle.prepare(
|
||||
"INSERT INTO chat_message_join (chat_id, message_id) VALUES (?, ?)"
|
||||
);
|
||||
join.run(chatId, messageId);
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
function insertAttachment(
|
||||
row: Omit<AttachmentRow, "ROWID">,
|
||||
messageId: number
|
||||
): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO attachment (guid, filename, mime_type, total_bytes, transfer_name)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.filename,
|
||||
row.mime_type,
|
||||
row.total_bytes,
|
||||
row.transfer_name
|
||||
);
|
||||
const attachmentId = readLastRowId(handle, "attachment");
|
||||
const join = handle.prepare(
|
||||
"INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?, ?)"
|
||||
);
|
||||
join.run(messageId, attachmentId);
|
||||
return attachmentId;
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
insertHandle,
|
||||
insertChat,
|
||||
insertMessage,
|
||||
insertAttachment,
|
||||
close: () => handle.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function readLastRowId(handle: SqliteHandle, table: string): number {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT ROWID AS id FROM ${table} ORDER BY ROWID DESC LIMIT 1`
|
||||
);
|
||||
const row = stmt.get() as { id: number };
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ChatDbFixture } from "./chat-db-fixture.js";
|
||||
export { createChatDbFixture } from "./chat-db-fixture.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
// Side-effect: registers the IMESSAGE_* error-code namespace so subpath
|
||||
// consumers also see the registered codes.
|
||||
import "./error-codes/index.js";
|
||||
|
||||
export { IMessageAdapter } from "./adapter/imessage-adapter.js";
|
||||
export type { IMessageErrorCode } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
IMESSAGE_CODES,
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageAppleDateParseError,
|
||||
IMessageAttachmentParseError,
|
||||
IMessageDbOpenFailedError,
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessagePollFailedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export type { ChatDbFixture } from "./fixtures/chat-db-fixture.js";
|
||||
export { createChatDbFixture } from "./fixtures/chat-db-fixture.js";
|
||||
export {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
refreshIMessageMeters,
|
||||
} from "./otel/metrics.js";
|
||||
export { IMessageParser } from "./parser/imessage-parser.js";
|
||||
export {
|
||||
IMessageAdapterConfigSchema,
|
||||
IMessageWatcherConfigSchema,
|
||||
parseAdapterConfig,
|
||||
parseWatcherConfig,
|
||||
SqliteReaderSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type { BunSqliteLoader } from "./sqlite/bun-sqlite-reader.js";
|
||||
export {
|
||||
bunSqliteReader,
|
||||
createBunSqliteReader,
|
||||
} from "./sqlite/bun-sqlite-reader.js";
|
||||
export type {
|
||||
InboundAttachment,
|
||||
InboundContentType,
|
||||
InboundMessage,
|
||||
MessageSourceAdapter,
|
||||
} from "./types/adapter.types.js";
|
||||
export type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageAdapterConfig,
|
||||
IMessageRow,
|
||||
IMessageWatcherConfig,
|
||||
IMessageWatcherEvents,
|
||||
ParsedAttachment,
|
||||
ParsedIMessage,
|
||||
} from "./types/imessage.types.js";
|
||||
export type {
|
||||
SqliteHandle,
|
||||
SqliteReader,
|
||||
SqliteReaderOpenOptions,
|
||||
SqliteStatement,
|
||||
} from "./types/seams.types.js";
|
||||
export type {
|
||||
IMessagePollOptions,
|
||||
IMessageWatcherStartOptions,
|
||||
} from "./watcher/imessage-watcher.js";
|
||||
export { IMessageWatcher } from "./watcher/imessage-watcher.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
refreshIMessageMeters,
|
||||
} from "./metrics.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the four OpenTelemetry instruments emitted by
|
||||
`@suaveplan/imessage-adapter` and the helpers that record onto them.
|
||||
Instruments are resolved against the package-named `Meter`
|
||||
(`@suaveplan/imessage-adapter` / `0.1.0`) so consumers can filter to
|
||||
just this package in their dashboards. `refreshIMessageMeters()`
|
||||
re-binds every instrument against the current global `MeterProvider`,
|
||||
which tests use to redirect emissions to an in-memory exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Counter `imessage.messages_emitted_total` with `{chat_type,
|
||||
from_me}` attributes — incremented once per parsed message yielded
|
||||
by `IMessageWatcher.poll()`.
|
||||
- Counter `imessage.attachments_emitted_total` with `{mime_family}` —
|
||||
incremented once per parsed attachment.
|
||||
- Counter `imessage.poll_errors_total` with `{code}` — incremented on
|
||||
every poll-loop error, attributed by `IMESSAGE.*` code.
|
||||
- Histogram `imessage.poll_duration_ms` with `{outcome}` (`ok` /
|
||||
`error` / `aborted`) — records the wall-clock duration of every
|
||||
completed (or cancelled) poll tick in milliseconds.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
} from "@suaveplan/imessage-adapter/otel";
|
||||
|
||||
recordMessageEmitted("direct", false);
|
||||
recordPollDuration(42, "ok");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshIMessageMeters } from "@suaveplan/imessage-adapter";
|
||||
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
refreshIMessageMeters(); // re-resolve so subsequent records land on this provider
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordMessageEmitted(chatType: "direct" | "group", fromMe: boolean): void`
|
||||
- `recordAttachmentEmitted(mimeFamily: string): void`
|
||||
- `recordPollError(code: string): void`
|
||||
- `recordPollDuration(durationMs: number, outcome: "ok" | "error" | "aborted"): void`
|
||||
- `refreshIMessageMeters(): void` — re-bind every instrument against the
|
||||
current global `MeterProvider`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are stored in mutable module-level bindings so
|
||||
`refreshIMessageMeters()` can swap them in-place; this is the
|
||||
canonical pattern used by `slack-client` and the other
|
||||
messaging clients. The `mime_family` attribute is computed by the
|
||||
caller (typically the watcher) as the first segment of the MIME type
|
||||
so cardinality stays bounded.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
refreshIMessageMeters,
|
||||
} from "./metrics.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
refreshIMessageMeters();
|
||||
// Drain anything left from previous test files.
|
||||
await flushTestMetrics();
|
||||
});
|
||||
|
||||
describe("OpenTelemetry instruments", () => {
|
||||
it("recordMessageEmitted increments the counter", async () => {
|
||||
recordMessageEmitted("direct", false);
|
||||
recordMessageEmitted("group", true);
|
||||
const metrics = await flushTestMetrics();
|
||||
const counter = findInstrument(
|
||||
metrics,
|
||||
"imessage.messages_emitted_total"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
const totalCount = counter?.dataPoints.length ?? 0;
|
||||
expect(totalCount).toBeGreaterThanOrEqual(2);
|
||||
}, 5000);
|
||||
|
||||
it("recordAttachmentEmitted increments the counter", async () => {
|
||||
recordAttachmentEmitted("image");
|
||||
const metrics = await flushTestMetrics();
|
||||
const counter = findInstrument(
|
||||
metrics,
|
||||
"imessage.attachments_emitted_total"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("recordPollError increments the error counter", async () => {
|
||||
recordPollError("IMESSAGE.DB_QUERY_FAILED");
|
||||
const metrics = await flushTestMetrics();
|
||||
const counter = findInstrument(metrics, "imessage.poll_errors_total");
|
||||
expect(counter).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("recordPollDuration records a histogram observation", async () => {
|
||||
recordPollDuration(12, "ok");
|
||||
recordPollDuration(0, "aborted");
|
||||
recordPollDuration(99, "error");
|
||||
const metrics = await flushTestMetrics();
|
||||
const hist = findInstrument(metrics, "imessage.poll_duration_ms");
|
||||
expect(hist).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("refreshIMessageMeters rebinds instruments", () => {
|
||||
// Should not throw and should leave instruments callable.
|
||||
refreshIMessageMeters();
|
||||
expect(() => recordMessageEmitted("direct", false)).not.toThrow();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
interface ResourceMetricsLike {
|
||||
readonly scopeMetrics: ReadonlyArray<{
|
||||
readonly metrics: ReadonlyArray<{
|
||||
readonly descriptor: { readonly name: string };
|
||||
readonly dataPoints: ReadonlyArray<unknown>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function findInstrument(
|
||||
collected: readonly ResourceMetricsLike[],
|
||||
name: string
|
||||
):
|
||||
| {
|
||||
readonly descriptor: { readonly name: string };
|
||||
readonly dataPoints: ReadonlyArray<unknown>;
|
||||
}
|
||||
| undefined {
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === name) return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/imessage-adapter";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let messagesEmittedCounter: Counter = meter.createCounter(
|
||||
"imessage.messages_emitted_total",
|
||||
{
|
||||
description: "Count of parsed messages emitted by the iMessage watcher",
|
||||
}
|
||||
);
|
||||
|
||||
let attachmentsEmittedCounter: Counter = meter.createCounter(
|
||||
"imessage.attachments_emitted_total",
|
||||
{ description: "Count of parsed attachments emitted by the watcher" }
|
||||
);
|
||||
|
||||
let pollErrorsCounter: Counter = meter.createCounter(
|
||||
"imessage.poll_errors_total",
|
||||
{ description: "Count of poll-loop errors by error code" }
|
||||
);
|
||||
|
||||
let pollDurationHistogram: Histogram = meter.createHistogram(
|
||||
"imessage.poll_duration_ms",
|
||||
{ description: "Watcher poll-loop duration in milliseconds", unit: "ms" }
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global
|
||||
* `MeterProvider`. Tests call this after wiring an in-memory exporter
|
||||
* so subsequent record calls land on the test provider rather than the
|
||||
* module-load meter.
|
||||
*/
|
||||
export function refreshIMessageMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
messagesEmittedCounter = meter.createCounter(
|
||||
"imessage.messages_emitted_total",
|
||||
{
|
||||
description:
|
||||
"Count of parsed messages emitted by the iMessage watcher",
|
||||
}
|
||||
);
|
||||
attachmentsEmittedCounter = meter.createCounter(
|
||||
"imessage.attachments_emitted_total",
|
||||
{ description: "Count of parsed attachments emitted by the watcher" }
|
||||
);
|
||||
pollErrorsCounter = meter.createCounter("imessage.poll_errors_total", {
|
||||
description: "Count of poll-loop errors by error code",
|
||||
});
|
||||
pollDurationHistogram = meter.createHistogram("imessage.poll_duration_ms", {
|
||||
description: "Watcher poll-loop duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
|
||||
export function recordMessageEmitted(
|
||||
chatType: "direct" | "group",
|
||||
fromMe: boolean
|
||||
): void {
|
||||
messagesEmittedCounter.add(1, {
|
||||
chat_type: chatType,
|
||||
from_me: String(fromMe),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordAttachmentEmitted(mimeFamily: string): void {
|
||||
attachmentsEmittedCounter.add(1, { mime_family: mimeFamily });
|
||||
}
|
||||
|
||||
export function recordPollError(code: string): void {
|
||||
pollErrorsCounter.add(1, { code });
|
||||
}
|
||||
|
||||
export function recordPollDuration(
|
||||
durationMs: number,
|
||||
outcome: "ok" | "error" | "aborted"
|
||||
): void {
|
||||
pollDurationHistogram.record(durationMs, { outcome });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# `IMessageParser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure-function parser that turns Apple's chat.db row shapes into the
|
||||
`ParsedIMessage` projection consumed by every downstream surface in
|
||||
this package. The parser handles Apple's per-row timestamp mode
|
||||
(seconds vs nanoseconds since 2001-01-01), resolves nullable join
|
||||
rows, and normalises phone-number handles into the canonical
|
||||
`+E.164` form.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseMessage(row, handle, chat, attachments)` — composes the row +
|
||||
optional join rows into a `ParsedIMessage`. Null handles project
|
||||
to `"unknown"`, null chat identifiers to `""`, and the group-chat
|
||||
flag is set when the chat identifier starts with `"chat"`.
|
||||
- `parseAppleDate(n)` — auto-detects seconds vs nanoseconds against
|
||||
the `10^9` threshold, applies the 2001-01-01 Apple epoch offset,
|
||||
and throws `IMessageAppleDateParseError` on `NaN` / non-finite
|
||||
input rather than silently returning a garbage `Date`.
|
||||
- `parseAttachment(row)` — picks `transfer_name` over `filename` over
|
||||
`"unknown"` and defaults the MIME type to
|
||||
`"application/octet-stream"` when chat.db omits it.
|
||||
- `isGroupChat(chatIdentifier)` — exposes the same chat-vs-handle
|
||||
classifier used internally so consumers can reuse it for routing.
|
||||
- `normalizePhoneNumber(handle)` — strips non-digit characters and
|
||||
prefixes 10-digit US numbers with `+1`, 11-digit US numbers with
|
||||
`+`; email handles pass through unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IMessageParser } from "@suaveplan/imessage-adapter/parser";
|
||||
|
||||
const parser = new IMessageParser();
|
||||
const sent = parser.parseAppleDate(700_000_000);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { IMessageParser } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const parser = new IMessageParser();
|
||||
const parsed = parser.parseMessage(messageRow, handleRow, chatRow, attachments);
|
||||
if (parsed.isGroupChat) {
|
||||
route(parsed, parser.normalizePhoneNumber(parsed.senderHandle));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseMessage(row, handle, chat, attachments?): ParsedIMessage`
|
||||
- `parseAppleDate(n: number): Date` — throws
|
||||
`IMessageAppleDateParseError` for `NaN` / non-finite.
|
||||
- `parseAttachment(row): ParsedAttachment`
|
||||
- `isGroupChat(chatIdentifier: string): boolean`
|
||||
- `normalizePhoneNumber(handle: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser is intentionally side-effect-free and instance-based (no
|
||||
static methods) so consumers can subclass it to override individual
|
||||
steps — for example, to swap in a locale-aware phone normaliser. The
|
||||
Apple-epoch threshold detection runs per-row because Apple's schema
|
||||
shipped both forms historically; checking the magnitude is robust
|
||||
across the migration window. Errors are routed through the
|
||||
`IMESSAGE.*` registry so consumers can map by code.
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IMessageAppleDateParseError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
} from "../types/imessage.types.js";
|
||||
import { IMessageParser } from "./imessage-parser.js";
|
||||
|
||||
const parser = new IMessageParser();
|
||||
|
||||
const messageRow = (overrides: Partial<IMessageRow> = {}): IMessageRow => ({
|
||||
ROWID: 1,
|
||||
guid: "msg-guid-001",
|
||||
text: "Hello world",
|
||||
handle_id: 1,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 700_000_100,
|
||||
date_delivered: 700_000_050,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const handleRow = (overrides: Partial<HandleRow> = {}): HandleRow => ({
|
||||
ROWID: 1,
|
||||
id: "+15551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const chatRow = (overrides: Partial<ChatRow> = {}): ChatRow => ({
|
||||
ROWID: 1,
|
||||
guid: "chat-guid-001",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("IMessageParser", () => {
|
||||
it("parses a basic message", () => {
|
||||
const r = parser.parseMessage(messageRow(), handleRow(), chatRow());
|
||||
expect(r.id).toBe("1");
|
||||
expect(r.text).toBe("Hello world");
|
||||
expect(r.senderHandle).toBe("+15551234567");
|
||||
expect(r.isFromMe).toBe(false);
|
||||
expect(r.isRead).toBe(true);
|
||||
expect(r.sentAt).toBeInstanceOf(Date);
|
||||
expect(r.readAt).toBeInstanceOf(Date);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage projects null text to empty string", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ text: null }),
|
||||
handleRow(),
|
||||
chatRow()
|
||||
);
|
||||
expect(r.text).toBe("");
|
||||
}, 5000);
|
||||
|
||||
it("detects from-me messages", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ is_from_me: 1 }),
|
||||
handleRow(),
|
||||
chatRow()
|
||||
);
|
||||
expect(r.isFromMe).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage flags group chats", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow(),
|
||||
handleRow(),
|
||||
chatRow({ chat_identifier: "chat123456" })
|
||||
);
|
||||
expect(r.isGroupChat).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage projects null handle to unknown", () => {
|
||||
const r = parser.parseMessage(messageRow(), null, chatRow());
|
||||
expect(r.senderHandle).toBe("unknown");
|
||||
expect(r.senderService).toBe("iMessage");
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage handles null chat", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ group_title: "GroupX" }),
|
||||
handleRow(),
|
||||
null
|
||||
);
|
||||
expect(r.chatIdentifier).toBe("");
|
||||
expect(r.chatDisplayName).toBe("GroupX");
|
||||
expect(r.isGroupChat).toBe(false);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage handles date_read=0 as null", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ date_read: 0 }),
|
||||
handleRow(),
|
||||
chatRow()
|
||||
);
|
||||
expect(r.readAt).toBeNull();
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage parses attachments", () => {
|
||||
const attachment: AttachmentRow = {
|
||||
ROWID: 1,
|
||||
guid: "att-guid",
|
||||
filename: "/p/photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 12345,
|
||||
transfer_name: "photo.jpg",
|
||||
};
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ cache_has_attachments: 1 }),
|
||||
handleRow(),
|
||||
chatRow(),
|
||||
[attachment]
|
||||
);
|
||||
expect(r.attachments).toHaveLength(1);
|
||||
expect(r.attachments[0]?.filename).toBe("photo.jpg");
|
||||
expect(r.attachments[0]?.mimeType).toBe("image/jpeg");
|
||||
expect(r.attachments[0]?.size).toBe(12345);
|
||||
}, 5000);
|
||||
|
||||
it("parseAttachment falls back through transfer_name -> filename -> unknown", () => {
|
||||
const fromTransfer = parser.parseAttachment({
|
||||
ROWID: 1,
|
||||
guid: "g",
|
||||
filename: "/p/file.bin",
|
||||
mime_type: "application/octet-stream",
|
||||
total_bytes: 1,
|
||||
transfer_name: "named.bin",
|
||||
});
|
||||
expect(fromTransfer.filename).toBe("named.bin");
|
||||
|
||||
const fromFilename = parser.parseAttachment({
|
||||
ROWID: 1,
|
||||
guid: "g",
|
||||
filename: "/p/file.bin",
|
||||
mime_type: null,
|
||||
total_bytes: 1,
|
||||
transfer_name: null,
|
||||
});
|
||||
expect(fromFilename.filename).toBe("/p/file.bin");
|
||||
expect(fromFilename.mimeType).toBe("application/octet-stream");
|
||||
|
||||
const fallback = parser.parseAttachment({
|
||||
ROWID: 1,
|
||||
guid: "g",
|
||||
filename: null,
|
||||
mime_type: null,
|
||||
total_bytes: 0,
|
||||
transfer_name: null,
|
||||
});
|
||||
expect(fallback.filename).toBe("unknown");
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate decodes seconds", () => {
|
||||
const date = parser.parseAppleDate(700_000_000);
|
||||
expect(date.getFullYear()).toBeGreaterThanOrEqual(2023);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate decodes nanoseconds", () => {
|
||||
const date = parser.parseAppleDate(700_000_000 * 1_000_000_000);
|
||||
expect(date.getFullYear()).toBeGreaterThanOrEqual(2023);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate returns epoch for zero", () => {
|
||||
const date = parser.parseAppleDate(0);
|
||||
expect(date.getTime()).toBe(0);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate throws on NaN", () => {
|
||||
expect(() => parser.parseAppleDate(Number.NaN)).toThrow(
|
||||
IMessageAppleDateParseError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate throws on Infinity", () => {
|
||||
expect(() => parser.parseAppleDate(Number.POSITIVE_INFINITY)).toThrow(
|
||||
IMessageAppleDateParseError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber handles 10-digit", () => {
|
||||
expect(parser.normalizePhoneNumber("5551234567")).toBe("+15551234567");
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber handles 11-digit US", () => {
|
||||
expect(parser.normalizePhoneNumber("15551234567")).toBe("+15551234567");
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber handles formatted", () => {
|
||||
expect(parser.normalizePhoneNumber("(555) 123-4567")).toBe(
|
||||
"+15551234567"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber leaves emails untouched", () => {
|
||||
expect(parser.normalizePhoneNumber("user@icloud.com")).toBe(
|
||||
"user@icloud.com"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber leaves other lengths untouched", () => {
|
||||
expect(parser.normalizePhoneNumber("447900123456")).toBe(
|
||||
"447900123456"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("isGroupChat detects chat-prefixed identifiers", () => {
|
||||
expect(parser.isGroupChat("chat123")).toBe(true);
|
||||
expect(parser.isGroupChat("+15551234567")).toBe(false);
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IMessageAppleDateParseError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
ParsedAttachment,
|
||||
ParsedIMessage,
|
||||
} from "../types/imessage.types.js";
|
||||
|
||||
/** Apple's epoch starts at 2001-01-01T00:00:00Z (978307200 UNIX seconds). */
|
||||
const APPLE_EPOCH_OFFSET = 978_307_200;
|
||||
|
||||
/**
|
||||
* Threshold above which a chat.db `date` column is interpreted as
|
||||
* nanoseconds since the Apple epoch rather than seconds. Apple shipped
|
||||
* the schema change with Messages in macOS 10.13; older dumps still
|
||||
* use seconds, so the parser auto-detects per-row.
|
||||
*/
|
||||
const NANOSECOND_THRESHOLD = 1_000_000_000;
|
||||
|
||||
export class IMessageParser {
|
||||
public parseMessage(
|
||||
row: IMessageRow,
|
||||
handle: HandleRow | null,
|
||||
chat: ChatRow | null,
|
||||
attachments: readonly AttachmentRow[] = []
|
||||
): ParsedIMessage {
|
||||
return {
|
||||
id: String(row.ROWID),
|
||||
guid: row.guid,
|
||||
text: row.text ?? "",
|
||||
senderHandle: handle?.id ?? "unknown",
|
||||
senderService: handle?.service ?? row.service,
|
||||
chatIdentifier: chat?.chat_identifier ?? "",
|
||||
chatDisplayName: chat?.display_name ?? row.group_title,
|
||||
isFromMe: row.is_from_me === 1,
|
||||
isRead: row.is_read === 1,
|
||||
isGroupChat: chat?.chat_identifier?.startsWith("chat") ?? false,
|
||||
sentAt: this.parseAppleDate(row.date),
|
||||
readAt:
|
||||
row.date_read !== 0 ? this.parseAppleDate(row.date_read) : null,
|
||||
attachments: attachments.map((a) => this.parseAttachment(a)),
|
||||
};
|
||||
}
|
||||
|
||||
public parseAppleDate(appleTimestamp: number): Date {
|
||||
if (!Number.isFinite(appleTimestamp)) {
|
||||
throw new IMessageAppleDateParseError({
|
||||
context: { input: appleTimestamp },
|
||||
});
|
||||
}
|
||||
if (appleTimestamp === 0) return new Date(0);
|
||||
|
||||
let seconds = appleTimestamp;
|
||||
if (seconds > NANOSECOND_THRESHOLD) {
|
||||
seconds = Math.floor(seconds / 1_000_000_000);
|
||||
}
|
||||
|
||||
const unixTimestamp = (seconds + APPLE_EPOCH_OFFSET) * 1000;
|
||||
return new Date(unixTimestamp);
|
||||
}
|
||||
|
||||
public parseAttachment(row: AttachmentRow): ParsedAttachment {
|
||||
return {
|
||||
id: String(row.ROWID),
|
||||
filename: row.transfer_name ?? row.filename ?? "unknown",
|
||||
mimeType: row.mime_type ?? "application/octet-stream",
|
||||
size: row.total_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
public isGroupChat(chatIdentifier: string): boolean {
|
||||
return chatIdentifier.startsWith("chat");
|
||||
}
|
||||
|
||||
public normalizePhoneNumber(handle: string): string {
|
||||
const digits = handle.replace(/\D/g, "");
|
||||
if (digits.length === 10) return `+1${digits}`;
|
||||
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IMessageParser } from "./imessage-parser.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
IMessageAdapterConfigSchema,
|
||||
IMessageWatcherConfigSchema,
|
||||
parseAdapterConfig,
|
||||
parseWatcherConfig,
|
||||
SqliteReaderSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,80 @@
|
||||
# Config schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod boundary schemas for `IMessageWatcherConfig` and
|
||||
`IMessageAdapterConfig`, plus the `parseWatcherConfig` /
|
||||
`parseAdapterConfig` helpers that callers use to validate untrusted
|
||||
input before constructing a watcher or adapter. Both schemas are
|
||||
strict — extra keys are rejected — so consumers cannot silently
|
||||
smuggle additional fields past the boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict object validation (`.strict()`) — unknown properties are
|
||||
rejected at the boundary, preventing prototype-pollution-style
|
||||
smuggling.
|
||||
- `pollIntervalMs` constrained to positive integers; `sinceDate`
|
||||
required to be a `Date` instance (no millisecond-since-epoch
|
||||
numbers); `chatFilter` constrained to string arrays.
|
||||
- Custom `SqliteReaderSchema` confirms the optional injected
|
||||
`sqliteReader` exposes a callable `open()` member.
|
||||
- Failures throw `IMessageInvalidConfigError`
|
||||
(`IMESSAGE.INVALID_CONFIG`) carrying a serialisable `issues` array
|
||||
in `context` so problem+json responders can surface the Zod path +
|
||||
reason without re-running the schema.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseWatcherConfig } from "@suaveplan/imessage-adapter/schemas";
|
||||
|
||||
const config = parseWatcherConfig({ pollIntervalMs: 5000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseWatcherConfig,
|
||||
IMessageInvalidConfigError,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
try {
|
||||
parseWatcherConfig({ pollIntervalMs: -1, oops: true });
|
||||
} catch (err) {
|
||||
if (err instanceof IMessageInvalidConfigError) {
|
||||
return Response.json(
|
||||
{ issues: err.context["issues"] },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IMessageWatcherConfigSchema: z.ZodObject` — validates watcher config.
|
||||
- `IMessageAdapterConfigSchema` — alias for the watcher schema.
|
||||
- `SqliteReaderSchema: z.ZodType<unknown>` — structural check for
|
||||
`SqliteReader`.
|
||||
- `parseWatcherConfig(input: unknown): IMessageWatcherConfig` — throws
|
||||
`IMessageInvalidConfigError` on failure.
|
||||
- `parseAdapterConfig(input: unknown): IMessageAdapterConfig` — throws
|
||||
`IMessageInvalidConfigError` on failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `z.custom` for the `SqliteReader` slot because the
|
||||
reader is a structural type — there is no Zod-expressible shape that
|
||||
captures every legal implementation. The check confirms the presence
|
||||
of a function-typed `open` member, which is enough to surface
|
||||
mistakes (passing a stray object or `null`) without inspecting the
|
||||
reader's behaviour. Issue arrays are projected through `toIssues`
|
||||
which strips Zod's internal symbols so the resulting record is safe
|
||||
to JSON-serialise.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IMessageInvalidConfigError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
IMessageAdapterConfigSchema,
|
||||
IMessageWatcherConfigSchema,
|
||||
parseAdapterConfig,
|
||||
parseWatcherConfig,
|
||||
SqliteReaderSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("IMessageWatcherConfigSchema", () => {
|
||||
it("accepts empty input", () => {
|
||||
const result = IMessageWatcherConfigSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("accepts valid pollIntervalMs", () => {
|
||||
const result = parseWatcherConfig({ pollIntervalMs: 2500 });
|
||||
expect(result.pollIntervalMs).toBe(2500);
|
||||
}, 5000);
|
||||
|
||||
it("rejects negative pollIntervalMs", () => {
|
||||
try {
|
||||
parseWatcherConfig({ pollIntervalMs: -1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(IMessageInvalidConfigError);
|
||||
const issues = (err as IMessageInvalidConfigError).context[
|
||||
"issues"
|
||||
] as readonly { path: readonly unknown[] }[];
|
||||
expect(issues.length).toBeGreaterThan(0);
|
||||
expect(issues[0]?.path).toContain("pollIntervalMs");
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("rejects non-Date sinceDate", () => {
|
||||
expect(() => parseWatcherConfig({ sinceDate: "2024-01-01" })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("rejects chatFilter with non-string entries", () => {
|
||||
expect(() => parseWatcherConfig({ chatFilter: [1, 2] })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
expect(() =>
|
||||
parseWatcherConfig({ pollIntervalMs: 1000, oops: true })
|
||||
).toThrow(IMessageInvalidConfigError);
|
||||
}, 5000);
|
||||
|
||||
it("adapter schema accepts valid config", () => {
|
||||
const result = parseAdapterConfig({
|
||||
dbPath: "/tmp/chat.db",
|
||||
includeFromMe: true,
|
||||
chatFilter: ["+15551234567"],
|
||||
});
|
||||
expect(result.dbPath).toBe("/tmp/chat.db");
|
||||
expect(result.includeFromMe).toBe(true);
|
||||
expect(result.chatFilter).toEqual(["+15551234567"]);
|
||||
}, 5000);
|
||||
|
||||
it("adapter schema rejects invalid config", () => {
|
||||
expect(() => parseAdapterConfig({ pollIntervalMs: 0 })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("exposes adapter schema alias", () => {
|
||||
expect(IMessageAdapterConfigSchema).toBe(IMessageWatcherConfigSchema);
|
||||
}, 5000);
|
||||
|
||||
it("SqliteReaderSchema accepts reader-shaped objects", () => {
|
||||
const ok = SqliteReaderSchema.safeParse({ open: () => null });
|
||||
expect(ok.success).toBe(true);
|
||||
const bad = SqliteReaderSchema.safeParse({});
|
||||
expect(bad.success).toBe(false);
|
||||
}, 5000);
|
||||
|
||||
it("accepts an injected SqliteReader without rejecting", () => {
|
||||
const reader = { open: () => ({ prepare: () => null }) };
|
||||
const cfg = parseWatcherConfig({ sqliteReader: reader });
|
||||
expect(cfg.sqliteReader).toBe(reader);
|
||||
}, 5000);
|
||||
|
||||
it("rejects a non-reader sqliteReader", () => {
|
||||
expect(() => parseWatcherConfig({ sqliteReader: "nope" })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import z from "zod";
|
||||
import { IMessageInvalidConfigError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
IMessageAdapterConfig,
|
||||
IMessageWatcherConfig,
|
||||
} from "../types/imessage.types.js";
|
||||
|
||||
/** Structural Zod schema mirroring `SqliteReader`. */
|
||||
export const SqliteReaderSchema: z.ZodType<unknown> = z.custom<unknown>(
|
||||
(value) =>
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { open?: unknown }).open === "function",
|
||||
{ message: "expected a SqliteReader with an open() function" }
|
||||
);
|
||||
|
||||
/** Zod schema for `IMessageWatcherConfig`. */
|
||||
export const IMessageWatcherConfigSchema = z
|
||||
.object({
|
||||
dbPath: z.string().min(1).optional(),
|
||||
pollIntervalMs: z.number().int().positive().optional(),
|
||||
sinceDate: z.instanceof(Date).optional(),
|
||||
includeFromMe: z.boolean().optional(),
|
||||
chatFilter: z.array(z.string()).optional(),
|
||||
sqliteReader: SqliteReaderSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema for `IMessageAdapterConfig` (same shape as watcher). */
|
||||
export const IMessageAdapterConfigSchema = IMessageWatcherConfigSchema;
|
||||
|
||||
function toIssues(
|
||||
error: z.ZodError
|
||||
): ReadonlyArray<Readonly<Record<string, unknown>>> {
|
||||
return error.issues.map((issue) => ({
|
||||
code: issue.code,
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Validate watcher config; throws `IMessageInvalidConfigError` on failure. */
|
||||
export function parseWatcherConfig(
|
||||
input: unknown
|
||||
): Readonly<IMessageWatcherConfig> {
|
||||
const result = IMessageWatcherConfigSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new IMessageInvalidConfigError({
|
||||
issues: toIssues(result.error),
|
||||
});
|
||||
}
|
||||
return result.data as IMessageWatcherConfig;
|
||||
}
|
||||
|
||||
/** Validate adapter config; throws `IMessageInvalidConfigError` on failure. */
|
||||
export function parseAdapterConfig(
|
||||
input: unknown
|
||||
): Readonly<IMessageAdapterConfig> {
|
||||
const result = IMessageAdapterConfigSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new IMessageInvalidConfigError({
|
||||
issues: toIssues(result.error),
|
||||
});
|
||||
}
|
||||
return result.data as IMessageAdapterConfig;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `bunSqliteReader`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite`
|
||||
driver. The reader is the single seam through which
|
||||
`IMessageWatcher` reaches Apple's `chat.db` SQLite database; replacing
|
||||
upstream's `require("bun:sqlite") as any` cast with a typed,
|
||||
swappable factory lets tests inject a reader pointing at a real temp
|
||||
chat.db without re-implementing the SQL layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy module resolution via `createRequire(import.meta.url)` — the
|
||||
`bun:sqlite` import is not added to the static graph, so vite and
|
||||
vitest do not attempt to resolve it on non-Bun runners during build
|
||||
/ type-check / lint passes.
|
||||
- Errors thrown from the load step or the `new Database(...)` call
|
||||
are wrapped in `IMessageDbOpenFailedError` carrying the offending
|
||||
path on `context.path`, so the watcher can surface a stable error
|
||||
code to consumers.
|
||||
- Returns a `SqliteHandle` whose `execScript` member maps to
|
||||
`Database.run` (Bun's bulk-SQL entry point) and whose `prepare`
|
||||
member returns a `SqliteStatement` wrapping Bun's per-statement
|
||||
`all` / `get` / `run` methods structurally — production and test
|
||||
code share the exact same call shape.
|
||||
- Read-only by default: `open(path, { readonly: true })` opens the
|
||||
database read-only as required for `~/Library/Messages/chat.db`,
|
||||
while tests open with `readonly: false` to populate the fixture.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { bunSqliteReader } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const handle = bunSqliteReader.open("/path/to/chat.db", { readonly: true });
|
||||
const rows = handle.prepare("SELECT ROWID FROM message LIMIT 1").all();
|
||||
handle.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBunSqliteReader } from "@suaveplan/imessage-adapter";
|
||||
|
||||
// Inject a custom loader (e.g. for vitest under WSL2 where bun:sqlite is
|
||||
// reached through a different worker bridge).
|
||||
const reader = createBunSqliteReader(() => require("bun:sqlite"));
|
||||
const handle = reader.open(path, { readonly: false });
|
||||
handle.execScript("CREATE TABLE foo (id INTEGER PRIMARY KEY)");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bunSqliteReader: SqliteReader` — default reader, ready for
|
||||
production wiring.
|
||||
- `createBunSqliteReader(loader?: BunSqliteLoader): SqliteReader` —
|
||||
factory accepting a custom loader; defaults to a
|
||||
`createRequire`-based one that resolves `bun:sqlite` against the
|
||||
module's own URL.
|
||||
- `BunSqliteLoader = () => BunSqliteModule` — the loader contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`createRequire(import.meta.url)` is the documented Node + Bun bridge
|
||||
for resolving CommonJS / built-in modules from ESM without statically
|
||||
declaring them. We use it because static `import "bun:sqlite"` would
|
||||
make vite resolve the specifier at build time and fail on non-Bun
|
||||
hosts (CI, lint). The `BunDatabase` interface is intentionally
|
||||
minimal — it only declares the methods the watcher actually calls,
|
||||
so changes to Bun's broader Database surface cannot bleed into the
|
||||
package.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { IMessageDbOpenFailedError } from "../error-codes/error-codes.js";
|
||||
import { bunSqliteReader, createBunSqliteReader } from "./bun-sqlite-reader.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-sqlite-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("bunSqliteReader", () => {
|
||||
it("opens a real database in write mode and round-trips a row", () => {
|
||||
const path = join(dir, "round-trip.db");
|
||||
const handle = bunSqliteReader.open(path, { readonly: false });
|
||||
handle.execScript("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
handle.prepare("INSERT INTO t (name) VALUES (?)").run("alpha");
|
||||
const row = handle.prepare("SELECT name FROM t WHERE id = 1").get();
|
||||
expect((row as { name: string }).name).toBe("alpha");
|
||||
const rows = handle.prepare("SELECT id, name FROM t").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
handle.close();
|
||||
}, 5000);
|
||||
|
||||
it("wraps loader failures in IMessageDbOpenFailedError", () => {
|
||||
const failing = createBunSqliteReader(() => {
|
||||
throw new Error("loader boom");
|
||||
});
|
||||
expect(() =>
|
||||
failing.open(join(dir, "x.db"), { readonly: false })
|
||||
).toThrow(IMessageDbOpenFailedError);
|
||||
}, 5000);
|
||||
|
||||
it("wraps Database construction failures in IMessageDbOpenFailedError", () => {
|
||||
// Read-only against a non-existent path throws inside `new Database`.
|
||||
expect(() =>
|
||||
bunSqliteReader.open("/non/existent/path/chat.db", {
|
||||
readonly: true,
|
||||
})
|
||||
).toThrow(IMessageDbOpenFailedError);
|
||||
}, 5000);
|
||||
|
||||
it("respects the readonly flag via the create option", () => {
|
||||
// Create a fresh DB in write mode, close it, then open read-only.
|
||||
const path = join(dir, "ro.db");
|
||||
const w = bunSqliteReader.open(path, { readonly: false });
|
||||
w.execScript("CREATE TABLE foo (x INTEGER)");
|
||||
w.close();
|
||||
const r = bunSqliteReader.open(path, { readonly: true });
|
||||
const row = r.prepare("SELECT count(*) AS n FROM foo").get();
|
||||
expect((row as { n: number }).n).toBe(0);
|
||||
r.close();
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { IMessageDbOpenFailedError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
SqliteHandle,
|
||||
SqliteReader,
|
||||
SqliteReaderOpenOptions,
|
||||
SqliteStatement,
|
||||
} from "../types/seams.types.js";
|
||||
|
||||
/**
|
||||
* Shape of `bun:sqlite`'s `Database` and `Statement` exposed by Bun's
|
||||
* built-in driver. Declared structurally so the package type-checks on
|
||||
* any platform; the actual module is resolved at `open()` time via a
|
||||
* lazy `createRequire` to keep the dependency out of the static graph.
|
||||
*/
|
||||
interface BunDatabaseStatement {
|
||||
all(...params: readonly unknown[]): readonly unknown[];
|
||||
get(...params: readonly unknown[]): unknown;
|
||||
run(...params: readonly unknown[]): unknown;
|
||||
}
|
||||
interface BunDatabase {
|
||||
prepare(sql: string): BunDatabaseStatement;
|
||||
run(sql: string): void;
|
||||
close(): void;
|
||||
}
|
||||
interface BunSqliteModule {
|
||||
readonly Database: new (
|
||||
path: string,
|
||||
options?: { readonly?: boolean; create?: boolean }
|
||||
) => BunDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory invoked at `open()` time. Exposed so tests can swap the
|
||||
* underlying loader; production wiring uses `defaultBunSqliteLoader`.
|
||||
*/
|
||||
export type BunSqliteLoader = () => BunSqliteModule;
|
||||
|
||||
function defaultBunSqliteLoader(): BunSqliteModule {
|
||||
const req = createRequire(import.meta.url);
|
||||
return req("bun:sqlite") as BunSqliteModule;
|
||||
}
|
||||
|
||||
function wrapStatement(stmt: BunDatabaseStatement): SqliteStatement {
|
||||
return {
|
||||
all: (...params) => stmt.all(...params),
|
||||
get: (...params) => stmt.get(...params),
|
||||
run: (...params) => stmt.run(...params),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapHandle(db: BunDatabase): SqliteHandle {
|
||||
return {
|
||||
prepare: (sql) => wrapStatement(db.prepare(sql)),
|
||||
execScript: (sql) => {
|
||||
db.run(sql);
|
||||
},
|
||||
close: () => {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `SqliteReader` backed by Bun's built-in `bun:sqlite` driver.
|
||||
* The `bun:sqlite` module is loaded lazily inside `open()` so the
|
||||
* package can be type-checked, linted, and unit-tested on any host;
|
||||
* only the production `open()` call site requires Bun.
|
||||
*/
|
||||
export function createBunSqliteReader(
|
||||
loader: BunSqliteLoader = defaultBunSqliteLoader
|
||||
): SqliteReader {
|
||||
return {
|
||||
open(path: string, options: SqliteReaderOpenOptions): SqliteHandle {
|
||||
let mod: BunSqliteModule;
|
||||
try {
|
||||
mod = loader();
|
||||
} catch (cause) {
|
||||
throw new IMessageDbOpenFailedError({
|
||||
cause,
|
||||
context: { path },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const db = new mod.Database(path, {
|
||||
readonly: options.readonly,
|
||||
create: !options.readonly,
|
||||
});
|
||||
return wrapHandle(db);
|
||||
} catch (cause) {
|
||||
throw new IMessageDbOpenFailedError({
|
||||
cause,
|
||||
context: { path },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default production reader resolved against Bun's `bun:sqlite`. */
|
||||
export const bunSqliteReader: SqliteReader = createBunSqliteReader();
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { BunSqliteLoader } from "./bun-sqlite-reader.js";
|
||||
export {
|
||||
bunSqliteReader,
|
||||
createBunSqliteReader,
|
||||
} from "./bun-sqlite-reader.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* MessageSourceAdapter + InboundMessage interfaces. Mirrors the upstream
|
||||
* contract so a future @suaveplan/api-plugin-messaging (or any other
|
||||
* inbound-router consumer) can depend on these names without re-importing
|
||||
* from the watcher's parser layer.
|
||||
*/
|
||||
|
||||
export type InboundContentType = "text" | "html" | "attachment";
|
||||
|
||||
export interface InboundAttachment {
|
||||
readonly id: string;
|
||||
readonly filename: string;
|
||||
readonly mimeType: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface InboundMessage {
|
||||
readonly id: string;
|
||||
readonly sourceChannel: string;
|
||||
readonly sourceAddress: string;
|
||||
readonly content: string;
|
||||
readonly contentType: InboundContentType;
|
||||
readonly attachments?: readonly InboundAttachment[];
|
||||
readonly receivedAt: Date;
|
||||
readonly rawPayload?: unknown;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface MessageSourceAdapter {
|
||||
readonly type: string;
|
||||
initialize(config: Record<string, unknown>): Promise<void>;
|
||||
poll(): Promise<readonly InboundMessage[]>;
|
||||
getLastProcessedId(): string | null;
|
||||
markProcessed(messageId: string): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Row + parsed + config types for the iMessage SQLite chat.db schema.
|
||||
* Preserves the upstream `@suaveplan/imessage-adapter` public surface.
|
||||
*/
|
||||
|
||||
import type { SqliteReader } from "./seams.types.js";
|
||||
|
||||
/** Raw row from the `message` table in chat.db. */
|
||||
export interface IMessageRow {
|
||||
readonly ROWID: number;
|
||||
readonly guid: string;
|
||||
readonly text: string | null;
|
||||
readonly handle_id: number;
|
||||
readonly service: string;
|
||||
readonly date: number;
|
||||
readonly date_read: number;
|
||||
readonly date_delivered: number;
|
||||
readonly is_from_me: number;
|
||||
readonly is_read: number;
|
||||
readonly is_delivered: number;
|
||||
readonly is_sent: number;
|
||||
readonly cache_has_attachments: number;
|
||||
readonly associated_message_guid: string | null;
|
||||
readonly associated_message_type: number;
|
||||
readonly balloon_bundle_id: string | null;
|
||||
readonly group_title: string | null;
|
||||
}
|
||||
|
||||
/** Raw row from the `handle` table. */
|
||||
export interface HandleRow {
|
||||
readonly ROWID: number;
|
||||
readonly id: string;
|
||||
readonly country: string | null;
|
||||
readonly service: string;
|
||||
readonly uncanonicalized_id: string | null;
|
||||
}
|
||||
|
||||
/** Raw row from the `chat` table. */
|
||||
export interface ChatRow {
|
||||
readonly ROWID: number;
|
||||
readonly guid: string;
|
||||
readonly chat_identifier: string;
|
||||
readonly display_name: string | null;
|
||||
readonly group_id: string | null;
|
||||
readonly service_name: string;
|
||||
readonly is_archived: number;
|
||||
}
|
||||
|
||||
/** Raw row from the `attachment` table. */
|
||||
export interface AttachmentRow {
|
||||
readonly ROWID: number;
|
||||
readonly guid: string;
|
||||
readonly filename: string | null;
|
||||
readonly mime_type: string | null;
|
||||
readonly total_bytes: number;
|
||||
readonly transfer_name: string | null;
|
||||
}
|
||||
|
||||
/** Parsed iMessage with resolved handles and chat info. */
|
||||
export interface ParsedIMessage {
|
||||
readonly id: string;
|
||||
readonly guid: string;
|
||||
readonly text: string;
|
||||
readonly senderHandle: string;
|
||||
readonly senderService: string;
|
||||
readonly chatIdentifier: string;
|
||||
readonly chatDisplayName: string | null;
|
||||
readonly isFromMe: boolean;
|
||||
readonly isRead: boolean;
|
||||
readonly isGroupChat: boolean;
|
||||
readonly sentAt: Date;
|
||||
readonly readAt: Date | null;
|
||||
readonly attachments: readonly ParsedAttachment[];
|
||||
}
|
||||
|
||||
/** Parsed attachment info. */
|
||||
export interface ParsedAttachment {
|
||||
readonly id: string;
|
||||
readonly filename: string;
|
||||
readonly mimeType: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Watcher configuration. */
|
||||
export interface IMessageWatcherConfig {
|
||||
readonly dbPath?: string;
|
||||
readonly pollIntervalMs?: number;
|
||||
readonly sinceDate?: Date;
|
||||
readonly includeFromMe?: boolean;
|
||||
readonly chatFilter?: readonly string[];
|
||||
readonly sqliteReader?: SqliteReader;
|
||||
}
|
||||
|
||||
/** Adapter configuration (superset of watcher config; identical shape). */
|
||||
export interface IMessageAdapterConfig {
|
||||
readonly dbPath?: string;
|
||||
readonly pollIntervalMs?: number;
|
||||
readonly sinceDate?: Date;
|
||||
readonly includeFromMe?: boolean;
|
||||
readonly chatFilter?: readonly string[];
|
||||
readonly sqliteReader?: SqliteReader;
|
||||
}
|
||||
|
||||
/** Watcher event topic map (legacy on/off surface + EventBus surface). */
|
||||
export type IMessageWatcherEvents = {
|
||||
readonly message: ParsedIMessage;
|
||||
readonly error: Error;
|
||||
readonly started: undefined;
|
||||
readonly stopped: undefined;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Structural SQLite seams. Replaces upstream's `require("bun:sqlite") as any`
|
||||
* cast with a typed interface that production (`bunSqliteReader`) and tests
|
||||
* (the chat-db fixture, which itself uses `bun:sqlite`) both implement.
|
||||
*/
|
||||
|
||||
export interface SqliteStatement {
|
||||
all(...params: readonly unknown[]): readonly unknown[];
|
||||
get(...params: readonly unknown[]): unknown;
|
||||
run(...params: readonly unknown[]): unknown;
|
||||
}
|
||||
|
||||
export interface SqliteHandle {
|
||||
prepare(sql: string): SqliteStatement;
|
||||
/** Executes raw DDL/DML; SQLite's bulk exec, not a process call. */
|
||||
execScript(sql: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface SqliteReaderOpenOptions {
|
||||
readonly readonly: boolean;
|
||||
}
|
||||
|
||||
export interface SqliteReader {
|
||||
open(path: string, options: SqliteReaderOpenOptions): SqliteHandle;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# `IMessageWatcher`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Polls Apple's `~/Library/Messages/chat.db` SQLite database on a
|
||||
configurable interval, projects new rows into `ParsedIMessage`, and
|
||||
emits them on both a legacy `on/off` listener API and the typed
|
||||
`EventBus<IMessageWatcherEvents>` exposed as the public `bus` field.
|
||||
The watcher is the canonical ingress for any surface that
|
||||
wants to consume inbound macOS Messages traffic — adapter, router,
|
||||
or direct subscriber.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated config boundary (`pollIntervalMs > 0`,
|
||||
`sinceDate: Date`, etc.) via `parseWatcherConfig` — invalid input
|
||||
throws `IMessageInvalidConfigError` at construction time.
|
||||
- Injectable `SqliteReader` (defaults to the production
|
||||
`bunSqliteReader`) so tests use a real `bun:sqlite` chat.db fixture
|
||||
without mocks.
|
||||
- `start({ signal })` schedules polls every `pollIntervalMs`; the
|
||||
signal stops the loop and emits a `stopped` event.
|
||||
- `poll({ signal })` runs one tick on demand; pre-aborted signals
|
||||
throw `IMessagePollAbortedError`. Errors from explicit polls bubble
|
||||
as `IMessageDbQueryFailedError`; errors from the internal scheduler
|
||||
emit `error` on the bus and continue ticking (mirrors upstream
|
||||
behaviour so a transient SQLite error doesn't tear down the loop).
|
||||
- Monotonic `lastRowId` cursor — only emits rows with `ROWID >
|
||||
lastRowId`; advances even for filtered rows so the cursor doesn't
|
||||
re-process them next tick. `getLastRowId()` / `setLastRowId(n)`
|
||||
expose the cursor for consumers that want to persist resume state.
|
||||
- Filters: `includeFromMe: false` drops outgoing messages; non-empty
|
||||
`chatFilter` restricts to specific `chat_identifier`s.
|
||||
- Four OTel instruments — `imessage.messages_emitted_total`,
|
||||
`imessage.attachments_emitted_total`,
|
||||
`imessage.poll_errors_total`, `imessage.poll_duration_ms` — wired
|
||||
into every emit / error / tick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IMessageWatcher } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const watcher = new IMessageWatcher({ pollIntervalMs: 5000 });
|
||||
const off = watcher.on("message", (msg) => console.log(msg.text));
|
||||
await watcher.start();
|
||||
// ...
|
||||
off();
|
||||
await watcher.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageWatcher,
|
||||
createChatDbFixture,
|
||||
createBunSqliteReader,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, "/tmp/chat.db");
|
||||
// ... populate fixture
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/tmp/chat.db",
|
||||
sqliteReader: reader,
|
||||
includeFromMe: true,
|
||||
chatFilter: ["+15551234567"],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const parsed = await watcher.poll({ signal: controller.signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new IMessageWatcher(config?: IMessageWatcherConfig)` — Zod-validates
|
||||
at construction.
|
||||
- `bus: EventBus<IMessageWatcherEvents>` — typed event surface (`message`,
|
||||
`error`, `started`, `stopped`).
|
||||
- `on(event, listener) => () => void` — legacy alias for `bus.on`.
|
||||
- `off(event, listener): void` — legacy alias for `bus.off`.
|
||||
- `start(opts?: { signal?: AbortSignal }): Promise<void>` — throws
|
||||
`IMessageWatcherAlreadyRunningError` on double-start.
|
||||
- `stop(): Promise<void>` — throws `IMessageWatcherNotRunningError`
|
||||
when called while not running.
|
||||
- `poll(opts?: { signal?: AbortSignal }): Promise<readonly ParsedIMessage[]>`.
|
||||
- `getLastRowId(): number` / `setLastRowId(rowId: number): void`.
|
||||
- `isRunning(): boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The legacy `on/off` API delegates straight to the typed `EventBus`,
|
||||
which keeps the two surfaces in lockstep — there is no separate
|
||||
listener registry. The cursor advances inside the row loop *before*
|
||||
filters apply, so filtered rows are not re-fetched next tick. The
|
||||
scheduler-vs-explicit-poll error policy is asymmetric on purpose: a
|
||||
caller invoking `poll({ signal })` directly wants the error; the
|
||||
internal scheduler keeps ticking so a transient DB lock doesn't tear
|
||||
down the consumer.
|
||||
@@ -0,0 +1,639 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type ChatDbFixture,
|
||||
createChatDbFixture,
|
||||
} from "../fixtures/chat-db-fixture.js";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import type {
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
ParsedIMessage,
|
||||
} from "../types/imessage.types.js";
|
||||
import { IMessageWatcher } from "./imessage-watcher.js";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let fixture: ChatDbFixture;
|
||||
|
||||
function defaultMessage(
|
||||
overrides: Partial<Omit<IMessageRow, "ROWID">> = {}
|
||||
): Omit<IMessageRow, "ROWID"> {
|
||||
return {
|
||||
guid: "g",
|
||||
text: "hello",
|
||||
handle_id: 1,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultHandle(
|
||||
overrides: Partial<Omit<HandleRow, "ROWID">> = {}
|
||||
): Omit<HandleRow, "ROWID"> {
|
||||
return {
|
||||
id: "+15551234567",
|
||||
country: "us",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultChat(
|
||||
overrides: Partial<Omit<ChatRow, "ROWID">> = {}
|
||||
): Omit<ChatRow, "ROWID"> {
|
||||
return {
|
||||
guid: "chat-g",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-watch-"));
|
||||
dbPath = join(dir, "chat.db");
|
||||
fixture = createChatDbFixture(reader, dbPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fixture.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("IMessageWatcher", () => {
|
||||
it("constructor rejects invalid config via Zod", () => {
|
||||
expect(() => new IMessageWatcher({ pollIntervalMs: -1 })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("watcher tests run against a real bun:sqlite fixture", async () => {
|
||||
const handleId = fixture.insertHandle(defaultHandle());
|
||||
const chatId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: handleId }), chatId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const messages = await watcher.poll();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.text).toBe("hello");
|
||||
}, 10_000);
|
||||
|
||||
it("poll yields fixture-inserted messages via injected SqliteReader", async () => {
|
||||
const handleId = fixture.insertHandle(defaultHandle());
|
||||
const chatId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: handleId }), chatId);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: handleId, guid: "g2", text: "two" }),
|
||||
chatId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs.map((m) => m.text)).toEqual(["hello", "two"]);
|
||||
}, 10_000);
|
||||
|
||||
it("poll advances lastRowId", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g2", text: "two" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await watcher.poll();
|
||||
expect(watcher.getLastRowId()).toBe(2);
|
||||
// Subsequent poll with no new rows yields nothing.
|
||||
const second = await watcher.poll();
|
||||
expect(second).toHaveLength(0);
|
||||
// Insert a new row; lastRowId should advance.
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g3", text: "three" }),
|
||||
cId
|
||||
);
|
||||
const third = await watcher.poll();
|
||||
expect(third).toHaveLength(1);
|
||||
expect(watcher.getLastRowId()).toBe(3);
|
||||
}, 10_000);
|
||||
|
||||
it("includeFromMe=false drops outgoing", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, is_from_me: 1, text: "out" }),
|
||||
cId
|
||||
);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, text: "in" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
includeFromMe: false,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]?.text).toBe("in");
|
||||
// lastRowId still advanced past the filtered row.
|
||||
expect(watcher.getLastRowId()).toBe(2);
|
||||
}, 10_000);
|
||||
|
||||
it("includeFromMe=true keeps outgoing", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, is_from_me: 1, text: "out" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
includeFromMe: true,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs).toHaveLength(1);
|
||||
}, 10_000);
|
||||
|
||||
it("chatFilter restricts emitted messages", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cA = fixture.insertChat(defaultChat({ chat_identifier: "A" }));
|
||||
const cB = fixture.insertChat(
|
||||
defaultChat({ guid: "g-B", chat_identifier: "B" })
|
||||
);
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cA);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "m2" }),
|
||||
cB
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
chatFilter: ["A"],
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs.map((m) => m.chatIdentifier)).toEqual(["A"]);
|
||||
}, 10_000);
|
||||
|
||||
it("attachments are joined and emitted", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
const mId = fixture.insertMessage(
|
||||
defaultMessage({
|
||||
handle_id: hId,
|
||||
cache_has_attachments: 1,
|
||||
}),
|
||||
cId
|
||||
);
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a",
|
||||
filename: "/tmp/p.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 100,
|
||||
transfer_name: "p.jpg",
|
||||
},
|
||||
mId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.attachments).toHaveLength(1);
|
||||
expect(msgs[0]?.attachments[0]?.mimeType).toBe("image/jpeg");
|
||||
}, 10_000);
|
||||
|
||||
it("attachment with unusual mime emits 'other' mime_family", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
const mId = fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, cache_has_attachments: 1 }),
|
||||
cId
|
||||
);
|
||||
// mime_type defaults to application/octet-stream in parser when null.
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "b",
|
||||
filename: null,
|
||||
mime_type: null,
|
||||
total_bytes: 1,
|
||||
transfer_name: null,
|
||||
},
|
||||
mId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.attachments[0]?.mimeType).toBe(
|
||||
"application/octet-stream"
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
it("message with handle_id=0 resolves to null handle", async () => {
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: 0, text: "system" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.senderHandle).toBe("unknown");
|
||||
}, 10_000);
|
||||
|
||||
it("poll rejects on pre-aborted signal", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(
|
||||
watcher.poll({ signal: AbortSignal.abort() })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 5000);
|
||||
|
||||
it("poll aborts mid-iteration when signal fires", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fixture.insertMessage(
|
||||
defaultMessage({
|
||||
handle_id: hId,
|
||||
guid: `g${i}`,
|
||||
text: `m${i}`,
|
||||
}),
|
||||
cId
|
||||
);
|
||||
}
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
// Abort immediately so the loop fires on the first iteration's
|
||||
// check.
|
||||
controller.abort();
|
||||
await expect(
|
||||
watcher.poll({ signal: controller.signal })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 10_000);
|
||||
|
||||
it("start/stop lifecycle with abort signal terminates the loop", async () => {
|
||||
const controller = new AbortController();
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const events: string[] = [];
|
||||
watcher.on("started", () => events.push("started"));
|
||||
watcher.on("stopped", () => events.push("stopped"));
|
||||
await watcher.start({ signal: controller.signal });
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
controller.abort();
|
||||
// Aborting calls stop() synchronously via the abort handler.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
expect(events).toEqual(["started", "stopped"]);
|
||||
}, 10_000);
|
||||
|
||||
it("start rejects when called twice", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await watcher.start();
|
||||
await expect(watcher.start()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherAlreadyRunningError
|
||||
);
|
||||
await watcher.stop();
|
||||
}, 5000);
|
||||
|
||||
it("start with pre-aborted signal throws POLL_ABORTED", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(
|
||||
watcher.start({ signal: AbortSignal.abort() })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 5000);
|
||||
|
||||
it("stop rejects when not running", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(watcher.stop()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherNotRunningError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("scheduler emits error event when underlying poll fails", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 5,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
await watcher.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await watcher.stop();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}, 10_000);
|
||||
|
||||
it("scheduler with signal catches poll rejection", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("scheduled-with-signal");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 5,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
const controller = new AbortController();
|
||||
await watcher.start({ signal: controller.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}, 10_000);
|
||||
|
||||
it("scheduler catch path tolerates non-Error rejection values", async () => {
|
||||
const reader = {
|
||||
open: () => {
|
||||
const literal: unknown = "string-rejection";
|
||||
throw literal;
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 5,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
const controller = new AbortController();
|
||||
await watcher.start({ signal: controller.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}, 10_000);
|
||||
|
||||
it("explicit poll wraps reader errors in IMessageDbQueryFailedError", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("locked");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
await expect(
|
||||
watcher.poll({ signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(IMessageError);
|
||||
}, 5000);
|
||||
|
||||
it("explicit poll without a signal returns [] on reader error", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("transient");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
const result = await watcher.poll();
|
||||
expect(result).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
}, 5000);
|
||||
|
||||
it("legacy on() delegates to the bus", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const seen: ParsedIMessage[] = [];
|
||||
const off = watcher.on("message", (m) => seen.push(m));
|
||||
await watcher.poll();
|
||||
expect(seen).toHaveLength(1);
|
||||
off();
|
||||
// After unregistering, the next poll for a new row should not
|
||||
// deliver.
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g2", text: "t2" }),
|
||||
cId
|
||||
);
|
||||
await watcher.poll();
|
||||
expect(seen).toHaveLength(1);
|
||||
}, 10_000);
|
||||
|
||||
it("off() removes a listener registered via on()", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const seen: ParsedIMessage[] = [];
|
||||
const handler = (m: ParsedIMessage): void => {
|
||||
seen.push(m);
|
||||
};
|
||||
watcher.on("message", handler);
|
||||
watcher.off("message", handler);
|
||||
// Emit directly via the bus.
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
await watcher.poll();
|
||||
expect(seen).toHaveLength(0);
|
||||
}, 5000);
|
||||
|
||||
it("setLastRowId rejects invalid values", () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
expect(() => watcher.setLastRowId(-1)).toThrow(
|
||||
IMessageDbQueryFailedError
|
||||
);
|
||||
expect(() => watcher.setLastRowId(Number.NaN)).toThrow(
|
||||
IMessageDbQueryFailedError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("setLastRowId accepts a positive value", () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
watcher.setLastRowId(42);
|
||||
expect(watcher.getLastRowId()).toBe(42);
|
||||
}, 5000);
|
||||
|
||||
it("poll emits messages_emitted_total + poll_duration_ms via OTel", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g2", text: "two" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const messages = await watcher.poll();
|
||||
expect(messages).toHaveLength(2);
|
||||
// OTel emission is asserted in metrics.test.ts; here we only
|
||||
// confirm the path doesn't throw and yields the expected count.
|
||||
}, 10_000);
|
||||
|
||||
it("chat with null display_name and group_title falls back to null", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.chatDisplayName).toBeNull();
|
||||
}, 10_000);
|
||||
|
||||
it("uses default config when no input provided", () => {
|
||||
// We cannot reach a real chat.db here, but the constructor with
|
||||
// the production reader must not throw on an empty config.
|
||||
const watcher = new IMessageWatcher();
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
expect(watcher.getLastRowId()).toBe(0);
|
||||
}, 5000);
|
||||
|
||||
it("message ROWID > lastRowId filter omits older rows", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
watcher.setLastRowId(5);
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs).toHaveLength(0);
|
||||
}, 10_000);
|
||||
|
||||
it("explicit poll without signal aborted mid-iteration: covers the inner check", async () => {
|
||||
// Insert many rows; abort the controller AFTER opening the
|
||||
// database but BEFORE iteration completes by passing a controller
|
||||
// that aborts immediately.
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fixture.insertMessage(
|
||||
defaultMessage({
|
||||
handle_id: hId,
|
||||
guid: `gx${i}`,
|
||||
text: `tx${i}`,
|
||||
}),
|
||||
cId
|
||||
);
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(
|
||||
watcher.poll({ signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
IMessageDbQueryFailedError,
|
||||
IMessagePollAbortedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
} from "../otel/metrics.js";
|
||||
import { IMessageParser } from "../parser/imessage-parser.js";
|
||||
import { parseWatcherConfig } from "../schemas/schemas.js";
|
||||
import { bunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
IMessageWatcherConfig,
|
||||
IMessageWatcherEvents,
|
||||
ParsedIMessage,
|
||||
} from "../types/imessage.types.js";
|
||||
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
|
||||
|
||||
const DEFAULT_DB_PATH = "~/Library/Messages/chat.db";
|
||||
const DEFAULT_POLL_INTERVAL = 5000;
|
||||
|
||||
interface ResolvedConfig {
|
||||
readonly dbPath: string;
|
||||
readonly pollIntervalMs: number;
|
||||
readonly sinceDate: Date;
|
||||
readonly includeFromMe: boolean;
|
||||
readonly chatFilter: readonly string[];
|
||||
readonly sqliteReader: SqliteReader;
|
||||
}
|
||||
|
||||
export interface IMessageWatcherStartOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface IMessagePollOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls Apple's iMessage chat.db SQLite database on a configurable
|
||||
* interval, projects new rows into `ParsedIMessage`, and emits them on
|
||||
* both a legacy `on/off` listener registry and the typed `EventBus`
|
||||
* exposed as `watcher.bus`.
|
||||
*/
|
||||
export class IMessageWatcher {
|
||||
public readonly bus: EventBus<IMessageWatcherEvents>;
|
||||
private readonly config: ResolvedConfig;
|
||||
private readonly parser: IMessageParser;
|
||||
private lastRowId = 0;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
private abortListener: (() => void) | null = null;
|
||||
private currentSignal: AbortSignal | null = null;
|
||||
|
||||
public constructor(input: IMessageWatcherConfig = {}) {
|
||||
const parsed = parseWatcherConfig(input);
|
||||
this.config = {
|
||||
dbPath: parsed.dbPath ?? DEFAULT_DB_PATH,
|
||||
pollIntervalMs: parsed.pollIntervalMs ?? DEFAULT_POLL_INTERVAL,
|
||||
sinceDate: parsed.sinceDate ?? new Date(),
|
||||
includeFromMe: parsed.includeFromMe ?? false,
|
||||
chatFilter: parsed.chatFilter ?? [],
|
||||
sqliteReader: parsed.sqliteReader ?? bunSqliteReader,
|
||||
};
|
||||
this.parser = new IMessageParser();
|
||||
this.bus = new EventBus<IMessageWatcherEvents>();
|
||||
}
|
||||
|
||||
/** Register a listener (legacy API; delegates to the typed bus). */
|
||||
public on<K extends keyof IMessageWatcherEvents>(
|
||||
event: K,
|
||||
listener: (payload: IMessageWatcherEvents[K]) => void
|
||||
): () => void {
|
||||
return this.bus.on(event, listener);
|
||||
}
|
||||
|
||||
/** Remove a listener (legacy API; delegates to the typed bus). */
|
||||
public off<K extends keyof IMessageWatcherEvents>(
|
||||
event: K,
|
||||
listener: (payload: IMessageWatcherEvents[K]) => void
|
||||
): void {
|
||||
this.bus.off(event, listener);
|
||||
}
|
||||
|
||||
public async start(
|
||||
options: IMessageWatcherStartOptions = {}
|
||||
): Promise<void> {
|
||||
if (this.running) throw new IMessageWatcherAlreadyRunningError();
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) {
|
||||
throw new IMessagePollAbortedError();
|
||||
}
|
||||
this.running = true;
|
||||
this.currentSignal = signal ?? null;
|
||||
if (signal) {
|
||||
const handler = (): void => {
|
||||
void this.stop();
|
||||
};
|
||||
this.abortListener = handler;
|
||||
signal.addEventListener("abort", handler, { once: true });
|
||||
}
|
||||
this.bus.emit("started", undefined);
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.poll(signal ? { signal } : {}).catch((err: unknown) => {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error(String(err));
|
||||
this.bus.emit("error", error);
|
||||
});
|
||||
}, this.config.pollIntervalMs);
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.running) throw new IMessageWatcherNotRunningError();
|
||||
this.running = false;
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
if (this.currentSignal && this.abortListener) {
|
||||
this.currentSignal.removeEventListener("abort", this.abortListener);
|
||||
}
|
||||
this.abortListener = null;
|
||||
this.currentSignal = null;
|
||||
this.bus.emit("stopped", undefined);
|
||||
}
|
||||
|
||||
public async poll(
|
||||
options: IMessagePollOptions = {}
|
||||
): Promise<readonly ParsedIMessage[]> {
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) {
|
||||
recordPollDuration(0, "aborted");
|
||||
throw new IMessagePollAbortedError();
|
||||
}
|
||||
const started = Date.now();
|
||||
let handle: SqliteHandle | null = null;
|
||||
try {
|
||||
handle = this.config.sqliteReader.open(this.config.dbPath, {
|
||||
readonly: true,
|
||||
});
|
||||
const rows = this.queryNewMessages(handle);
|
||||
const parsed: ParsedIMessage[] = [];
|
||||
for (const row of rows) {
|
||||
if (signal?.aborted) {
|
||||
recordPollDuration(Date.now() - started, "aborted");
|
||||
throw new IMessagePollAbortedError();
|
||||
}
|
||||
const handleRow = this.queryHandle(handle, row.handle_id);
|
||||
const chatRow = this.queryChat(handle, row.ROWID);
|
||||
const attachments: readonly AttachmentRow[] =
|
||||
row.cache_has_attachments
|
||||
? this.queryAttachments(handle, row.ROWID)
|
||||
: [];
|
||||
const message = this.parser.parseMessage(
|
||||
row,
|
||||
handleRow,
|
||||
chatRow,
|
||||
attachments
|
||||
);
|
||||
if (row.ROWID > this.lastRowId) {
|
||||
this.lastRowId = row.ROWID;
|
||||
}
|
||||
if (!this.config.includeFromMe && message.isFromMe) continue;
|
||||
if (
|
||||
this.config.chatFilter.length > 0 &&
|
||||
!this.config.chatFilter.includes(message.chatIdentifier)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
parsed.push(message);
|
||||
this.bus.emit("message", message);
|
||||
recordMessageEmitted(
|
||||
message.isGroupChat ? "group" : "direct",
|
||||
message.isFromMe
|
||||
);
|
||||
for (const attachment of message.attachments) {
|
||||
const family = attachment.mimeType.split("/")[0] ?? "other";
|
||||
recordAttachmentEmitted(family);
|
||||
}
|
||||
}
|
||||
recordPollDuration(Date.now() - started, "ok");
|
||||
return parsed;
|
||||
} catch (cause) {
|
||||
recordPollDuration(Date.now() - started, "error");
|
||||
if (cause instanceof IMessagePollAbortedError) {
|
||||
throw cause;
|
||||
}
|
||||
const queryError = new IMessageDbQueryFailedError({ cause });
|
||||
recordPollError(queryError.code);
|
||||
const err =
|
||||
cause instanceof Error ? cause : new Error(String(cause));
|
||||
this.bus.emit("error", err);
|
||||
// Wrap-and-rethrow only when a caller explicitly polled (no
|
||||
// signal-driven internal scheduler); otherwise swallow to
|
||||
// mirror upstream behaviour where the loop keeps ticking.
|
||||
if (signal !== undefined) throw queryError;
|
||||
return [];
|
||||
} finally {
|
||||
if (handle) handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
public getLastRowId(): number {
|
||||
return this.lastRowId;
|
||||
}
|
||||
|
||||
public setLastRowId(rowId: number): void {
|
||||
if (!Number.isFinite(rowId) || rowId < 0) {
|
||||
throw new IMessageDbQueryFailedError({
|
||||
message: "invalid lastRowId",
|
||||
context: { rowId },
|
||||
});
|
||||
}
|
||||
this.lastRowId = rowId;
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private queryNewMessages(handle: SqliteHandle): readonly IMessageRow[] {
|
||||
const stmt = handle.prepare(
|
||||
"SELECT * FROM message WHERE ROWID > ? ORDER BY ROWID ASC LIMIT 100"
|
||||
);
|
||||
return stmt.all(this.lastRowId) as readonly IMessageRow[];
|
||||
}
|
||||
|
||||
private queryHandle(
|
||||
handle: SqliteHandle,
|
||||
handleId: number
|
||||
): HandleRow | null {
|
||||
if (!handleId) return null;
|
||||
const stmt = handle.prepare("SELECT * FROM handle WHERE ROWID = ?");
|
||||
const row = stmt.get(handleId);
|
||||
return (row as HandleRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private queryChat(
|
||||
handle: SqliteHandle,
|
||||
messageRowId: number
|
||||
): ChatRow | null {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT c.* FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
WHERE cmj.message_id = ?`
|
||||
);
|
||||
const row = stmt.get(messageRowId);
|
||||
return (row as ChatRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private queryAttachments(
|
||||
handle: SqliteHandle,
|
||||
messageRowId: number
|
||||
): readonly AttachmentRow[] {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT a.* FROM attachment a
|
||||
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?`
|
||||
);
|
||||
return stmt.all(messageRowId) as readonly AttachmentRow[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
IMessagePollOptions,
|
||||
IMessageWatcherStartOptions,
|
||||
} from "./imessage-watcher.js";
|
||||
export { IMessageWatcher } from "./imessage-watcher.js";
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/imessage-adapter.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module loads, so the meter resolved at module load is the real
|
||||
* provider's meter (and not the noop fallback). Tests inspect emitted
|
||||
* instruments by calling `flushTestMetrics()`.
|
||||
*
|
||||
* Server-tier (bun:sqlite required): no DOM, no browser globals.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics(): Promise<
|
||||
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
|
||||
> {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/imessage-adapter",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"bun:sqlite",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user