* bots: document APIGetChats command and CRApiChats response * bots: regenerate API docs and TypeScript types * simplex-chat-nodejs: add apiGetChats * support bot: avoid OOM on large databases apiListGroups / apiListContacts return every record in one response and overflow V8's string allocation on large DBs. Replace list-then-find-by-id patterns with apiGetChat(type, id, 0) lookups, and the one genuine scan (refreshAllCards) with paginated apiGetChats, count=1000. * support bot: update test assertions to match current message text * bots: simplify PaginationByTime, expose only PTLast * simplex-chat-nodejs: bump types and nodejs versions
16 KiB
Plan: Fix support-bot crash on large databases — use pagination and direct lookup
Context
The simplex-support-bot crashes during startup against large production databases:
[2026-04-30T15:52:53.498Z] Grok contact from state: ID=142676
[2026-04-30T15:52:53.498Z] Resolving team group...
<anonymous_script>:0
[Error: Unknown failure]
The crash happens inside chat.apiListGroups(mainUser.userId) at
apps/simplex-support-bot/src/index.ts:215. The native binding marshals the
Haskell core's response to a JS string at
packages/simplex-chat-nodejs/cpp/simplex.cc:255 (chat_send_cmd) →
HandleCResult (line 157) → Napi::String::New in OnOK. When the response
exceeds V8's max string length (~512 MB on 64-bit), N-API string allocation
fails. The literal string "Unknown failure" does not appear anywhere in
this repo — confirmed by full-tree search — so the message originates from V8
or N-API internals rather than the binding's own error path (which would say
chat_send_cmd failed). Hypothesis: oversized string allocation throws a JS
exception that propagates up unannotated.
Two distinct misuse patterns drive the payload size:
A. List-then-find by ID (most call sites). The bot pulls every contact /
every group with apiListContacts / apiListGroups, then calls find(...)
to locate one record by a known ID. This is gratuitous — there is already
apiGetChat(chatType, chatId, count=0) (packages/simplex-chat-nodejs/src/api.ts:819)
that returns one AChat whose chatInfo carries the full GroupInfo /
Contact (with customData) and zero items. The Haskell parser accepts
count=0 (src/Simplex/Chat/Library/Commands.hs:5210), and
getDirectChatLast_ / getGroupChatLast_ return empty chatItems with full
chatInfo.
B. Genuine multi-record scan (one site).
apps/simplex-support-bot/src/cards.ts:131 (refreshAllCards) enumerates
groups where customData.cardItemId && !complete to refresh in-flight cards
on restart. The Haskell side already supports paginated scans via
APIGetChats (/_get chats {userId} pcc=on|off count=N,
src/Simplex/Chat/Library/Commands.hs:4868). It is currently in
undocumentedCommands (bots/src/API/Docs/Commands.hs:360), so the codegen
does not emit it for the TypeScript bot library. Confirmed: the chat preview
returned by getChatPreviews carries customData on GroupInfo
(src/Simplex/Chat/Store/Shared.hs:685, toGroupInfo).
Active card state is already tracked on each group via customData.cardItemId
and customData.complete (written through apiSetGroupCustomData at
apps/simplex-support-bot/src/cards.ts:103,231). No state.json schema
change is needed — phase 3 reads exactly the same customData it already
writes, just via paginated APIGetChats instead of a full apiListGroups.
Per the constraint, apiListContacts / apiListGroups stay in the nodejs
library unchanged for other consumers. Audit confirmed no callers outside
support-bot use them today.
Phase 1 — Plumb APIGetChats through the bot library
The codegen pipeline is test-driven: tests/APIDocs.hs:41–44 invokes
testGenerate against the functions in
bots/src/API/Docs/Generate/TypeScript.hs, which writes to:
packages/simplex-chat-client/types/typescript/src/commands.tspackages/simplex-chat-client/types/typescript/src/responses.tspackages/simplex-chat-client/types/typescript/src/types.ts
Run via cabal test. The published @simplex-chat/types npm package is
built from this TypeScript source; the copy under
packages/simplex-chat-nodejs/node_modules/@simplex-chat/types/dist/ is a
downstream build artifact and is not edited directly.
Currently missing from generated TS:
T.PaginationByTime, T.ChatListQuery, CC.APIGetChats, and the
apiChats response tag on ChatResponse.
1.1 bots/src/API/Docs/Commands.hs
-
Remove
"APIGetChats",fromundocumentedCommands(line 360). -
Add an entry under "Chat commands" (next to
APIListContacts/APIListGroupsat lines 145–146). Match the Haskell parser atsrc/Simplex/Chat/Library/Commands.hs:4868:( "APIGetChats", [], "Get chat previews. Supports time-based pagination — use this " <> "instead of APIListContacts / APIListGroups when scanning at scale.", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> Optional "" (" " <> Param "$0") "pagination" <> Optional "" (" " <> Json "$0") "query" )Note: the
querysegment uses" " <> Json "$0"(no"json "prefix) — the parser acceptsA.space *> jsonPdirectly.
1.2 bots/src/API/Docs/Types.hs
The type universe already references PaginationByTime and ChatListQuery
in commented form (lines 381, 390 and 592, 602). Uncomment all four lines.
Confirm the constructor-prefix encoding (STRecord/STUnion, prefix
""/"CLQ") matches the existing definitions in
src/Simplex/Chat/Controller.hs:992,998 and the JSON deriving at line 1661
(sumTypeJSON $ dropPrefix "CLQ").
1.3 bots/src/API/Docs/Responses.hs
- Uncomment
("CRApiChats", "...")at line 100. - Remove
"CRApiChats",fromundocumentedResponsesat line 123.
1.4 Regenerate TypeScript types
Run cabal test (the APIDocs test suite drives generation). Inspect the
diffs in packages/simplex-chat-client/types/typescript/src/{commands,responses,types}.ts.
Verify:
T.PaginationByTime(sum type withPTLast/PTAfter/PTBefore) exists with a generatedcmdString. Compare wire format against the HaskellpaginationByTimePatsrc/Simplex/Chat/Library/Commands.hs:5216:count=N|after=TS count=N|before=TS count=N.T.ChatListQueryexists withCLQFilters/CLQSearchJSON-encoded variants.CC.APIGetChats.cmdString({userId, pendingConnections, pagination, query})exists and emits the expected wire format.r.type === "apiChats"withr.chats: T.AChat[]exists in the response union (dropsCRprefix persumTypeJSON,src/Simplex/Chat/Controller.hs:1743).
Bump @simplex-chat/types version and re-link / re-build the
simplex-chat-nodejs package so the new symbols are available.
1.5 packages/simplex-chat-nodejs/src/api.ts
Add a single method next to apiListGroups (line 761):
/**
* Get chat previews (paginated).
* Network usage: no.
*
* Prefer this over apiListContacts / apiListGroups for any scan: those
* methods load the entire history into memory and will fail on large DBs.
*/
async apiGetChats(
userId: number,
pagination: T.PaginationByTime,
query: T.ChatListQuery = {type: "filters", favorite: false, unread: false},
pendingConnections = false,
): Promise<T.AChat[]> {
const r = await this.sendChatCmd(
CC.APIGetChats.cmdString({userId, pendingConnections, pagination, query})
)
if (r.type === "apiChats") return r.chats
throw new ChatCommandError("error getting chats", r)
}
(Exact T.PaginationByTime / T.ChatListQuery shapes come from the codegen
output of phase 1.4 — verify the discriminator field names before locking
this signature.)
Phase 2 — Replace list-then-find with direct lookup
For every site below, replace apiList…().find(…) with
apiGetChat(ChatType.X, id, 0). Treat "not found" — the chat was deleted —
as a clean missing-record case (log + skip). The wire format
/_get chat #{id} count=0 is already supported.
2.1 Error matcher
The Haskell SEContactNotFound / SEGroupNotFound (in
src/Simplex/Chat/Store/Shared.hs:863 and elsewhere) surface to TS as:
err.chatError?.type === "errorStore"
&& err.chatError.storeError.type === "groupNotFound" // or "contactNotFound"
Both discriminators are already present in the generated types
(types.d.ts:2825 and :2788). Add a small helper in
apps/simplex-support-bot/src/util.ts:
export function isChatNotFound(err: unknown, kind: "group" | "contact"): boolean {
if (!(err instanceof core.ChatAPIError)) return false
if (err.chatError?.type !== "errorStore") return false
const seType = err.chatError.storeError.type
return kind === "group" ? seType === "groupNotFound" : seType === "contactNotFound"
}
(Strict — does not swallow other errorStore variants.)
2.2 Ergonomic wrappers
Add two thin helpers in apps/simplex-support-bot/src/util.ts (the constraint
forbids touching apiListContacts / apiListGroups in the nodejs library;
keeping these helpers in the support-bot util keeps the library surface
unchanged):
export async function getGroupInfo(chat: api.ChatApi, groupId: number): Promise<T.GroupInfo | null> {
try {
const c = await chat.apiGetChat(T.ChatType.Group, groupId, 0)
return c.chatInfo.type === "group" ? c.chatInfo.groupInfo : null
} catch (err) {
if (isChatNotFound(err, "group")) return null
throw err
}
}
export async function getContact(chat: api.ChatApi, contactId: number): Promise<T.Contact | null> {
try {
const c = await chat.apiGetChat(T.ChatType.Direct, contactId, 0)
return c.chatInfo.type === "direct" ? c.chatInfo.contact : null
} catch (err) {
if (isChatNotFound(err, "contact")) return null
throw err
}
}
2.3 Call-site changes
All sites must keep their existing withMainProfile / profileMutex
wrapping where present.
apps/simplex-support-bot/src/index.ts:165–180(Grok contact resolution). Drop theapiListContacts(mainUser.userId)call entirely. Ifstate.grokContactIdis set, callgetContact(chat, state.grokContactId)insideprofileMutex.runExclusive. Preserve the existing log lines.apps/simplex-support-bot/src/index.ts:306–320(team member validation). Loop andgetContact(chat, member.id)per member. ComparedisplayNameas before. Team rosters are small; N round-trips are fine.apps/simplex-support-bot/src/index.ts:213–227(team group resolution). ReplaceapiListGroups+findwithgetGroupInfo(chat, state.teamGroupId). Preserve the "create new group" fallback when the lookup returnsnull.apps/simplex-support-bot/src/bot.ts:796–805(handleJoinCommand). Replace withgetGroupInfo(chat, targetGroupId); samebusinessChatvalidation.apps/simplex-support-bot/src/cards.ts:120(flushOne). DirectgetGroupInfo(chat, groupId)(still insidewithMainProfile).apps/simplex-support-bot/src/cards.ts:213(getRawCustomData). Direct lookup. Hot path — called on everymergeCustomData/clearCustomData. Largest single win.apps/simplex-support-bot/src/cards.ts:251(updateCard). Direct lookup. The "Read customData and groupInfo in one apiListGroups call" comment goes away.
After phase 2 the bot can boot and operate steadily on a large DB; phase 3 is purely about startup reconciliation.
Phase 3 — Paginate refreshAllCards
apps/simplex-support-bot/src/cards.ts:131 is the only legitimate
multi-record scan. Convert it to a single bounded apiGetChats call:
async refreshAllCards(): Promise<void> {
// Scan the most recently active 1000 chats. Active cards live on
// recently-active customer chats by definition — a card stays open while
// the conversation is in flight. If the bot has been offline long enough
// that an active card has fallen outside the recent-1000 window, that
// card refreshes lazily on the next customer message (which moves the
// chat back into the recent window).
const chats = await this.withMainProfile(() =>
this.chat.apiGetChats(
this.mainUserId,
{type: "last", count: 1000},
)
)
const activeCards: {groupId: number; cardItemId: number}[] = []
for (const c of chats) {
if (c.chatInfo.type !== "group") continue
const customData = c.chatInfo.groupInfo.customData as Record<string, unknown> | undefined
if (customData
&& typeof customData.cardItemId === "number"
&& !customData.complete) {
activeCards.push({
groupId: c.chatInfo.groupInfo.groupId,
cardItemId: customData.cardItemId,
})
}
}
// (sort and refresh loop unchanged)
}
count = 1000 per the constraint. No state.json schema change. Card
status remains entirely on the group's customData (cardItemId,
complete), which is what the bot already reads and writes.
Phase 4 — Verification
4.1 Stress test
Existing tests use MockChatApi (apps/simplex-support-bot/bot.test.ts:24),
which is in-memory and won't exercise the native binding. A meaningful
stress test needs a real ChatApi.init against Postgres.
Add a new test file (e.g.
apps/simplex-support-bot/test/stress.test.ts) that:
- Starts an ephemeral Postgres or uses an existing test DB.
- Calls
ChatApi.initand seeds N synthetic groups + contacts via the chat API. (No existing seeding helper — write one.) Reasonable N: 20k each, large enough to expose the marshaling cliff but not so large that the test takes minutes. - Boots the support-bot main flow against this DB and asserts: startup completes within a wall-clock budget; resident memory stays bounded; no native error.
This is new infrastructure — keep scope tight. If standing up Postgres in CI is too heavy, run as a manual stress harness rather than a CI test.
4.2 Production replay
Replay against (a copy of) the affected production DB. Confirm the bot starts and the team group / Grok contact / team members all resolve.
4.3 Smoke tests
Existing functional flows via bot.test.ts continue to pass after the
phase-2 changes. Manually exercise:
- Business-request acceptance.
/joinvalidation (the changedbot.ts:799path).- Card create/update/complete cycle (
cards.tshot path). - Restart-time card refresh (
refreshAllCards).
Risks and footguns
/_get chatsparser default isPTLast 5000(src/Simplex/Chat/Library/Commands.hs:4872). Even 5000 previews can be heavy. Support-bot now always passes an explicitcount=1000, but the default itself remains a footgun for other callers — flag for follow-up; not changed here.apiListMembersis per-group, not per-DB. Used atbot.ts:629,825andcards.ts:165. Bounded by group membership, not history size, so out of scope for this fix. Flag if customer groups grow huge (>1000 members) — would warrant a paginated members API at that point.- Codegen output sanity. Phase 1.4 must be inspected by hand — the
generated
cmdStringforT.PaginationByTimeand ther.type/r.chatsshape on the response side are the integration points the rest of the plan depends on. Do not skip eyeballing the diff. apiGetChat(..., 0)semantics on a non-existent chatId. Verified: the error tag ischatError.type === "errorStore"withstoreError.type === "groupNotFound"or"contactNotFound". Both discriminators already exist in the generated types (types.d.ts:2825,2788).isChatNotFoundmatches them precisely; do not loosen it.- Native binding crash hypothesis is unverified. The literal "Unknown
failure" string is not in this tree. Most likely V8/N-API surfacing a
string-allocation or JSON-parse failure. The fix in this plan addresses
the proximate cause (oversized response payload) regardless of the exact
surfacing path; if the same error reappears after the fix, dig into the
binding's
OnOKhandler to add explicit size-check / better diagnostics. @simplex-chat/typespackage version bump. Phase 1.4 produces TypeScript changes inpackages/simplex-chat-client/types/typescript/. Bumping the version and re-publishing (or rebuilding locally) is required before phase 1.5 lands. Coordinate the release sequence.
Out of scope
- Deprecating or paginating
apiListContacts/apiListGroupsin the nodejs library. They stay as-is; only support-bot stops calling them. - Lowering the
/_get chatsparser default fromPTLast 5000. - Adding a paginated members API.
- Native binding diagnostics for oversized responses.