Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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);
});

View File

@@ -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)
);
}
}

View File

@@ -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";