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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export type { ChatDbFixture } from "./chat-db-fixture.js";
export { createChatDbFixture } from "./chat-db-fixture.js";