Files
fusion/packages/core/src/message-store.ts
Drew Donaldson e74183359a fix(postgres): sanitize NUL bytes in chat/mailbox writes; retry embedded-PG join race (#2366)
## Summary

Chat messages, chat room messages, and agent/user mailbox sends could
crash mid-conversation when the persisted content or metadata contained
a raw U+0000 (NUL) byte — e.g. Windows CLI diagnostic/tool output piped
directly into a message body. PostgreSQL text/jsonb columns reject NUL
outright (`unsupported Unicode escape sequence` / `\u0000 cannot be
converted to text`), which surfaced as an uncaught `PostgresError` that
aborted the write and killed the conversation turn.

A NUL-byte sanitizer already existed for the one-time SQLite →
PostgreSQL first-boot migration (`sqlite-migrator.ts`'s
`stripNulChars`/`deepStripNulChars`), but it was never wired into the
**live** write paths — only into that one-shot migration.

## What changed

- Extracted `stripNulChars`/`deepStripNulChars` into a shared
`packages/core/src/postgres/nul-sanitize.ts` module
(`sqlite-migrator.ts` now imports from it instead of defining its own
copy).
- Wired sanitization into the three live write paths that persist
free-form content/metadata:
  - `async-chat-store.ts`: `addChatMessage`, `addChatRoomMessage`
  - `async-message-store.ts`: `sendMessage`
- Each of these functions now also **returns the sanitized value** —
previously they returned the original, unsanitized input object even
though the sanitized value is what was actually persisted to the
database, which was a latent inconsistency I found while adding test
coverage.

## Bonus fix: embedded-Postgres startup race

While rebuilding and testing this locally via `pnpm smoke:boot`, I hit a
separate, pre-existing, reproducible race: a process joining an existing
embedded-Postgres data dir (via `postmaster.pid`, per the existing
`FNXC:PostgresStartupRace 2026-07-15-20:45` comment in
`embedded-lifecycle.ts`) can race the true owner's TCP listener bind and
get `ECONNREFUSED` on its very first connection attempt.
`bootSchemaBackendOnce` turned this into a hard `startup-factory: failed
to initialize PostgreSQL schema backend` failure with no retry.

I verified this is **not** caused by my NUL-sanitize change — it
reproduces identically on unmodified `main` (confirmed via `git stash`).

Added `JoinedInstanceUnreachableError` and one retry (mirroring the
existing `NonUtf8EmbeddedClusterError` one-retry pattern already in the
same file) instead of failing the whole boot outright.

## Tests

- New unit tests for the shared sanitizer:
`packages/core/src/__tests__/nul-sanitize.test.ts` (10 tests, including
a regression test reproducing the exact production failure signature).
- New PostgreSQL integration test coverage in the existing `.pg.test.ts`
suites, reproducing the exact production failure payload for both
`addChatMessage` and `sendMessage` and asserting both the in-memory
return value and the re-read-from-database value are NUL-free.
- Verified end-to-end against a real, disposable PostgreSQL 16 instance
(outside the vitest harness, since this dev machine lacked a local
`psql`/`pg_dump` client at the time) using a standalone script that
calls the actual patched functions with the production crash payload —
all checks passed before and after the return-value fix was added.
- `pnpm --filter @fusion/core typecheck` clean.

## Changeset

Included (`patch`, category `fix`).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Prevented crashes and PostgreSQL insertion failures when chat or
mailbox content/JSON metadata contains raw NUL (`U+0000`) bytes.
* NUL characters are now stripped from message text and deeply from
nested metadata (including JSON object keys) before writes, and
sanitized values are reflected in returned messages.
* Improved embedded PostgreSQL startup reliability by retrying once on
transient joined-instance connection-refused failures.
* **Tests**
* Added unit and PostgreSQL regression coverage for NUL sanitization
across message/chat paths and for the embedded startup retry scenario.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 00:00:08 -07:00

745 lines
29 KiB
TypeScript

/**
* MessageStore - SQLite-based persistence for the messaging system.
*
* Messages are stored in the `messages` table with indexed lookups
* for inbox/outbox/conversation queries.
*
* Follows the same patterns as ChatStore:
* - EventEmitter for change notifications
* - SQLite for structured data storage (synchronous)
* - JSON columns for optional metadata
*/
import { EventEmitter } from "node:events";
import { createHash, randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import { createLogger } from "./logger.js";
import { DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, type Message, type MessageCreateInput, type MessageFilter, type MessageType, type Mailbox, type ParticipantType } from "./types.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import * as asyncMessageStore from "./async-message-store.js";
import { sanitizeTextValue, sanitizeJsonbValue } from "./postgres/nul-sanitize.js";
const messageStoreLog = createLogger("message-store");
// ── Event Types ─────────────────────────────────────────────────────
/** Events emitted by MessageStore */
export interface MessageStoreEvents {
/** Emitted when a new message is created and sent */
"message:sent": [message: Message];
/** Emitted when a message is received by a participant */
"message:received": [message: Message];
/** Emitted when a message is marked as read */
"message:read": [message: Message];
/** Emitted when a message is deleted */
"message:deleted": [messageId: string];
/** Emitted when proposal metadata changes without creating a new message. */
"message:updated": [message: Message];
}
// ── Row Interfaces ───────────────────────────────────────────────────
/** Database row shape for the messages table. */
interface MessageRow {
id: string;
fromId: string;
fromType: string;
toId: string;
toType: string;
content: string;
type: string;
read: number;
metadata: string | null;
createdAt: string;
updatedAt: string;
}
// ── Options Types ────────────────────────────────────────────────────
/** Options for MessageStore constructor */
export interface MessageStoreOptions {
/**
* Optional hook invoked when a message is addressed to an agent.
* FNXC:PostgresBackend 2026-06-28-10:20: widened to allow an async hook so the
* agent wake-on-message delivery (agent-heartbeat.handleMessageToAgent) can read
* the AgentStore via its async PG-capable path. The hook is awaited inside a
* try/catch — a rejected wake hook is logged and degraded, never failing the send.
*/
onMessageToAgent?: (message: Message) => void | Promise<void>;
}
// ── MessageStore Class ───────────────────────────────────────────────
/**
* MessageStore manages messages between agents, users, and the system.
* Uses SQLite for persistent storage with efficient indexed queries.
*
* FNXC:MessageStore 2026-06-24-12:30:
* Backend dual-path: when an `AsyncDataLayer` is provided (PostgreSQL backend
* active), every method delegates to the async-message-store helpers against
* PostgreSQL. When absent, the legacy sync SQLite path runs byte-identically.
*/
export class MessageStore extends EventEmitter<MessageStoreEvents> {
private onMessageToAgent?: (message: Message) => void | Promise<void>;
private readonly asyncLayer: AsyncDataLayer | null;
// Prepared statements for frequently-run queries (SQLite path only)
private stmtInsert!: ReturnType<Database["prepare"]>;
private stmtInsertOnce!: ReturnType<Database["prepare"]>;
private stmtGetById!: ReturnType<Database["prepare"]>;
private stmtUpdateRead!: ReturnType<Database["prepare"]>;
private stmtDelete!: ReturnType<Database["prepare"]>;
constructor(
private db: Database | null,
options?: MessageStoreOptions & { asyncLayer?: AsyncDataLayer | null },
) {
super();
this.setMaxListeners(100);
this.onMessageToAgent = options?.onMessageToAgent;
this.asyncLayer = options?.asyncLayer ?? null;
if (this.asyncLayer) {
// Backend mode: no prepared statements needed; data access is async.
return;
}
// Prepare frequently-run statements (SQLite path)
const sqliteDb = this.db!;
this.stmtInsert = sqliteDb.prepare(`
INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.stmtInsertOnce = sqliteDb.prepare(`
INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.stmtGetById = sqliteDb.prepare(`
SELECT * FROM messages WHERE id = ?
`);
this.stmtUpdateRead = sqliteDb.prepare(`
UPDATE messages SET read = 1, updatedAt = ? WHERE id = ?
`);
this.stmtDelete = sqliteDb.prepare(`
DELETE FROM messages WHERE id = ?
`);
}
/** True when the store is backed by PostgreSQL (AsyncDataLayer present). */
isBackendMode(): boolean {
return this.asyncLayer !== null;
}
// ── Row-to-Object Converters ───────────────────────────────────────
/**
* Convert a database row to a Message object.
*/
private rowToMessage(row: MessageRow): Message {
return {
id: row.id,
fromId: row.fromId,
fromType: row.fromType as ParticipantType,
toId: row.toId,
toType: row.toType as ParticipantType,
content: row.content,
type: row.type as MessageType,
read: row.read === 1,
metadata: fromJson<Message["metadata"]>(row.metadata),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Public API ────────────────────────────────────────────────────
/**
* Create and store a new message.
* @param input - Message creation parameters
* @returns The created message
*/
async sendMessage(input: MessageCreateInput): Promise<Message> {
validateMessageMetadata(input.metadata);
const now = new Date().toISOString();
const messageId = `msg-${randomUUID().slice(0, 8)}`;
const from = normalizeMessageParticipant(input.fromId ?? "system", input.fromType ?? "system");
const to = normalizeMessageParticipant(input.toId, input.toType);
const message: Message = {
id: messageId,
fromId: from.id,
fromType: from.type,
toId: to.id,
toType: to.type,
// FNXC:PostgresMigrationNulSanitize 2026-07-21: sanitize here so the
// exact same object is persisted, emitted (message:sent/message:received,
// consumed by the agent wake hook), and returned to the caller. The
// async-message-store.ts sendMessage() layer also sanitizes before its
// own insert, but this class discarded that function's return value and
// used its own locally-built (unsanitized) `message` object for
// everything else — this closes that gap.
content: sanitizeTextValue(input.content),
type: input.type,
read: false,
metadata: sanitizeJsonbValue(input.metadata),
createdAt: now,
updatedAt: now,
};
if (this.asyncLayer) {
const layer = this.asyncLayer;
await asyncMessageStore.sendMessage(layer.db, {
id: message.id,
fromId: message.fromId,
fromType: message.fromType,
toId: message.toId,
toType: message.toType,
content: message.content,
type: message.type,
read: message.read,
metadata: message.metadata ?? null,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
});
} else {
this.stmtInsert.run(
message.id,
message.fromId,
message.fromType,
message.toId,
message.toType,
message.content,
message.type,
message.read ? 1 : 0,
toJsonNullable(message.metadata),
message.createdAt,
message.updatedAt,
);
this.db!.bumpLastModified();
}
messageStoreLog.log(`MessageStore emitting message:sent id=${message.id} type=${message.type} fromId=${message.fromId} toId=${message.toId}`);
this.emit("message:sent", message);
this.emit("message:received", message);
if (message.toType === "agent" && this.onMessageToAgent) {
// FNXC:PostgresBackend 2026-06-28-10:20:
// The agent-delivery hook (agent-heartbeat.handleMessageToAgent) is now async
// and PG-capable: it reads the AgentStore via its async getAgent path, so
// wake-on-message works in PG backend mode. The message is already persisted
// at this point, so a wake-hook failure (sync throw OR rejected promise) must
// NOT fail the send — await inside try/catch, then log and degrade rather
// than 500.
try {
await this.onMessageToAgent(message);
} catch (err) {
messageStoreLog.warn(
`MessageStore onMessageToAgent hook failed for id=${message.id} (send still succeeded): ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
return message;
}
/**
* Atomically send one message for a globally scoped idempotency key.
*
* FNXC:PostgresMigrationInbox 2026-07-14-12:10:
* Once-only system notices use a deterministic message primary key and each backend's conflict-ignore insert. This closes the check-then-send race between concurrent engine starts while preserving normal MessageStore events only for the winning insert.
*/
async sendMessageOnce(
input: MessageCreateInput,
idempotencyKey: string,
): Promise<{ message: Message; inserted: boolean }> {
validateMessageMetadata(input.metadata);
const now = new Date().toISOString();
const digest = createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 24);
const from = normalizeMessageParticipant(input.fromId ?? "system", input.fromType ?? "system");
const to = normalizeMessageParticipant(input.toId, input.toType);
const message: Message = {
id: `msg-once-${digest}`,
fromId: from.id,
fromType: from.type,
toId: to.id,
toType: to.type,
// FNXC:PostgresMigrationNulSanitize 2026-07-21: see sendMessage() above
// for why this must sanitize here rather than rely solely on
// asyncMessageStore.sendMessageOnce's own internal sanitization —
// that function only returns a boolean, so this class's locally-built
// `message` object is what actually gets emitted/returned.
content: sanitizeTextValue(input.content),
type: input.type,
read: false,
metadata: sanitizeJsonbValue(input.metadata),
createdAt: now,
updatedAt: now,
};
let inserted: boolean;
if (this.asyncLayer) {
inserted = await asyncMessageStore.sendMessageOnce(this.asyncLayer.db, {
...message,
metadata: message.metadata ?? null,
});
} else {
const result = this.stmtInsertOnce.run(
message.id,
message.fromId,
message.fromType,
message.toId,
message.toType,
message.content,
message.type,
0,
toJsonNullable(message.metadata),
message.createdAt,
message.updatedAt,
);
inserted = result.changes === 1;
if (inserted) this.db!.bumpLastModified();
}
if (!inserted) return { message, inserted: false };
this.emit("message:sent", message);
this.emit("message:received", message);
if (message.toType === "agent" && this.onMessageToAgent) {
try {
await this.onMessageToAgent(message);
} catch (err) {
messageStoreLog.warn(
`MessageStore onMessageToAgent hook failed for once-only id=${message.id} (send still succeeded): ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
return { message, inserted: true };
}
/**
* Get a single message by ID.
* @param id - The message ID
* @returns The message, or null if not found
*/
async getMessage(id: string): Promise<Message | null> {
if (this.asyncLayer) {
return asyncMessageStore.getMessage(this.asyncLayer.db, id);
}
const row = this.stmtGetById.get(id) as unknown as MessageRow | undefined;
if (!row) return null;
return this.rowToMessage(row);
}
/** Atomically acquire a pending proposal's transient creation lease. */
async claimProposalForCreation(messageId: string): Promise<{ claimed: boolean; idempotencyKey?: string; claimOwnerToken?: string }> {
if (this.asyncLayer) {
const result = await asyncMessageStore.claimProposalForCreation(this.asyncLayer.db, messageId);
if (result.message) this.emit("message:updated", result.message);
return result;
}
const owner = randomUUID();
const now = new Date().toISOString();
// FNXC:EphemeralAgentTaskCreation 2026-07-30-16:00: SQLite-compatible stores persist the lease start with its owner, allowing a later operator request to recover a dead creator without changing the stable idempotency key.
const changed = this.db!.prepare(`UPDATE messages SET metadata = json_set(metadata, '$.proposalStatus', 'creating', '$.claimOwnerToken', ?, '$.claimStartedAt', ?), updatedAt = ? WHERE id = ? AND json_extract(metadata, '$.kind') = 'task-proposal' AND json_extract(metadata, '$.proposalStatus') = 'pending'`).run(owner, now, now, messageId).changes;
if (!changed) return { claimed: false };
this.db!.bumpLastModified();
const message = await this.getMessage(messageId);
if (message) this.emit("message:updated", message);
return { claimed: true, idempotencyKey: message?.metadata?.proposalIdempotencyKey as string | undefined, claimOwnerToken: owner };
}
async finalizeProposalCreation(messageId: string, claimOwnerToken: string, createdTaskId: string): Promise<Message | null> {
if (this.asyncLayer) {
const message = await asyncMessageStore.finalizeProposalCreation(this.asyncLayer.db, messageId, claimOwnerToken, createdTaskId);
if (message) this.emit("message:updated", message);
return message;
}
const existing = await this.getMessage(messageId);
if (existing?.metadata?.proposalStatus === "created" && existing.metadata.createdTaskId === createdTaskId) return existing;
const changed = this.db!.prepare(`UPDATE messages SET metadata = json_set(metadata, '$.proposalStatus', 'created', '$.createdTaskId', ?, '$.claimOwnerToken', null, '$.claimStartedAt', null), updatedAt = ? WHERE id = ? AND json_extract(metadata, '$.proposalStatus') = 'creating' AND json_extract(metadata, '$.claimOwnerToken') = ?`).run(createdTaskId, new Date().toISOString(), messageId, claimOwnerToken).changes;
if (!changed) return null;
this.db!.bumpLastModified(); const message = await this.getMessage(messageId); if (message) this.emit("message:updated", message); return message;
}
async releaseProposalClaim(messageId: string, claimOwnerToken: string): Promise<Message | null> {
if (this.asyncLayer) { const message = await asyncMessageStore.releaseProposalClaim(this.asyncLayer.db, messageId, claimOwnerToken); if (message) this.emit("message:updated", message); return message; }
const changed = this.db!.prepare(`UPDATE messages SET metadata = json_set(metadata, '$.proposalStatus', 'pending', '$.claimOwnerToken', null, '$.claimStartedAt', null), updatedAt = ? WHERE id = ? AND json_extract(metadata, '$.proposalStatus') = 'creating' AND json_extract(metadata, '$.claimOwnerToken') = ?`).run(new Date().toISOString(), messageId, claimOwnerToken).changes;
if (!changed) return null; this.db!.bumpLastModified(); const message = await this.getMessage(messageId); if (message) this.emit("message:updated", message); return message;
}
async reconcileProposalCreation(messageId: string, resolvedTaskId: string | undefined): Promise<Message | null> {
const message = await this.getMessage(messageId);
if (!message || message.metadata?.proposalStatus !== "creating") return message;
if (resolvedTaskId) return this.finalizeProposalCreation(messageId, message.metadata.claimOwnerToken ?? "", resolvedTaskId);
return this.releaseProposalClaim(messageId, message.metadata.claimOwnerToken ?? "");
}
/**
* Get inbox messages for a participant (messages where they are the recipient).
* @param ownerId - The participant ID
* @param ownerType - The participant type
* @param filter - Optional filter criteria
* @returns Array of messages (newest first)
*/
async getInbox(
ownerId: string,
ownerType: ParticipantType,
filter?: MessageFilter,
): Promise<Message[]> {
if (this.asyncLayer) {
return asyncMessageStore.queryMessagesByParticipant(this.asyncLayer.db, "to", ownerId, ownerType, filter);
}
return this.queryMessagesByParticipant("to", ownerId, ownerType, filter);
}
/**
* Get outbox messages for a participant (messages they sent).
* @param ownerId - The participant ID
* @param ownerType - The participant type
* @param filter - Optional filter criteria
* @returns Array of messages (newest first)
*/
async getOutbox(
ownerId: string,
ownerType: ParticipantType,
filter?: MessageFilter,
): Promise<Message[]> {
if (this.asyncLayer) {
return asyncMessageStore.queryMessagesByParticipant(this.asyncLayer.db, "from", ownerId, ownerType, filter);
}
return this.queryMessagesByParticipant("from", ownerId, ownerType, filter);
}
private getParticipantIdsForLookup(ownerId: string, ownerType: ParticipantType): string[] {
if (ownerType === "user" && ownerId === DASHBOARD_USER_ID) {
return [DASHBOARD_USER_ID, "user", "user:dashboard", "User: user:dashboard"];
}
return [ownerId];
}
private queryMessagesByParticipant(
direction: "to" | "from",
ownerId: string,
ownerType: ParticipantType,
filter?: MessageFilter,
): Message[] {
const idCol = direction === "to" ? "toId" : "fromId";
const typeCol = direction === "to" ? "toType" : "fromType";
const participantIds = this.getParticipantIdsForLookup(ownerId, ownerType);
const idPredicate = participantIds.length === 1
? `${idCol} = ?`
: `${idCol} IN (${participantIds.map(() => "?").join(", ")})`;
const whereClauses: string[] = [idPredicate, `${typeCol} = ?`];
const params: (string | number)[] = [...participantIds, ownerType];
if (filter?.type) {
whereClauses.push("type = ?");
params.push(filter.type);
}
if (filter?.read !== undefined) {
whereClauses.push("read = ?");
params.push(filter.read ? 1 : 0);
}
const whereSql = whereClauses.join(" AND ");
const limit = filter?.limit ?? 100;
const offset = filter?.offset ?? 0;
const rows = this.db!.prepare(`
SELECT * FROM messages
WHERE ${whereSql}
ORDER BY createdAt DESC, rowid DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return (rows as unknown as MessageRow[]).map((row) => this.rowToMessage(row));
}
/**
* Mark a message as read.
* @param messageId - The message ID
* @returns The updated message
* @throws Error if message not found
*/
async markAsRead(messageId: string): Promise<Message> {
if (this.asyncLayer) {
const updated = await asyncMessageStore.markMessageAsRead(this.asyncLayer.db, messageId);
if (!updated) throw new Error(`Message ${messageId} not found`);
this.emit("message:read", updated);
return updated;
}
// First check if the message exists
const existing = await this.getMessage(messageId);
if (!existing) {
throw new Error(`Message ${messageId} not found`);
}
if (existing.read) return existing;
const now = new Date().toISOString();
this.stmtUpdateRead.run(now, messageId);
this.db!.bumpLastModified();
const updated = await this.getMessage(messageId);
this.emit("message:read", updated!);
return updated!;
}
/**
* Mark all inbox messages as read for a participant.
* @param ownerId - The participant ID
* @param ownerType - The participant type
* @returns Number of messages marked as read
*/
async markAllAsRead(
ownerId: string,
ownerType: ParticipantType,
): Promise<number> {
if (this.asyncLayer) {
return asyncMessageStore.markAllMessagesAsRead(this.asyncLayer.db, ownerId, ownerType);
}
const now = new Date().toISOString();
const participantIds = this.getParticipantIdsForLookup(ownerId, ownerType);
const toIdPredicate = participantIds.length === 1
? "toId = ?"
: `toId IN (${participantIds.map(() => "?").join(", ")})`;
// Get count of unread messages before updating
const unreadRow = this.db!.prepare(`
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0
`).get(...participantIds, ownerType) as { count: number } | undefined;
const count = unreadRow?.count ?? 0;
// Mark all as read
this.db!.prepare(`
UPDATE messages SET read = 1, updatedAt = ? WHERE ${toIdPredicate} AND toType = ? AND read = 0
`).run(now, ...participantIds, ownerType);
this.db!.bumpLastModified();
return count;
}
/**
* Delete a message by ID.
* @param id - The message ID
* @throws Error if message not found
*/
async deleteMessage(id: string): Promise<void> {
if (this.asyncLayer) {
const existing = await this.getMessage(id);
if (!existing) {
throw new Error(`Message ${id} not found`);
}
await asyncMessageStore.deleteMessage(this.asyncLayer.db, id);
this.emit("message:deleted", id);
return;
}
// First check if the message exists
const existing = await this.getMessage(id);
if (!existing) {
throw new Error(`Message ${id} not found`);
}
this.stmtDelete.run(id);
this.db!.bumpLastModified();
this.emit("message:deleted", id);
}
/**
* Delete messages older than a max inactivity threshold (by updatedAt).
* @param maxAgeMs - Inactivity threshold in milliseconds
* @returns Number of deleted messages
*/
async cleanupOldMessages(maxAgeMs: number): Promise<{ messagesDeleted: number }> {
if (this.asyncLayer) {
const layer = this.asyncLayer;
const deletedIds = await asyncMessageStore.cleanupOldMessages(layer, maxAgeMs);
for (const id of deletedIds) {
this.emit("message:deleted", id);
}
messageStoreLog.log(`cleanupOldMessages deleted=${deletedIds.length}`);
return { messagesDeleted: deletedIds.length };
}
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
return { messagesDeleted: 0 };
}
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
const deletedIds = this.db!.transaction(() => {
const rows = this.db!.prepare(`
DELETE FROM messages
WHERE updatedAt < ?
RETURNING id
`).all(cutoff) as Array<{ id: string }>;
return rows.map((row) => row.id);
});
if (deletedIds.length === 0) {
messageStoreLog.log(`cleanupOldMessages deleted=0 cutoff=${cutoff}`);
return { messagesDeleted: 0 };
}
for (const id of deletedIds) {
this.emit("message:deleted", id);
}
this.db!.bumpLastModified();
messageStoreLog.log(`cleanupOldMessages deleted=${deletedIds.length} cutoff=${cutoff}`);
return { messagesDeleted: deletedIds.length };
}
/**
* Get messages between two participants (conversation view).
*
* FNXC:MessageStorePerf 2026-07-11 (PR #1793 review):
* Capped to the most recent `options.limit` messages (default 200) — the
* unbounded read fed the CLI chat's AI context with the FULL history on
* every exchange. Results stay oldest-first.
*
* @param participantA - First participant
* @param participantB - Second participant
* @param options - Optional `limit` override for the most-recent-N cap
* @returns Array of messages (oldest first for conversation ordering)
*/
async getConversation(
participantA: { id: string; type: ParticipantType },
participantB: { id: string; type: ParticipantType },
options?: { limit?: number },
): Promise<Message[]> {
if (this.asyncLayer) {
return asyncMessageStore.getConversation(this.asyncLayer.db, participantA, participantB, options);
}
const participantAIds = this.getParticipantIdsForLookup(participantA.id, participantA.type);
const participantBIds = this.getParticipantIdsForLookup(participantB.id, participantB.type);
const participantAFromPredicate = participantAIds.length === 1
? "fromId = ?"
: `fromId IN (${participantAIds.map(() => "?").join(", ")})`;
const participantAToPredicate = participantAIds.length === 1
? "toId = ?"
: `toId IN (${participantAIds.map(() => "?").join(", ")})`;
const participantBFromPredicate = participantBIds.length === 1
? "fromId = ?"
: `fromId IN (${participantBIds.map(() => "?").join(", ")})`;
const participantBToPredicate = participantBIds.length === 1
? "toId = ?"
: `toId IN (${participantBIds.map(() => "?").join(", ")})`;
// Find messages where either participant is sender or receiver
const rows = this.db!.prepare(`
SELECT * FROM messages
WHERE (
(${participantAFromPredicate} AND fromType = ? AND ${participantBToPredicate} AND toType = ?)
OR
(${participantBFromPredicate} AND fromType = ? AND ${participantAToPredicate} AND toType = ?)
)
ORDER BY createdAt DESC
LIMIT ?
`).all(
...participantAIds,
participantA.type,
...participantBIds,
participantB.type,
...participantBIds,
participantB.type,
...participantAIds,
participantA.type,
Math.max(1, options?.limit ?? asyncMessageStore.DEFAULT_CONVERSATION_LIMIT),
);
return (rows as unknown as MessageRow[]).reverse().map((row) => this.rowToMessage(row));
}
/**
* Get mailbox summary for a participant.
* @param ownerId - The participant ID
* @param ownerType - The participant type
* @returns Mailbox summary with unread count and last message
*/
async getMailbox(
ownerId: string,
ownerType: ParticipantType,
): Promise<Mailbox> {
if (this.asyncLayer) {
const summary = await asyncMessageStore.getMailbox(this.asyncLayer.db, ownerId, ownerType);
return {
ownerId: summary.ownerId,
ownerType: summary.ownerType,
unreadCount: summary.unreadCount,
lastMessage: summary.lastMessage,
};
}
const participantIds = this.getParticipantIdsForLookup(ownerId, ownerType);
const toIdPredicate = participantIds.length === 1
? "toId = ?"
: `toId IN (${participantIds.map(() => "?").join(", ")})`;
const unreadRow = this.db!.prepare(`
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0
`).get(...participantIds, ownerType) as { count: number } | undefined;
const unreadCount = unreadRow?.count ?? 0;
const lastRow = this.db!.prepare(`
SELECT * FROM messages WHERE ${toIdPredicate} AND toType = ? ORDER BY createdAt DESC, rowid DESC LIMIT 1
`).get(...participantIds, ownerType) as unknown as MessageRow | undefined;
const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined;
return {
ownerId,
ownerType,
unreadCount,
lastMessage,
};
}
/**
* Get all agent-to-agent messages across all agents.
* @returns Array of messages (newest first)
*/
async getAllAgentToAgentMessages(): Promise<Message[]> {
if (this.asyncLayer) {
return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db);
}
const rows = this.db!.prepare(`
SELECT * FROM messages
WHERE type = ?
ORDER BY createdAt DESC, rowid DESC
`).all("agent-to-agent");
return (rows as unknown as MessageRow[]).map((row) => this.rowToMessage(row));
}
/**
* Get unread count across all agent-to-agent messages.
*/
async getUnreadAgentToAgentCount(): Promise<number> {
if (this.asyncLayer) {
return asyncMessageStore.getUnreadAgentToAgentCount(this.asyncLayer.db);
}
const row = this.db!.prepare(`
SELECT COUNT(*) as count FROM messages
WHERE type = ? AND read = 0
`).get("agent-to-agent") as { count: number } | undefined;
return row?.count ?? 0;
}
/**
* Set or update the hook used when messages are sent to agents.
*/
setMessageToAgentHook(hook: (message: Message) => void | Promise<void>): void {
this.onMessageToAgent = hook;
}
}