Initial Commit
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user